@rulebricks/cli 1.9.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.
Files changed (93) hide show
  1. package/README.md +62 -0
  2. package/dist/commands/clone.d.ts +6 -0
  3. package/dist/commands/clone.js +60 -0
  4. package/dist/commands/deploy.d.ts +8 -0
  5. package/dist/commands/deploy.js +409 -0
  6. package/dist/commands/destroy.d.ts +8 -0
  7. package/dist/commands/destroy.js +298 -0
  8. package/dist/commands/init.d.ts +7 -0
  9. package/dist/commands/init.js +201 -0
  10. package/dist/commands/logs.d.ts +9 -0
  11. package/dist/commands/logs.js +222 -0
  12. package/dist/commands/open.d.ts +7 -0
  13. package/dist/commands/open.js +139 -0
  14. package/dist/commands/status.d.ts +5 -0
  15. package/dist/commands/status.js +125 -0
  16. package/dist/commands/upgrade.d.ts +7 -0
  17. package/dist/commands/upgrade.js +239 -0
  18. package/dist/components/DNSWaitScreen.d.ts +9 -0
  19. package/dist/components/DNSWaitScreen.js +73 -0
  20. package/dist/components/Wizard/WizardContext.d.ts +176 -0
  21. package/dist/components/Wizard/WizardContext.js +346 -0
  22. package/dist/components/Wizard/index.d.ts +2 -0
  23. package/dist/components/Wizard/index.js +2 -0
  24. package/dist/components/Wizard/steps/CloudProviderStep.d.ts +6 -0
  25. package/dist/components/Wizard/steps/CloudProviderStep.js +210 -0
  26. package/dist/components/Wizard/steps/CredentialsStep.d.ts +6 -0
  27. package/dist/components/Wizard/steps/CredentialsStep.js +22 -0
  28. package/dist/components/Wizard/steps/DatabaseStep.d.ts +6 -0
  29. package/dist/components/Wizard/steps/DatabaseStep.js +80 -0
  30. package/dist/components/Wizard/steps/DeploymentModeStep.d.ts +5 -0
  31. package/dist/components/Wizard/steps/DeploymentModeStep.js +26 -0
  32. package/dist/components/Wizard/steps/DomainStep.d.ts +6 -0
  33. package/dist/components/Wizard/steps/DomainStep.js +126 -0
  34. package/dist/components/Wizard/steps/FeatureConfigStep.d.ts +6 -0
  35. package/dist/components/Wizard/steps/FeatureConfigStep.js +765 -0
  36. package/dist/components/Wizard/steps/FeaturesStep.d.ts +6 -0
  37. package/dist/components/Wizard/steps/FeaturesStep.js +119 -0
  38. package/dist/components/Wizard/steps/ReviewStep.d.ts +6 -0
  39. package/dist/components/Wizard/steps/ReviewStep.js +56 -0
  40. package/dist/components/Wizard/steps/SMTPStep.d.ts +6 -0
  41. package/dist/components/Wizard/steps/SMTPStep.js +191 -0
  42. package/dist/components/Wizard/steps/SupabaseCredentialsStep.d.ts +6 -0
  43. package/dist/components/Wizard/steps/SupabaseCredentialsStep.js +76 -0
  44. package/dist/components/Wizard/steps/TierStep.d.ts +6 -0
  45. package/dist/components/Wizard/steps/TierStep.js +29 -0
  46. package/dist/components/Wizard/steps/VersionStep.d.ts +6 -0
  47. package/dist/components/Wizard/steps/VersionStep.js +113 -0
  48. package/dist/components/Wizard/steps/index.d.ts +12 -0
  49. package/dist/components/Wizard/steps/index.js +12 -0
  50. package/dist/components/common/AppShell.d.ts +31 -0
  51. package/dist/components/common/AppShell.js +31 -0
  52. package/dist/components/common/Box.d.ts +20 -0
  53. package/dist/components/common/Box.js +20 -0
  54. package/dist/components/common/Logo.d.ts +7 -0
  55. package/dist/components/common/Logo.js +22 -0
  56. package/dist/components/common/Spinner.d.ts +12 -0
  57. package/dist/components/common/Spinner.js +28 -0
  58. package/dist/components/common/index.d.ts +6 -0
  59. package/dist/components/common/index.js +5 -0
  60. package/dist/index.d.ts +2 -0
  61. package/dist/index.js +202 -0
  62. package/dist/lib/cloudCli.d.ts +156 -0
  63. package/dist/lib/cloudCli.js +691 -0
  64. package/dist/lib/config.d.ts +91 -0
  65. package/dist/lib/config.js +278 -0
  66. package/dist/lib/dns.d.ts +41 -0
  67. package/dist/lib/dns.js +235 -0
  68. package/dist/lib/dockerHub.d.ts +57 -0
  69. package/dist/lib/dockerHub.js +128 -0
  70. package/dist/lib/helm.d.ts +53 -0
  71. package/dist/lib/helm.js +209 -0
  72. package/dist/lib/helmValues.d.ts +17 -0
  73. package/dist/lib/helmValues.js +693 -0
  74. package/dist/lib/kubernetes.d.ts +161 -0
  75. package/dist/lib/kubernetes.js +755 -0
  76. package/dist/lib/terraform.d.ts +44 -0
  77. package/dist/lib/terraform.js +230 -0
  78. package/dist/lib/theme.d.ts +81 -0
  79. package/dist/lib/theme.js +115 -0
  80. package/dist/lib/validation.d.ts +47 -0
  81. package/dist/lib/validation.js +164 -0
  82. package/dist/lib/versions.d.ts +69 -0
  83. package/dist/lib/versions.js +139 -0
  84. package/dist/types/index.d.ts +718 -0
  85. package/dist/types/index.js +556 -0
  86. package/email-templates/email_change.html +325 -0
  87. package/email-templates/invite.html +383 -0
  88. package/email-templates/password_change.html +414 -0
  89. package/email-templates/verify.html +396 -0
  90. package/package.json +78 -0
  91. package/terraform/aws/main.tf +327 -0
  92. package/terraform/azure/main.tf +326 -0
  93. package/terraform/gcp/main.tf +369 -0
