@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.
- package/README.md +62 -0
- package/dist/commands/clone.d.ts +6 -0
- package/dist/commands/clone.js +60 -0
- package/dist/commands/deploy.d.ts +8 -0
- package/dist/commands/deploy.js +409 -0
- package/dist/commands/destroy.d.ts +8 -0
- package/dist/commands/destroy.js +298 -0
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.js +201 -0
- package/dist/commands/logs.d.ts +9 -0
- package/dist/commands/logs.js +222 -0
- package/dist/commands/open.d.ts +7 -0
- package/dist/commands/open.js +139 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +125 -0
- package/dist/commands/upgrade.d.ts +7 -0
- package/dist/commands/upgrade.js +239 -0
- package/dist/components/DNSWaitScreen.d.ts +9 -0
- package/dist/components/DNSWaitScreen.js +73 -0
- package/dist/components/Wizard/WizardContext.d.ts +176 -0
- package/dist/components/Wizard/WizardContext.js +346 -0
- package/dist/components/Wizard/index.d.ts +2 -0
- package/dist/components/Wizard/index.js +2 -0
- package/dist/components/Wizard/steps/CloudProviderStep.d.ts +6 -0
- package/dist/components/Wizard/steps/CloudProviderStep.js +210 -0
- package/dist/components/Wizard/steps/CredentialsStep.d.ts +6 -0
- package/dist/components/Wizard/steps/CredentialsStep.js +22 -0
- package/dist/components/Wizard/steps/DatabaseStep.d.ts +6 -0
- package/dist/components/Wizard/steps/DatabaseStep.js +80 -0
- package/dist/components/Wizard/steps/DeploymentModeStep.d.ts +5 -0
- package/dist/components/Wizard/steps/DeploymentModeStep.js +26 -0
- package/dist/components/Wizard/steps/DomainStep.d.ts +6 -0
- package/dist/components/Wizard/steps/DomainStep.js +126 -0
- package/dist/components/Wizard/steps/FeatureConfigStep.d.ts +6 -0
- package/dist/components/Wizard/steps/FeatureConfigStep.js +765 -0
- package/dist/components/Wizard/steps/FeaturesStep.d.ts +6 -0
- package/dist/components/Wizard/steps/FeaturesStep.js +119 -0
- package/dist/components/Wizard/steps/ReviewStep.d.ts +6 -0
- package/dist/components/Wizard/steps/ReviewStep.js +56 -0
- package/dist/components/Wizard/steps/SMTPStep.d.ts +6 -0
- package/dist/components/Wizard/steps/SMTPStep.js +191 -0
- package/dist/components/Wizard/steps/SupabaseCredentialsStep.d.ts +6 -0
- package/dist/components/Wizard/steps/SupabaseCredentialsStep.js +76 -0
- package/dist/components/Wizard/steps/TierStep.d.ts +6 -0
- package/dist/components/Wizard/steps/TierStep.js +29 -0
- package/dist/components/Wizard/steps/VersionStep.d.ts +6 -0
- package/dist/components/Wizard/steps/VersionStep.js +113 -0
- package/dist/components/Wizard/steps/index.d.ts +12 -0
- package/dist/components/Wizard/steps/index.js +12 -0
- package/dist/components/common/AppShell.d.ts +31 -0
- package/dist/components/common/AppShell.js +31 -0
- package/dist/components/common/Box.d.ts +20 -0
- package/dist/components/common/Box.js +20 -0
- package/dist/components/common/Logo.d.ts +7 -0
- package/dist/components/common/Logo.js +22 -0
- package/dist/components/common/Spinner.d.ts +12 -0
- package/dist/components/common/Spinner.js +28 -0
- package/dist/components/common/index.d.ts +6 -0
- package/dist/components/common/index.js +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +202 -0
- package/dist/lib/cloudCli.d.ts +156 -0
- package/dist/lib/cloudCli.js +691 -0
- package/dist/lib/config.d.ts +91 -0
- package/dist/lib/config.js +278 -0
- package/dist/lib/dns.d.ts +41 -0
- package/dist/lib/dns.js +235 -0
- package/dist/lib/dockerHub.d.ts +57 -0
- package/dist/lib/dockerHub.js +128 -0
- package/dist/lib/helm.d.ts +53 -0
- package/dist/lib/helm.js +209 -0
- package/dist/lib/helmValues.d.ts +17 -0
- package/dist/lib/helmValues.js +693 -0
- package/dist/lib/kubernetes.d.ts +161 -0
- package/dist/lib/kubernetes.js +755 -0
- package/dist/lib/terraform.d.ts +44 -0
- package/dist/lib/terraform.js +230 -0
- package/dist/lib/theme.d.ts +81 -0
- package/dist/lib/theme.js +115 -0
- package/dist/lib/validation.d.ts +47 -0
- package/dist/lib/validation.js +164 -0
- package/dist/lib/versions.d.ts +69 -0
- package/dist/lib/versions.js +139 -0
- package/dist/types/index.d.ts +718 -0
- package/dist/types/index.js +556 -0
- package/email-templates/email_change.html +325 -0
- package/email-templates/invite.html +383 -0
- package/email-templates/password_change.html +414 -0
- package/email-templates/verify.html +396 -0
- package/package.json +78 -0
- package/terraform/aws/main.tf +327 -0
- package/terraform/azure/main.tf +326 -0
- 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
|
+
}
|