@rulebricks/cli 2.1.7 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +51 -16
  2. package/cluster-setup/aws/README.md +96 -47
  3. package/cluster-setup/aws/check-aws-access.sh +216 -52
  4. package/cluster-setup/aws/parameters.json +13 -0
  5. package/cluster-setup/aws/rulebricks-cluster.cfn.yaml +355 -0
  6. package/cluster-setup/azure/README.md +103 -55
  7. package/cluster-setup/azure/check-aks-prereqs.sh +236 -56
  8. package/cluster-setup/azure/parameters.json +30 -0
  9. package/cluster-setup/azure/rulebricks-cluster.bicep +546 -0
  10. package/cluster-setup/gcp/README.md +51 -34
  11. package/cluster-setup/gcp/check-gke-prereqs.sh +222 -60
  12. package/dist/commands/backup.d.ts +5 -0
  13. package/dist/commands/backup.js +104 -0
  14. package/dist/commands/deploy.d.ts +3 -1
  15. package/dist/commands/deploy.js +226 -326
  16. package/dist/commands/destroy.d.ts +1 -1
  17. package/dist/commands/destroy.js +73 -123
  18. package/dist/commands/init.d.ts +5 -1
  19. package/dist/commands/init.js +78 -54
  20. package/dist/commands/list.d.ts +1 -0
  21. package/dist/commands/list.js +74 -0
  22. package/dist/commands/open.d.ts +1 -1
  23. package/dist/commands/open.js +4 -12
  24. package/dist/commands/redeploy.d.ts +6 -0
  25. package/dist/commands/redeploy.js +310 -0
  26. package/dist/commands/restore.d.ts +5 -0
  27. package/dist/commands/restore.js +338 -0
  28. package/dist/commands/status.js +62 -49
  29. package/dist/commands/upgrade.js +74 -51
  30. package/dist/components/DNSWaitScreen.d.ts +5 -1
  31. package/dist/components/DNSWaitScreen.js +47 -41
  32. package/dist/components/Wizard/WizardContext.d.ts +157 -36
  33. package/dist/components/Wizard/WizardContext.js +872 -160
  34. package/dist/components/Wizard/steps/CloudProviderStep.js +192 -107
  35. package/dist/components/Wizard/steps/DomainStep.js +5 -24
  36. package/dist/components/Wizard/steps/ExternalServicesStep.d.ts +6 -0
  37. package/dist/components/Wizard/steps/ExternalServicesStep.js +645 -0
  38. package/dist/components/Wizard/steps/FeatureConfigStep.d.ts +2 -1
  39. package/dist/components/Wizard/steps/FeatureConfigStep.js +739 -425
  40. package/dist/components/Wizard/steps/FeaturesStep.js +31 -35
  41. package/dist/components/Wizard/steps/ObservabilityStep.d.ts +6 -0
  42. package/dist/components/Wizard/steps/ObservabilityStep.js +137 -0
  43. package/dist/components/Wizard/steps/ReviewStep.d.ts +2 -1
  44. package/dist/components/Wizard/steps/ReviewStep.js +56 -12
  45. package/dist/components/Wizard/steps/StorageStep.d.ts +9 -0
  46. package/dist/components/Wizard/steps/StorageStep.js +592 -0
  47. package/dist/components/Wizard/steps/SupabaseCredentialsStep.js +20 -21
  48. package/dist/components/Wizard/steps/VersionStep.js +45 -23
  49. package/dist/components/Wizard/steps/index.d.ts +3 -3
  50. package/dist/components/Wizard/steps/index.js +3 -3
  51. package/dist/components/common/CommandApproval.d.ts +12 -0
  52. package/dist/components/common/CommandApproval.js +91 -0
  53. package/dist/components/common/DeploymentPicker.d.ts +14 -0
  54. package/dist/components/common/DeploymentPicker.js +16 -0
  55. package/dist/components/common/index.d.ts +2 -0
  56. package/dist/components/common/index.js +2 -0
  57. package/dist/index.js +94 -62
  58. package/dist/lib/cloudCli.d.ts +134 -63
  59. package/dist/lib/cloudCli.js +512 -220
  60. package/dist/lib/clusterSetupDefaults.d.ts +30 -0
  61. package/dist/lib/clusterSetupDefaults.js +64 -0
  62. package/dist/lib/commandApproval.d.ts +26 -0
  63. package/dist/lib/commandApproval.js +114 -0
  64. package/dist/lib/config.d.ts +12 -10
  65. package/dist/lib/config.js +91 -33
  66. package/dist/lib/configFixtures.d.ts +5 -0
  67. package/dist/lib/configFixtures.js +513 -0
  68. package/dist/lib/deploymentHealth.d.ts +32 -0
  69. package/dist/lib/deploymentHealth.js +157 -0
  70. package/dist/lib/dns.d.ts +1 -1
  71. package/dist/lib/dns.js +19 -1
  72. package/dist/lib/dns.test.d.ts +1 -0
  73. package/dist/lib/dns.test.js +27 -0
  74. package/dist/lib/dockerHub.d.ts +12 -1
  75. package/dist/lib/dockerHub.js +18 -8
  76. package/dist/lib/helm.d.ts +4 -0
  77. package/dist/lib/helm.js +16 -0
  78. package/dist/lib/helmValues.d.ts +25 -0
  79. package/dist/lib/helmValues.js +1841 -289
  80. package/dist/lib/helmValues.test.d.ts +1 -0
  81. package/dist/lib/helmValues.test.js +1012 -0
  82. package/dist/lib/htpasswd.d.ts +1 -0
  83. package/dist/lib/htpasswd.js +15 -0
  84. package/dist/lib/kubernetes.d.ts +124 -17
  85. package/dist/lib/kubernetes.js +576 -145
  86. package/dist/lib/secrets.d.ts +23 -0
  87. package/dist/lib/secrets.js +158 -0
  88. package/dist/lib/validateValues.d.ts +31 -0
  89. package/dist/lib/validateValues.js +253 -0
  90. package/dist/lib/versions.d.ts +82 -11
  91. package/dist/lib/versions.js +131 -31
  92. package/dist/lib/versions.test.d.ts +1 -0
  93. package/dist/lib/versions.test.js +81 -0
  94. package/dist/lib/wizardSteps.d.ts +14 -0
  95. package/dist/lib/wizardSteps.js +23 -0
  96. package/dist/lib/workloadIdentity.d.ts +26 -0
  97. package/dist/lib/workloadIdentity.js +323 -0
  98. package/dist/lib/workloadIdentity.test.d.ts +1 -0
  99. package/dist/lib/workloadIdentity.test.js +57 -0
  100. package/dist/types/index.d.ts +1860 -164
  101. package/dist/types/index.js +518 -295
  102. package/package.json +9 -4
  103. package/schema/values.schema.json +1934 -0
  104. package/cluster-setup/aws/cluster.yaml +0 -33
  105. package/cluster-setup/azure/main.bicep +0 -282
  106. package/cluster-setup/azure/main.parameters.json +0 -21
  107. package/dist/components/Wizard/steps/CredentialsStep.d.ts +0 -6
  108. package/dist/components/Wizard/steps/CredentialsStep.js +0 -22
  109. package/dist/components/Wizard/steps/DeploymentModeStep.d.ts +0 -5
  110. package/dist/components/Wizard/steps/DeploymentModeStep.js +0 -26
  111. package/dist/components/Wizard/steps/TierStep.d.ts +0 -6
  112. package/dist/components/Wizard/steps/TierStep.js +0 -29
  113. package/dist/lib/terraform.d.ts +0 -66
  114. package/dist/lib/terraform.js +0 -754
  115. package/terraform/aws/main.tf +0 -355
  116. package/terraform/azure/main.tf +0 -371
  117. package/terraform/gcp/main.tf +0 -407