@@ -0,0 +1,755 @@
1
+ import { execa } from "execa";
2
+ import { DEFAULT_NAMESPACE } from "../types/index.js";
3
+ /**
4
+ * Extracts meaningful error message from execa error
5
+ */
6
+ function getErrorMessage(error) {
7
+ const execaError = error;
8
+ const output = execaError.stderr || execaError.stdout || "";
9
+ if (output) {
10
+ const truncated = output.length > 500 ? "..." + output.slice(-500) : output;
11
+ return truncated;
12
+ }
13
+ return execaError.shortMessage || execaError.message || "Unknown error";
14
+ }
15
+ /**
16
+ * Sleep for a specified number of milliseconds
17
+ */
18
+ function sleep(ms) {
19
+ return new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+ /**
22
+ * Checks if kubectl is installed
23
+ */
24
+ export async function isKubectlInstalled() {
25
+ try {
26
+ await execa("kubectl", ["version", "--client"]);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /**
34
+ * Gets the kubectl client version
35
+ */
36
+ export async function getKubectlVersion() {
37
+ const { stdout } = await execa("kubectl", [
38
+ "version",
39
+ "--client",
40
+ "-o",
41
+ "json",
42
+ ]);
43
+ const info = JSON.parse(stdout);
44
+ return info.clientVersion.gitVersion;
45
+ }
46
+ /**
47
+ * Checks if the cluster is accessible
48
+ */
49
+ export async function isClusterAccessible() {
50
+ try {
51
+ await execa("kubectl", ["cluster-info"]);
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ /**
59
+ * Checks if the cluster is accessible and returns error details if not.
60
+ * Returns null if accessible, or an error message string if not.
61
+ */
62
+ export async function checkClusterAccessible() {
63
+ try {
64
+ await execa("kubectl", ["cluster-info"]);
65
+ return null;
66
+ }
67
+ catch (error) {
68
+ const execaError = error;
69
+ // Build helpful error message with context
70
+ const parts = [];
71
+ // Get current context for debugging
72
+ let currentContext = "";
73
+ try {
74
+ const { stdout: context } = await execa("kubectl", [
75
+ "config",
76
+ "current-context",
77
+ ]);
78
+ currentContext = context.trim();
79
+ parts.push(`Current context: ${currentContext}`);
80
+ }
81
+ catch {
82
+ parts.push("No kubectl context is currently set");
83
+ }
84
+ // Include the actual error output (but truncate the repetitive memcache errors)
85
+ const stderr = execaError.stderr?.trim() || "";
86
+ const stdout = execaError.stdout?.trim() || "";
87
+ const rawOutput = stderr || stdout;
88
+ // Clean up verbose/repetitive kubectl errors
89
+ const outputLines = rawOutput.split("\n");
90
+ const seenErrors = new Set();
91
+ const cleanedLines = outputLines.filter((line) => {
92
+ // Skip repetitive memcache errors, keep just one
93
+ if (line.includes("memcache.go") && line.includes("Unhandled Error")) {
94
+ const key = "memcache-unhandled";
95
+ if (seenErrors.has(key))
96
+ return false;
97
+ seenErrors.add(key);
98
+ return false; // Skip all memcache lines, they're noise
99
+ }
100
+ return true;
101
+ });
102
+ const output = cleanedLines.join("\n").trim();
103
+ if (output) {
104
+ parts.push(`Error: ${output}`);
105
+ }
106
+ else if (execaError.message) {
107
+ parts.push(`Error: ${execaError.message}`);
108
+ }
109
+ // Detect specific error patterns and provide targeted suggestions
110
+ const isEksCluster = currentContext.includes("eks") || currentContext.includes("arn:aws");
111
+ const isGkeCluster = currentContext.includes("gke_") || currentContext.includes("gke-");
112
+ const isAksCluster = currentContext.includes("aks") || currentContext.includes("azure");
113
+ const isCredentialsError = rawOutput.includes("provide credentials") ||
114
+ rawOutput.includes("Unauthorized") ||
115
+ rawOutput.includes("authentication");
116
+ const isConnectionError = rawOutput.includes("connection refused") ||
117
+ rawOutput.includes("no such host") ||
118
+ rawOutput.includes("timeout");
119
+ parts.push("");
120
+ parts.push("Suggestions:");
121
+ if (isEksCluster && isCredentialsError) {
122
+ // EKS-specific authentication issue
123
+ parts.push(" • Verify AWS credentials are configured: aws sts get-caller-identity");
124
+ parts.push(" • Check if AWS CLI profile matches: aws configure list");
125
+ parts.push(" • Refresh kubeconfig: aws eks update-kubeconfig --name <cluster-name> --region <region>");
126
+ parts.push(" • Ensure your IAM user/role has EKS cluster access permissions");
127
+ parts.push(" • If using SSO, refresh session: aws sso login --profile <profile>");
128
+ }
129
+ else if (isGkeCluster && isCredentialsError) {
130
+ // GKE-specific authentication issue
131
+ parts.push(" • Verify gcloud auth: gcloud auth list");
132
+ parts.push(" • Refresh credentials: gcloud container clusters get-credentials <cluster> --region <region>");
133
+ parts.push(" • Check project: gcloud config get-value project");
134
+ }
135
+ else if (isAksCluster && isCredentialsError) {
136
+ // AKS-specific authentication issue
137
+ parts.push(" • Verify Azure CLI login: az account show");
138
+ parts.push(" • Refresh credentials: az aks get-credentials --name <cluster> --resource-group <rg>");
139
+ }
140
+ else if (isConnectionError) {
141
+ // Connection/network issues
142
+ parts.push(" • Check if the cluster is running and accessible");
143
+ parts.push(" • Verify network connectivity to the cluster endpoint");
144
+ parts.push(" • Check if VPN connection is required");
145
+ }
146
+ else {
147
+ // Generic suggestions
148
+ parts.push(" • Verify your kubeconfig is correct: kubectl config view");
149
+ parts.push(" • Check the current context: kubectl config current-context");
150
+ parts.push(" • Test cluster access: kubectl cluster-info");
151
+ parts.push(" • Ensure your credentials are valid and not expired");
152
+ }
153
+ return parts.join("\n");
154
+ }
155
+ }
156
+ /**
157
+ * Gets the current kubectl context
158
+ */
159
+ export async function getCurrentContext() {
160
+ try {
161
+ const { stdout } = await execa("kubectl", ["config", "current-context"]);
162
+ return stdout.trim();
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ /**
169
+ * Gets pod status for the Rulebricks namespace
170
+ */
171
+ export async function getPodStatus(namespace = DEFAULT_NAMESPACE) {
172
+ try {
173
+ const { stdout } = await execa("kubectl", [
174
+ "get",
175
+ "pods",
176
+ "-n",
177
+ namespace,
178
+ "-o",
179
+ "json",
180
+ ]);
181
+ const data = JSON.parse(stdout);
182
+ return data.items.map((pod) => ({
183
+ name: pod.metadata.name,
184
+ status: pod.status.phase,
185
+ ready: pod.status.containerStatuses?.every((c) => c.ready) ?? false,
186
+ restarts: pod.status.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0,
187
+ }));
188
+ }
189
+ catch {
190
+ return [];
191
+ }
192
+ }
193
+ /**
194
+ * Gets service status for the Rulebricks namespace
195
+ */
196
+ export async function getServiceStatus(namespace = DEFAULT_NAMESPACE) {
197
+ try {
198
+ const { stdout } = await execa("kubectl", [
199
+ "get",
200
+ "services",
201
+ "-n",
202
+ namespace,
203
+ "-o",
204
+ "json",
205
+ ]);
206
+ const data = JSON.parse(stdout);
207
+ return data.items.map((svc) => ({
208
+ name: svc.metadata.name,
209
+ type: svc.spec.type,
210
+ ports: svc.spec.ports?.map((p) => p.port) ?? [],
211
+ externalIP: svc.status.loadBalancer?.ingress?.[0]?.hostname ||
212
+ svc.status.loadBalancer?.ingress?.[0]?.ip ||
213
+ null,
214
+ }));
215
+ }
216
+ catch {
217
+ return [];
218
+ }
219
+ }
220
+ /**
221
+ * Gets ingress status for the Rulebricks namespace
222
+ */
223
+ export async function getIngressStatus(namespace = DEFAULT_NAMESPACE) {
224
+ try {
225
+ const { stdout } = await execa("kubectl", [
226
+ "get",
227
+ "ingress",
228
+ "-n",
229
+ namespace,
230
+ "-o",
231
+ "json",
232
+ ]);
233
+ const data = JSON.parse(stdout);
234
+ return data.items.map((ing) => ({
235
+ name: ing.metadata.name,
236
+ hosts: ing.spec.rules?.map((r) => r.host) ?? [],
237
+ tls: (ing.spec.tls?.length ?? 0) > 0,
238
+ address: ing.status.loadBalancer?.ingress?.[0]?.hostname ||
239
+ ing.status.loadBalancer?.ingress?.[0]?.ip ||
240
+ null,
241
+ }));
242
+ }
243
+ catch {
244
+ return [];
245
+ }
246
+ }
247
+ /**
248
+ * Gets certificate status
249
+ */
250
+ export async function getCertificateStatus(namespace = DEFAULT_NAMESPACE) {
251
+ try {
252
+ const { stdout } = await execa("kubectl", [
253
+ "get",
254
+ "certificates",
255
+ "-n",
256
+ namespace,
257
+ "-o",
258
+ "json",
259
+ ]);
260
+ const data = JSON.parse(stdout);
261
+ return data.items.map((cert) => ({
262
+ name: cert.metadata.name,
263
+ dnsNames: cert.spec.dnsNames ?? [],
264
+ ready: cert.status.conditions?.some((c) => c.type === "Ready" && c.status === "True") ?? false,
265
+ }));
266
+ }
267
+ catch {
268
+ return [];
269
+ }
270
+ }
271
+ /**
272
+ * Streams logs from a pod
273
+ */
274
+ export async function streamLogs(podName, namespace = DEFAULT_NAMESPACE, options = {}) {
275
+ const { follow = false, tail = 100, container } = options;
276
+ const args = ["logs", podName, "-n", namespace];
277
+ if (follow) {
278
+ args.push("-f");
279
+ }
280
+ if (tail) {
281
+ args.push("--tail", String(tail));
282
+ }
283
+ if (container) {
284
+ args.push("-c", container);
285
+ }
286
+ await execa("kubectl", args, { stdio: "inherit" });
287
+ }
288
+ /**
289
+ * Colors for multi-pod log prefixes
290
+ */
291
+ const POD_COLORS = [
292
+ "\x1b[36m", // cyan
293
+ "\x1b[33m", // yellow
294
+ "\x1b[35m", // magenta
295
+ "\x1b[32m", // green
296
+ "\x1b[34m", // blue
297
+ "\x1b[91m", // bright red
298
+ "\x1b[92m", // bright green
299
+ "\x1b[93m", // bright yellow
300
+ ];
301
+ const RESET_COLOR = "\x1b[0m";
302
+ /**
303
+ * Streams logs from multiple pods simultaneously.
304
+ * Each log line is prefixed with the pod name and a unique color.
305
+ * Returns a cleanup function to stop all log streams.
306
+ */
307
+ export function streamMultiPodLogs(podNames, namespace, options = {}) {
308
+ const { follow = true, tail = 100, timestamps = false, onLine } = options;
309
+ const processes = [];
310
+ // Spawn a kubectl logs process for each pod
311
+ podNames.forEach((podName, index) => {
312
+ const args = ["logs", podName, "-n", namespace];
313
+ if (follow) {
314
+ args.push("-f");
315
+ }
316
+ if (tail) {
317
+ args.push("--tail", String(tail));
318
+ }
319
+ if (timestamps) {
320
+ args.push("--timestamps");
321
+ }
322
+ const colorIndex = index % POD_COLORS.length;
323
+ const color = POD_COLORS[colorIndex];
324
+ // Shorten pod name for display (take last 2 segments or truncate)
325
+ const shortName = shortenPodName(podName);
326
+ const proc = execa("kubectl", args);
327
+ processes.push(proc);
328
+ // Handle stdout line by line
329
+ if (proc.stdout) {
330
+ let buffer = "";
331
+ proc.stdout.on("data", (chunk) => {
332
+ buffer += chunk.toString();
333
+ const lines = buffer.split("\n");
334
+ // Keep the last incomplete line in buffer
335
+ buffer = lines.pop() || "";
336
+ for (const line of lines) {
337
+ if (line.trim()) {
338
+ if (onLine) {
339
+ onLine(podName, line, colorIndex);
340
+ }
341
+ else {
342
+ // Default: print to stdout with colored prefix
343
+ const prefix = `${color}[${shortName}]${RESET_COLOR}`;
344
+ console.log(`${prefix} ${line}`);
345
+ }
346
+ }
347
+ }
348
+ });
349
+ // Flush any remaining buffer on close
350
+ proc.stdout.on("close", () => {
351
+ if (buffer.trim()) {
352
+ if (onLine) {
353
+ onLine(podName, buffer, colorIndex);
354
+ }
355
+ else {
356
+ const prefix = `${color}[${shortName}]${RESET_COLOR}`;
357
+ console.log(`${prefix} ${buffer}`);
358
+ }
359
+ }
360
+ });
361
+ }
362
+ // Handle stderr - print errors but continue
363
+ if (proc.stderr) {
364
+ proc.stderr.on("data", (chunk) => {
365
+ const errLine = chunk.toString().trim();
366
+ if (errLine) {
367
+ console.error(`${color}[${shortName}]${RESET_COLOR} \x1b[31m${errLine}${RESET_COLOR}`);
368
+ }
369
+ });
370
+ }
371
+ // Ignore process exit errors (happens on cleanup)
372
+ proc.catch(() => { });
373
+ });
374
+ // Return cleanup function
375
+ return () => {
376
+ for (const proc of processes) {
377
+ try {
378
+ proc.kill("SIGTERM");
379
+ }
380
+ catch {
381
+ // Process may have already exited
382
+ }
383
+ }
384
+ };
385
+ }
386
+ /**
387
+ * Shortens a pod name for display in log prefixes.
388
+ * E.g., "rulebricks-app-7f8b9c6d5-x2k4m" -> "app-x2k4m"
389
+ */
390
+ function shortenPodName(podName) {
391
+ const parts = podName.split("-");
392
+ if (parts.length >= 3) {
393
+ // Try to find the component name and keep it with the random suffix
394
+ // Pattern: <release>-<component>-<hash>-<suffix> or <component>-<hash>-<suffix>
395
+ const suffix = parts[parts.length - 1];
396
+ // Find meaningful component name (skip 'rulebricks' prefix)
397
+ let componentIndex = 0;
398
+ if (parts[0] === "rulebricks" || parts[0].length > 10) {
399
+ componentIndex = 1;
400
+ }
401
+ const component = parts[componentIndex] || parts[0];
402
+ return `${component}-${suffix}`;
403
+ }
404
+ // If name is short enough, return as-is
405
+ return podName.length > 20 ? podName.substring(0, 17) + "..." : podName;
406
+ }
407
+ /**
408
+ * Gets pods by label selector
409
+ */
410
+ export async function getPodsByLabel(labelSelector, namespace = DEFAULT_NAMESPACE) {
411
+ try {
412
+ const { stdout } = await execa("kubectl", [
413
+ "get",
414
+ "pods",
415
+ "-n",
416
+ namespace,
417
+ "-l",
418
+ labelSelector,
419
+ "-o",
420
+ "jsonpath={.items[*].metadata.name}",
421
+ ]);
422
+ return stdout.split(" ").filter(Boolean);
423
+ }
424
+ catch {
425
+ return [];
426
+ }
427
+ }
428
+ /**
429
+ * List of valid component names for log viewing
430
+ */
431
+ export const VALID_LOG_COMPONENTS = [
432
+ "app",
433
+ "hps",
434
+ "workers",
435
+ "kafka",
436
+ "supabase",
437
+ "traefik",
438
+ ];
439
+ /**
440
+ * Pod name patterns for each component.
441
+ * Used to filter pods by name when label selectors may vary.
442
+ */
443
+ const COMPONENT_POD_PATTERNS = {
444
+ app: ["app", "rulebricks-app"],
445
+ hps: ["hps", "rulebricks-hps"],
446
+ workers: ["hps-worker", "worker"],
447
+ kafka: ["kafka"],
448
+ supabase: ["supabase", "db", "postgres"],
449
+ traefik: ["traefik"],
450
+ };
451
+ /**
452
+ * Gets pods for a specific component in a deployment.
453
+ * Queries all pods in the namespace and filters by component name patterns.
454
+ * This approach works for all components including subcharts like Traefik
455
+ * that may have different instance labels than the parent release.
456
+ */
457
+ export async function getComponentPods(component, _releaseName, namespace) {
458
+ if (!VALID_LOG_COMPONENTS.includes(component)) {
459
+ return [];
460
+ }
461
+ try {
462
+ // Get all pods in the namespace - subcharts like Traefik may have
463
+ // different instance labels, so we can't rely on a single label selector
464
+ const { stdout } = await execa("kubectl", [
465
+ "get",
466
+ "pods",
467
+ "-n",
468
+ namespace,
469
+ "-o",
470
+ "jsonpath={.items[*].metadata.name}",
471
+ ]);
472
+ const pods = stdout.split(" ").filter(Boolean);
473
+ // Filter pods by component name patterns
474
+ const patterns = COMPONENT_POD_PATTERNS[component] || [component];
475
+ const matchingPods = pods.filter((podName) => {
476
+ const lowerPodName = podName.toLowerCase();
477
+ return patterns.some((pattern) => lowerPodName.includes(pattern.toLowerCase()));
478
+ });
479
+ return matchingPods;
480
+ }
481
+ catch {
482
+ return [];
483
+ }
484
+ }
485
+ /**
486
+ * Deletes a namespace
487
+ */
488
+ export async function deleteNamespace(namespace, options = {}) {
489
+ const { wait = false } = options;
490
+ try {
491
+ const args = ["delete", "namespace", namespace];
492
+ if (wait) {
493
+ args.push("--wait=true");
494
+ }
495
+ // 60 second timeout to prevent hanging
496
+ await execa("kubectl", args, { timeout: 60000 });
497
+ }
498
+ catch (error) {
499
+ const execaError = error;
500
+ const errorMsg = execaError.stderr || execaError.message || "";
501
+ // Ignore "not found" errors and timeouts - namespace may already be deleted
502
+ if (!errorMsg.includes("not found") && !execaError.timedOut) {
503
+ throw new Error(`Failed to delete namespace:\n${getErrorMessage(error)}`);
504
+ }
505
+ }
506
+ }
507
+ /**
508
+ * Deletes all PVCs in a namespace
509
+ */
510
+ export async function deletePVCs(namespace, options = {}) {
511
+ const { wait = false } = options;
512
+ try {
513
+ const args = ["delete", "pvc", "--all", "-n", namespace];
514
+ if (wait) {
515
+ args.push("--wait=true");
516
+ }
517
+ // 60 second timeout to prevent hanging
518
+ await execa("kubectl", args, { timeout: 60000 });
519
+ }
520
+ catch (error) {
521
+ const execaError = error;
522
+ const errorMsg = execaError.stderr || execaError.message || "";
523
+ // Ignore "not found" errors, "No resources", and timeouts
524
+ if (!errorMsg.includes("not found") &&
525
+ !errorMsg.includes("No resources found") &&
526
+ !execaError.timedOut) {
527
+ throw new Error(`Failed to delete PVCs:\n${getErrorMessage(error)}`);
528
+ }
529
+ }
530
+ }
531
+ /**
532
+ * Removes finalizers from KEDA ScaledObjects to prevent namespace deletion from hanging.
533
+ * KEDA finalizers wait for the KEDA controller to clean up, but if KEDA is being deleted
534
+ * with the namespace, this causes a deadlock.
535
+ */
536
+ export async function removeKedaFinalizers(namespace) {
537
+ try {
538
+ // Get all ScaledObjects in the namespace
539
+ const { stdout } = await execa("kubectl", [
540
+ "get",
541
+ "scaledobjects.keda.sh",
542
+ "-n",
543
+ namespace,
544
+ "-o",
545
+ "jsonpath={.items[*].metadata.name}",
546
+ ], { timeout: 15000 });
547
+ const scaledObjects = stdout.split(" ").filter(Boolean);
548
+ // Patch each ScaledObject to remove finalizers
549
+ for (const name of scaledObjects) {
550
+ try {
551
+ await execa("kubectl", [
552
+ "patch",
553
+ "scaledobject",
554
+ name,
555
+ "-n",
556
+ namespace,
557
+ "-p",
558
+ '{"metadata":{"finalizers":null}}',
559
+ "--type=merge",
560
+ ], { timeout: 15000 });
561
+ }
562
+ catch {
563
+ // Ignore errors - object might already be deleted
564
+ }
565
+ }
566
+ }
567
+ catch {
568
+ // Ignore errors - KEDA CRDs might not be installed
569
+ }
570
+ }
571
+ /**
572
+ * Checks if a namespace exists
573
+ */
574
+ export async function namespaceExists(namespace) {
575
+ try {
576
+ await execa("kubectl", ["get", "namespace", namespace], { timeout: 15000 });
577
+ return true;
578
+ }
579
+ catch {
580
+ return false;
581
+ }
582
+ }
583
+ /**
584
+ * Waits for cluster to be accessible with retries.
585
+ * EKS IAM authentication can take time to propagate after cluster creation.
586
+ */
587
+ export async function waitForClusterAccess(maxRetries = 30, delayMs = 10000) {
588
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
589
+ try {
590
+ await execa("kubectl", ["cluster-info"]);
591
+ return; // Success
592
+ }
593
+ catch (error) {
594
+ if (attempt === maxRetries) {
595
+ throw new Error(`Cluster not accessible after ${maxRetries} attempts. ` +
596
+ `EKS IAM authentication may not have propagated yet. ` +
597
+ `Please wait a few minutes and try again.\n${getErrorMessage(error)}`);
598
+ }
599
+ // Wait before next retry
600
+ await sleep(delayMs);
601
+ }
602
+ }
603
+ }
604
+ /**
605
+ * Creates default StorageClass for the cloud provider.
606
+ * Should be called after kubeconfig is configured and cluster is accessible.
607
+ */
608
+ export async function createDefaultStorageClass(provider) {
609
+ // First wait for cluster to be accessible
610
+ await waitForClusterAccess();
611
+ let storageClassYaml;
612
+ switch (provider) {
613
+ case "aws":
614
+ storageClassYaml = `
615
+ apiVersion: storage.k8s.io/v1
616
+ kind: StorageClass
617
+ metadata:
618
+ name: gp3
619
+ annotations:
620
+ storageclass.kubernetes.io/is-default-class: "true"
621
+ provisioner: ebs.csi.aws.com
622
+ reclaimPolicy: Delete
623
+ volumeBindingMode: WaitForFirstConsumer
624
+ parameters:
625
+ type: gp3
626
+ encrypted: "true"
627
+ `;
628
+ break;
629
+ case "gcp":
630
+ storageClassYaml = `
631
+ apiVersion: storage.k8s.io/v1
632
+ kind: StorageClass
633
+ metadata:
634
+ name: pd-ssd
635
+ annotations:
636
+ storageclass.kubernetes.io/is-default-class: "true"
637
+ provisioner: pd.csi.storage.gke.io
638
+ reclaimPolicy: Delete
639
+ volumeBindingMode: WaitForFirstConsumer
640
+ parameters:
641
+ type: pd-ssd
642
+ `;
643
+ break;
644
+ case "azure":
645
+ storageClassYaml = `
646
+ apiVersion: storage.k8s.io/v1
647
+ kind: StorageClass
648
+ metadata:
649
+ name: managed-premium
650
+ annotations:
651
+ storageclass.kubernetes.io/is-default-class: "true"
652
+ provisioner: disk.csi.azure.com
653
+ reclaimPolicy: Delete
654
+ volumeBindingMode: WaitForFirstConsumer
655
+ parameters:
656
+ skuName: Premium_LRS
657
+ `;
658
+ break;
659
+ default:
660
+ throw new Error(`Unsupported cloud provider: ${provider}`);
661
+ }
662
+ try {
663
+ await execa("kubectl", ["apply", "-f", "-"], {
664
+ input: storageClassYaml,
665
+ });
666
+ }
667
+ catch (error) {
668
+ throw new Error(`Failed to create StorageClass:\n${getErrorMessage(error)}`);
669
+ }
670
+ }
671
+ /**
672
+ * Extracts the version tag from a Docker image string.
673
+ * E.g., "rulebricks/rulebricks:v1.5.8" -> "v1.5.8"
674
+ */
675
+ function extractImageTag(image) {
676
+ if (!image)
677
+ return null;
678
+ const parts = image.split(":");
679
+ if (parts.length < 2)
680
+ return null;
681
+ return parts[parts.length - 1];
682
+ }
683
+ /**
684
+ * Gets the actual deployed image versions from Kubernetes deployments.
685
+ * Queries the app and HPS deployments to get their current image tags.
686
+ *
687
+ * @param releaseName - The Helm release name (e.g., "rulebricks")
688
+ * @param namespace - The Kubernetes namespace
689
+ * @returns DeployedVersions with app and HPS versions, or null if not found
690
+ */
691
+ export async function getDeployedImageVersions(releaseName, namespace) {
692
+ const result = {
693
+ appVersion: null,
694
+ hpsVersion: null,
695
+ };
696
+ // Get app deployment image
697
+ try {
698
+ const { stdout: appImage } = await execa("kubectl", [
699
+ "get",
700
+ "deployment",
701
+ `${releaseName}-app`,
702
+ "-n",
703
+ namespace,
704
+ "-o",
705
+ "jsonpath={.spec.template.spec.containers[0].image}",
706
+ ]);
707
+ result.appVersion = extractImageTag(appImage.trim());
708
+ }
709
+ catch {
710
+ // Deployment may not exist or cluster not accessible
711
+ }
712
+ // Get HPS deployment image
713
+ try {
714
+ const { stdout: hpsImage } = await execa("kubectl", [
715
+ "get",
716
+ "deployment",
717
+ `${releaseName}-hps`,
718
+ "-n",
719
+ namespace,
720
+ "-o",
721
+ "jsonpath={.spec.template.spec.containers[0].image}",
722
+ ]);
723
+ result.hpsVersion = extractImageTag(hpsImage.trim());
724
+ }
725
+ catch {
726
+ // Deployment may not exist or cluster not accessible
727
+ }
728
+ return result;
729
+ }
730
+ /**
731
+ * Performs a rollout restart on a Kubernetes workload (deployment, statefulset, or daemonset).
732
+ * This forces pods to be recreated, pulling fresh images if pullPolicy is Always.
733
+ *
734
+ * @param workloadType - The type of workload (deployment, statefulset, daemonset)
735
+ * @param name - The name of the workload to restart
736
+ * @param namespace - The Kubernetes namespace
737
+ * @returns true if restart was successful, false if workload doesn't exist or failed
738
+ */
739
+ export async function rolloutRestart(workloadType, name, namespace) {
740
+ try {
741
+ await execa("kubectl", [
742
+ "rollout",
743
+ "restart",
744
+ workloadType,
745
+ name,
746
+ "-n",
747
+ namespace,
748
+ ]);
749
+ return true;
750
+ }
751
+ catch {
752
+ // Workload may not exist or cluster not accessible
753
+ return false;
754
+ }
755
+ }