@@ -1,754 +0,0 @@
1
- import { execa } from 'execa';
2
- import { promises as fs } from 'fs';
3
- import path from 'path';
4
- import { fileURLToPath } from 'url';
5
- import { isSupportedDnsProvider } from '../types/index.js';
6
- import { getTerraformDir } from './config.js';
7
- const __filename = fileURLToPath(import.meta.url);
8
- const __dirname = path.dirname(__filename);
9
- // Path to embedded terraform templates
10
- const TERRAFORM_TEMPLATES_DIR = path.resolve(__dirname, '../../terraform');
11
- /**
12
- * Detects if an error is a GCP authentication error
13
- */
14
- function isGcpAuthError(output) {
15
- const lowerOutput = output.toLowerCase();
16
- return (lowerOutput.includes('oauth2') ||
17
- lowerOutput.includes('invalid_grant') ||
18
- lowerOutput.includes('reauth') ||
19
- lowerOutput.includes('invalid_rapt') ||
20
- lowerOutput.includes('authentication') && lowerOutput.includes('google') ||
21
- lowerOutput.includes('unable to find default credentials') ||
22
- lowerOutput.includes('application default credentials'));
23
- }
24
- /**
25
- * Enhances GCP authentication errors with helpful guidance
26
- */
27
- function enhanceGcpAuthError(output) {
28
- return ('GCP Authentication Error\n\n' +
29
- 'Terraform requires Application Default Credentials (ADC) to authenticate with Google Cloud.\n\n' +
30
- 'To fix this:\n' +
31
- ' • Run: gcloud auth login\n' +
32
- ' • Run: gcloud auth application-default login\n' +
33
- ' • Verify: gcloud auth application-default print-access-token\n\n' +
34
- 'For more information: https://cloud.google.com/docs/authentication/application-default-credentials\n\n' +
35
- 'Original error:\n' +
36
- (output.length > 500 ? '...' + output.slice(-500) : output));
37
- }
38
- /**
39
- * Extracts meaningful error message from execa error
40
- */
41
- function getErrorMessage(error, fallback) {
42
- const execaError = error;
43
- // Try stderr first, then stdout (terraform sometimes writes errors to stdout)
44
- const output = execaError.stderr || execaError.stdout || '';
45
- if (output) {
46
- // Check if this is a GCP authentication error
47
- if (isGcpAuthError(output)) {
48
- return enhanceGcpAuthError(output);
49
- }
50
- // Get last 500 chars of output for the error message
51
- const truncated = output.length > 500 ? '...' + output.slice(-500) : output;
52
- return truncated;
53
- }
54
- return execaError.shortMessage || execaError.message || fallback;
55
- }
56
- /**
57
- * Saves command output to a log file
58
- */
59
- async function saveLogFile(workDir, command, stdout, stderr) {
60
- const logFile = path.join(workDir, `${command}-${Date.now()}.log`);
61
- const content = `=== STDOUT ===\n${stdout}\n\n=== STDERR ===\n${stderr}`;
62
- await fs.writeFile(logFile, content);
63
- return logFile;
64
- }
65
- /**
66
- * Checks if Terraform is installed
67
- */
68
- export async function isTerraformInstalled() {
69
- try {
70
- await execa('terraform', ['version']);
71
- return true;
72
- }
73
- catch {
74
- return false;
75
- }
76
- }
77
- /**
78
- * Gets the installed Terraform version
79
- */
80
- export async function getTerraformVersion() {
81
- const { stdout } = await execa('terraform', ['version', '-json']);
82
- const info = JSON.parse(stdout);
83
- return info.terraform_version;
84
- }
85
- /**
86
- * Copies terraform templates to the deployment directory
87
- */
88
- export async function setupTerraformWorkspace(deploymentName, provider) {
89
- const sourceDir = path.join(TERRAFORM_TEMPLATES_DIR, provider);
90
- const targetDir = getTerraformDir(deploymentName);
91
- // Create target directory
92
- await fs.mkdir(targetDir, { recursive: true });
93
- // Copy all terraform files
94
- await copyDirectory(sourceDir, targetDir);
95
- return targetDir;
96
- }
97
- /**
98
- * Recursively copies a directory
99
- */
100
- async function copyDirectory(src, dest) {
101
- await fs.mkdir(dest, { recursive: true });
102
- const entries = await fs.readdir(src, { withFileTypes: true });
103
- for (const entry of entries) {
104
- const srcPath = path.join(src, entry.name);
105
- const destPath = path.join(dest, entry.name);
106
- if (entry.isDirectory()) {
107
- await copyDirectory(srcPath, destPath);
108
- }
109
- else {
110
- await fs.copyFile(srcPath, destPath);
111
- }
112
- }
113
- }
114
- /**
115
- * Initializes Terraform in the deployment directory
116
- */
117
- export async function terraformInit(deploymentName) {
118
- const workDir = getTerraformDir(deploymentName);
119
- try {
120
- // Use 'pipe' to capture output instead of 'inherit' to avoid
121
- // interfering with Ink's terminal rendering
122
- await execa('terraform', ['init', '-upgrade'], {
123
- cwd: workDir
124
- });
125
- }
126
- catch (error) {
127
- const execaError = error;
128
- // Save logs for debugging
129
- if (execaError.stdout || execaError.stderr) {
130
- await saveLogFile(workDir, 'init', execaError.stdout || '', execaError.stderr || '');
131
- }
132
- throw new Error(`Terraform init failed:\n${getErrorMessage(error, 'Unknown error')}\n\nLogs saved to: ${workDir}`);
133
- }
134
- }
135
- /**
136
- * Plans Terraform changes
137
- */
138
- export async function terraformPlan(deploymentName) {
139
- const workDir = getTerraformDir(deploymentName);
140
- try {
141
- await execa('terraform', ['plan', '-out=tfplan'], {
142
- cwd: workDir
143
- });
144
- }
145
- catch (error) {
146
- const execaError = error;
147
- if (execaError.stdout || execaError.stderr) {
148
- await saveLogFile(workDir, 'plan', execaError.stdout || '', execaError.stderr || '');
149
- }
150
- throw new Error(`Terraform plan failed:\n${getErrorMessage(error, 'Unknown error')}\n\nLogs saved to: ${workDir}`);
151
- }
152
- }
153
- /**
154
- * Applies Terraform changes
155
- */
156
- export async function terraformApply(deploymentName) {
157
- const workDir = getTerraformDir(deploymentName);
158
- try {
159
- await execa('terraform', ['apply', '-auto-approve', 'tfplan'], {
160
- cwd: workDir
161
- });
162
- }
163
- catch (error) {
164
- const execaError = error;
165
- if (execaError.stdout || execaError.stderr) {
166
- await saveLogFile(workDir, 'apply', execaError.stdout || '', execaError.stderr || '');
167
- }
168
- throw new Error(`Terraform apply failed:\n${getErrorMessage(error, 'Unknown error')}\n\nLogs saved to: ${workDir}`);
169
- }
170
- }
171
- /**
172
- * Lightweight pre-deploy cleanup for the CloudWatch log group that the EKS module
173
- * no longer manages (create_cloudwatch_log_group = false). Safe to call before
174
- * terraform apply since it targets a resource outside terraform's control.
175
- */
176
- export async function cleanupOrphanedResources(provider, clusterName, region) {
177
- if (provider === 'aws') {
178
- const logGroupName = `/aws/eks/${clusterName}/cluster`;
179
- try {
180
- await execa('aws', [
181
- 'logs', 'delete-log-group',
182
- '--log-group-name', logGroupName,
183
- '--region', region,
184
- ]);
185
- }
186
- catch {
187
- // Log group may not exist — that's fine
188
- }
189
- }
190
- }
191
- // ============================================================================
192
- // Post-destroy cloud-native cleanup (AWS)
193
- //
194
- // Handles every uniquely-named resource that terraform tends to leave behind
195
- // after a failed destroy or partial apply. Runs unconditionally after every
196
- // terraform destroy since terraform can report success while resources linger.
197
- // Every step is best-effort: failures are silently swallowed.
198
- // ============================================================================
199
- async function deleteAwsEksNodeGroups(clusterName, region) {
200
- let nodeGroups;
201
- try {
202
- const { stdout } = await execa('aws', [
203
- 'eks', 'list-nodegroups',
204
- '--cluster-name', clusterName,
205
- '--region', region,
206
- '--output', 'json',
207
- ]);
208
- const parsed = JSON.parse(stdout);
209
- nodeGroups = parsed.nodegroups ?? [];
210
- }
211
- catch {
212
- return; // Cluster may not exist
213
- }
214
- for (const ng of nodeGroups) {
215
- try {
216
- await execa('aws', [
217
- 'eks', 'delete-nodegroup',
218
- '--cluster-name', clusterName,
219
- '--nodegroup-name', ng,
220
- '--region', region,
221
- ]);
222
- }
223
- catch { /* already gone */ }
224
- }
225
- // Wait for all node groups to finish deleting
226
- for (const ng of nodeGroups) {
227
- try {
228
- await execa('aws', [
229
- 'eks', 'wait', 'nodegroup-deleted',
230
- '--cluster-name', clusterName,
231
- '--nodegroup-name', ng,
232
- '--region', region,
233
- ]);
234
- }
235
- catch { /* timeout or already gone */ }
236
- }
237
- }
238
- async function deleteAwsEksCluster(clusterName, region) {
239
- try {
240
- await execa('aws', [
241
- 'eks', 'delete-cluster',
242
- '--name', clusterName,
243
- '--region', region,
244
- ]);
245
- }
246
- catch {
247
- return; // Cluster may not exist
248
- }
249
- try {
250
- await execa('aws', [
251
- 'eks', 'wait', 'cluster-deleted',
252
- '--name', clusterName,
253
- '--region', region,
254
- ]);
255
- }
256
- catch { /* timeout or already gone */ }
257
- }
258
- async function deleteAwsCloudWatchLogGroup(clusterName, region) {
259
- try {
260
- await execa('aws', [
261
- 'logs', 'delete-log-group',
262
- '--log-group-name', `/aws/eks/${clusterName}/cluster`,
263
- '--region', region,
264
- ]);
265
- }
266
- catch { /* may not exist */ }
267
- }
268
- /**
269
- * Captures the OIDC issuer URL from an EKS cluster before it's deleted.
270
- * The URL uses a random cluster ID (not the cluster name), so we must
271
- * grab it while the cluster still exists to identify the OIDC provider later.
272
- */
273
- async function getEksOidcIssuer(clusterName, region) {
274
- try {
275
- const { stdout } = await execa('aws', [
276
- 'eks', 'describe-cluster',
277
- '--name', clusterName,
278
- '--region', region,
279
- '--query', 'cluster.identity.oidc.issuer',
280
- '--output', 'text',
281
- ]);
282
- const url = stdout.trim();
283
- return url && url !== 'None' ? url : undefined;
284
- }
285
- catch {
286
- return undefined;
287
- }
288
- }
289
- async function deleteAwsOidcProvider(oidcIssuerUrl) {
290
- if (!oidcIssuerUrl)
291
- return;
292
- // Strip the https:// prefix to match how IAM stores the URL
293
- const issuerHost = oidcIssuerUrl.replace('https://', '');
294
- let providerArns;
295
- try {
296
- const { stdout } = await execa('aws', [
297
- 'iam', 'list-open-id-connect-providers',
298
- '--output', 'json',
299
- ]);
300
- const parsed = JSON.parse(stdout);
301
- providerArns = (parsed.OpenIDConnectProviderList ?? []).map((p) => p.Arn);
302
- }
303
- catch {
304
- return;
305
- }
306
- for (const arn of providerArns) {
307
- try {
308
- const { stdout } = await execa('aws', [
309
- 'iam', 'get-open-id-connect-provider',
310
- '--open-id-connect-provider-arn', arn,
311
- '--output', 'json',
312
- ]);
313
- const parsed = JSON.parse(stdout);
314
- if (parsed.Url && issuerHost.includes(parsed.Url)) {
315
- await execa('aws', [
316
- 'iam', 'delete-open-id-connect-provider',
317
- '--open-id-connect-provider-arn', arn,
318
- ]);
319
- }
320
- }
321
- catch { /* skip */ }
322
- }
323
- }
324
- async function releaseAwsElasticIps(clusterName, region) {
325
- try {
326
- const { stdout } = await execa('aws', [
327
- 'ec2', 'describe-addresses',
328
- '--filters', `Name=tag:Name,Values=*${clusterName}*`,
329
- '--region', region,
330
- '--query', 'Addresses[?AssociationId==null].AllocationId',
331
- '--output', 'json',
332
- ]);
333
- const allocationIds = JSON.parse(stdout);
334
- for (const id of allocationIds) {
335
- try {
336
- await execa('aws', [
337
- 'ec2', 'release-address',
338
- '--allocation-id', id,
339
- '--region', region,
340
- ]);
341
- }
342
- catch { /* may already be released */ }
343
- }
344
- }
345
- catch { /* skip */ }
346
- }
347
- async function deleteAwsIamRole(roleName) {
348
- // Detach all managed policies
349
- try {
350
- const { stdout } = await execa('aws', [
351
- 'iam', 'list-attached-role-policies',
352
- '--role-name', roleName,
353
- '--output', 'json',
354
- ]);
355
- const parsed = JSON.parse(stdout);
356
- for (const policy of parsed.AttachedPolicies ?? []) {
357
- try {
358
- await execa('aws', [
359
- 'iam', 'detach-role-policy',
360
- '--role-name', roleName,
361
- '--policy-arn', policy.PolicyArn,
362
- ]);
363
- }
364
- catch { /* skip */ }
365
- }
366
- }
367
- catch { /* role may not exist */ }
368
- // Delete inline policies
369
- try {
370
- const { stdout } = await execa('aws', [
371
- 'iam', 'list-role-policies',
372
- '--role-name', roleName,
373
- '--output', 'json',
374
- ]);
375
- const parsed = JSON.parse(stdout);
376
- for (const policyName of parsed.PolicyNames ?? []) {
377
- try {
378
- await execa('aws', [
379
- 'iam', 'delete-role-policy',
380
- '--role-name', roleName,
381
- '--policy-name', policyName,
382
- ]);
383
- }
384
- catch { /* skip */ }
385
- }
386
- }
387
- catch { /* role may not exist */ }
388
- // Delete the role itself
389
- try {
390
- await execa('aws', ['iam', 'delete-role', '--role-name', roleName]);
391
- }
392
- catch { /* may not exist */ }
393
- }
394
- async function deleteAwsKmsAlias(clusterName, region) {
395
- const aliasName = `alias/eks/${clusterName}`;
396
- let keyId;
397
- // Find the KMS key behind the alias so we can schedule it for deletion
398
- try {
399
- const { stdout } = await execa('aws', [
400
- 'kms', 'list-aliases',
401
- '--query', `Aliases[?AliasName=='${aliasName}'].TargetKeyId | [0]`,
402
- '--output', 'text',
403
- '--region', region,
404
- ]);
405
- const id = stdout.trim();
406
- if (id && id !== 'None') {
407
- keyId = id;
408
- }
409
- }
410
- catch { /* skip */ }
411
- // Delete the alias (unique name constraint -- blocks re-deploy if left behind)
412
- try {
413
- await execa('aws', [
414
- 'kms', 'delete-alias',
415
- '--alias-name', aliasName,
416
- '--region', region,
417
- ]);
418
- }
419
- catch { /* may not exist */ }
420
- // Schedule the underlying key for deletion (7-day mandatory minimum)
421
- if (keyId) {
422
- try {
423
- await execa('aws', [
424
- 'kms', 'schedule-key-deletion',
425
- '--key-id', keyId,
426
- '--pending-window-in-days', '7',
427
- '--region', region,
428
- ]);
429
- }
430
- catch { /* key may already be pending deletion or not exist */ }
431
- }
432
- }
433
- /**
434
- * Finds KMS keys by the description the EKS module uses, and schedules them for
435
- * deletion. Catches keys that survive after their alias is already deleted.
436
- */
437
- async function scheduleAwsOrphanedKmsKeys(clusterName, region) {
438
- try {
439
- const { stdout } = await execa('aws', [
440
- 'kms', 'list-keys',
441
- '--region', region,
442
- '--query', 'Keys[].KeyId',
443
- '--output', 'json',
444
- ]);
445
- const keyIds = JSON.parse(stdout);
446
- for (const keyId of keyIds) {
447
- try {
448
- const { stdout: meta } = await execa('aws', [
449
- 'kms', 'describe-key',
450
- '--key-id', keyId,
451
- '--region', region,
452
- '--query', 'KeyMetadata.{State:KeyState,Desc:Description,Manager:KeyManager}',
453
- '--output', 'json',
454
- ]);
455
- const info = JSON.parse(meta);
456
- if (info.Manager === 'CUSTOMER' &&
457
- info.State === 'Enabled' &&
458
- info.Desc.includes(clusterName)) {
459
- await execa('aws', [
460
- 'kms', 'schedule-key-deletion',
461
- '--key-id', keyId,
462
- '--pending-window-in-days', '7',
463
- '--region', region,
464
- ]);
465
- }
466
- }
467
- catch { /* skip individual key */ }
468
- }
469
- }
470
- catch { /* skip */ }
471
- }
472
- async function deleteAwsLaunchTemplates(clusterName, region) {
473
- try {
474
- const { stdout } = await execa('aws', [
475
- 'ec2', 'describe-launch-templates',
476
- '--filters', `Name=tag:Environment,Values=rulebricks`,
477
- '--region', region,
478
- '--query', 'LaunchTemplates[].LaunchTemplateId',
479
- '--output', 'json',
480
- ]);
481
- const ids = JSON.parse(stdout);
482
- for (const id of ids) {
483
- try {
484
- await execa('aws', [
485
- 'ec2', 'delete-launch-template',
486
- '--launch-template-id', id,
487
- '--region', region,
488
- ]);
489
- }
490
- catch { /* may not exist or in use */ }
491
- }
492
- }
493
- catch { /* skip */ }
494
- }
495
- async function deleteAwsIamPolicy(policyName) {
496
- try {
497
- const { stdout } = await execa('aws', [
498
- 'iam', 'list-policies',
499
- '--query', `Policies[?PolicyName=='${policyName}']`,
500
- '--output', 'json',
501
- ]);
502
- const policies = JSON.parse(stdout);
503
- for (const policy of policies) {
504
- try {
505
- await execa('aws', ['iam', 'delete-policy', '--policy-arn', policy.Arn]);
506
- }
507
- catch { /* may have attachments or not exist */ }
508
- }
509
- }
510
- catch { /* skip */ }
511
- }
512
- /**
513
- * Comprehensive post-destroy cleanup of AWS resources that terraform leaves
514
- * behind. Handles the full dependency chain in the correct order.
515
- * Entirely best-effort: every step silently swallows errors.
516
- */
517
- async function cleanupAwsResources(clusterName, region) {
518
- // Capture the OIDC issuer URL BEFORE deleting the cluster -- the URL uses a
519
- // random cluster ID (not the cluster name) so we can't find it after deletion.
520
- const oidcIssuerUrl = await getEksOidcIssuer(clusterName, region);
521
- // 1. EKS node groups (must be deleted before cluster)
522
- await deleteAwsEksNodeGroups(clusterName, region);
523
- // 2. EKS cluster
524
- await deleteAwsEksCluster(clusterName, region);
525
- // 3. CloudWatch log group (now safe -- cluster is gone, won't be recreated)
526
- await deleteAwsCloudWatchLogGroup(clusterName, region);
527
- // 4. OIDC provider (matched by issuer URL captured above)
528
- await deleteAwsOidcProvider(oidcIssuerUrl);
529
- // 5. IAM roles created by terraform modules
530
- await deleteAwsIamRole(`${clusterName}-ebs-csi`);
531
- await deleteAwsIamRole(`${clusterName}-external-dns`);
532
- await deleteAwsIamRole(`${clusterName}-vector`);
533
- // 6. Customer-managed IAM policies
534
- await deleteAwsIamPolicy(`${clusterName}-vector-s3`);
535
- // 7. KMS key + alias (created by EKS module for envelope encryption)
536
- await deleteAwsKmsAlias(clusterName, region);
537
- // 8. KMS keys that lost their alias but are still Enabled (matched by description)
538
- await scheduleAwsOrphanedKmsKeys(clusterName, region);
539
- // 9. Launch templates (created by EKS managed node groups)
540
- await deleteAwsLaunchTemplates(clusterName, region);
541
- // 10. Elastic IPs (created by VPC module for NAT gateways, cost money if leaked)
542
- await releaseAwsElasticIps(clusterName, region);
543
- }
544
- /**
545
- * Destroys Terraform infrastructure, then sweeps remaining cloud resources.
546
- *
547
- * Flow:
548
- * 1. terraform destroy (single attempt)
549
- * 2. Cloud-native cleanup ALWAYS runs (terraform can report success while
550
- * resources still exist)
551
- * 3. If terraform reported failure, try once more now that blockers are gone
552
- */
553
- export async function terraformDestroy(deploymentName, cloudContext) {
554
- const workDir = getTerraformDir(deploymentName);
555
- // Run init first to ensure terraform is ready
556
- try {
557
- await execa('terraform', ['init', '-upgrade'], {
558
- cwd: workDir
559
- });
560
- }
561
- catch (initError) {
562
- const execaInitError = initError;
563
- if (execaInitError.stdout || execaInitError.stderr) {
564
- await saveLogFile(workDir, 'destroy-init', execaInitError.stdout || '', execaInitError.stderr || '');
565
- }
566
- }
567
- // First terraform destroy attempt
568
- let firstAttemptFailed = false;
569
- try {
570
- await execa('terraform', ['destroy', '-auto-approve'], {
571
- cwd: workDir
572
- });
573
- }
574
- catch (error) {
575
- firstAttemptFailed = true;
576
- const execaError = error;
577
- if (execaError.stdout || execaError.stderr) {
578
- await saveLogFile(workDir, 'destroy', execaError.stdout || '', execaError.stderr || '');
579
- }
580
- }
581
- // ALWAYS run cloud-native cleanup -- terraform can't be trusted to report
582
- // accurately whether all resources were actually destroyed
583
- if (cloudContext?.provider === 'aws') {
584
- await cleanupAwsResources(cloudContext.clusterName, cloudContext.region);
585
- }
586
- // If terraform failed, try once more now that cloud-native cleanup removed blockers
587
- if (firstAttemptFailed) {
588
- try {
589
- await execa('terraform', ['destroy', '-auto-approve'], {
590
- cwd: workDir
591
- });
592
- }
593
- catch (error) {
594
- const execaError = error;
595
- if (execaError.stdout || execaError.stderr) {
596
- await saveLogFile(workDir, 'destroy-final', execaError.stdout || '', execaError.stderr || '');
597
- }
598
- throw new Error(`Terraform destroy failed:\n${getErrorMessage(error, 'Unknown error')}\n\nLogs saved to: ${workDir}`);
599
- }
600
- }
601
- }
602
- /**
603
- * Gets Terraform outputs
604
- */
605
- export async function getTerraformOutputs(deploymentName) {
606
- const workDir = getTerraformDir(deploymentName);
607
- try {
608
- const { stdout } = await execa('terraform', ['output', '-json'], {
609
- cwd: workDir
610
- });
611
- const outputs = JSON.parse(stdout);
612
- const result = {};
613
- for (const [key, data] of Object.entries(outputs)) {
614
- result[key] = String(data.value);
615
- }
616
- return result;
617
- }
618
- catch {
619
- return {};
620
- }
621
- }
622
- /**
623
- * Checks if Terraform files/state exist for a deployment.
624
- * Returns true if the terraform directory contains any terraform files,
625
- * not just the state file. This allows destroy to work on partial infrastructure.
626
- */
627
- export async function hasTerraformState(deploymentName) {
628
- const workDir = getTerraformDir(deploymentName);
629
- try {
630
- // Check if terraform directory exists
631
- await fs.access(workDir);
632
- // Check for any of: state file, .terraform folder, or .tf files
633
- const entries = await fs.readdir(workDir);
634
- const hasTerraformFiles = entries.some((e) => e === 'terraform.tfstate' ||
635
- e === '.terraform' ||
636
- e.endsWith('.tf'));
637
- return hasTerraformFiles;
638
- }
639
- catch {
640
- return false;
641
- }
642
- }
643
- /**
644
- * Generates Terraform variables from deployment configuration
645
- */
646
- export function generateTerraformVars(config) {
647
- const provider = config.infrastructure.provider;
648
- if (!provider) {
649
- throw new Error('Cloud provider is required for infrastructure provisioning');
650
- }
651
- const region = config.infrastructure.region || (provider === 'gcp' ? 'us-central1' : provider === 'aws' ? 'us-east-1' : 'eastus');
652
- const clusterName = config.infrastructure.clusterName || `${config.name}-cluster`;
653
- const tier = config.tier || 'small';
654
- const kubernetesVersion = '1.34';
655
- // Determine if external DNS should be enabled
656
- const enableExternalDns = config.dns.autoManage && isSupportedDnsProvider(config.dns.provider);
657
- // Determine logging configuration
658
- const loggingSink = config.features.logging.sink;
659
- const loggingBucket = config.features.logging.bucket || '';
660
- switch (provider) {
661
- case 'gcp': {
662
- if (!config.infrastructure.gcpProjectId) {
663
- throw new Error('GCP project ID is required for GCP infrastructure provisioning');
664
- }
665
- const vars = {
666
- project_id: config.infrastructure.gcpProjectId,
667
- region,
668
- cluster_name: clusterName,
669
- tier,
670
- kubernetes_version: kubernetesVersion,
671
- enable_external_dns: enableExternalDns,
672
- enable_gcs_logging: loggingSink === 'gcs',
673
- logging_gcs_bucket: loggingSink === 'gcs' ? loggingBucket : '',
674
- };
675
- return vars;
676
- }
677
- case 'aws': {
678
- // Extract domain suffix for external DNS domain filter
679
- const domainSuffix = enableExternalDns && config.domain ? config.domain.split('.').slice(1).join('.') : '';
680
- const vars = {
681
- region,
682
- cluster_name: clusterName,
683
- tier,
684
- kubernetes_version: kubernetesVersion,
685
- enable_external_dns: enableExternalDns,
686
- external_dns_domain: enableExternalDns ? domainSuffix : '',
687
- enable_s3_logging: loggingSink === 's3',
688
- logging_s3_bucket: loggingSink === 's3' ? loggingBucket : '',
689
- };
690
- return vars;
691
- }
692
- case 'azure': {
693
- const resourceGroupName = config.infrastructure.azureResourceGroup || `${config.name}-rg`;
694
- // For Azure DNS, we need the DNS zone resource group
695
- // This is typically the same as the resource group, but can be different
696
- const dnsZoneResourceGroup = enableExternalDns ? resourceGroupName : '';
697
- const vars = {
698
- resource_group_name: resourceGroupName,
699
- location: region,
700
- cluster_name: clusterName,
701
- tier,
702
- kubernetes_version: kubernetesVersion,
703
- enable_external_dns: enableExternalDns,
704
- dns_zone_resource_group: dnsZoneResourceGroup,
705
- enable_blob_logging: loggingSink === 'azure-blob',
706
- logging_storage_account: loggingSink === 'azure-blob' ? loggingBucket : '',
707
- logging_container_name: loggingSink === 'azure-blob' ? 'logs' : '',
708
- };
709
- return vars;
710
- }
711
- default:
712
- throw new Error(`Unsupported cloud provider: ${provider}`);
713
- }
714
- }
715
- /**
716
- * Updates kubeconfig for the provisioned cluster
717
- */
718
- export async function updateKubeconfig(provider, clusterName, region, options = {}) {
719
- try {
720
- switch (provider) {
721
- case 'aws':
722
- await execa('aws', [
723
- 'eks', 'update-kubeconfig',
724
- '--name', clusterName,
725
- '--region', region
726
- ]);
727
- break;
728
- case 'gcp':
729
- if (!options.gcpProjectId) {
730
- throw new Error('GCP project ID is required');
731
- }
732
- await execa('gcloud', [
733
- 'container', 'clusters', 'get-credentials',
734
- clusterName,
735
- '--region', region,
736
- '--project', options.gcpProjectId
737
- ]);
738
- break;
739
- case 'azure':
740
- if (!options.azureResourceGroup) {
741
- throw new Error('Azure resource group is required');
742
- }
743
- await execa('az', [
744
- 'aks', 'get-credentials',
745
- '--name', clusterName,
746
- '--resource-group', options.azureResourceGroup
747
- ]);
748
- break;
749
- }
750
- }
751
- catch (error) {
752
- throw new Error(`Failed to update kubeconfig:\n${getErrorMessage(error, 'Unknown error')}`);
753
- }
754
- }