@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.
- package/README.md +51 -16
- package/cluster-setup/aws/README.md +96 -47
- package/cluster-setup/aws/check-aws-access.sh +216 -52
- package/cluster-setup/aws/parameters.json +13 -0
- package/cluster-setup/aws/rulebricks-cluster.cfn.yaml +355 -0
- package/cluster-setup/azure/README.md +103 -55
- package/cluster-setup/azure/check-aks-prereqs.sh +236 -56
- package/cluster-setup/azure/parameters.json +30 -0
- package/cluster-setup/azure/rulebricks-cluster.bicep +546 -0
- package/cluster-setup/gcp/README.md +51 -34
- package/cluster-setup/gcp/check-gke-prereqs.sh +222 -60
- package/dist/commands/backup.d.ts +5 -0
- package/dist/commands/backup.js +104 -0
- package/dist/commands/deploy.d.ts +3 -1
- package/dist/commands/deploy.js +226 -326
- package/dist/commands/destroy.d.ts +1 -1
- package/dist/commands/destroy.js +73 -123
- package/dist/commands/init.d.ts +5 -1
- package/dist/commands/init.js +78 -54
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +74 -0
- package/dist/commands/open.d.ts +1 -1
- package/dist/commands/open.js +4 -12
- package/dist/commands/redeploy.d.ts +6 -0
- package/dist/commands/redeploy.js +310 -0
- package/dist/commands/restore.d.ts +5 -0
- package/dist/commands/restore.js +338 -0
- package/dist/commands/status.js +62 -49
- package/dist/commands/upgrade.js +74 -51
- package/dist/components/DNSWaitScreen.d.ts +5 -1
- package/dist/components/DNSWaitScreen.js +47 -41
- package/dist/components/Wizard/WizardContext.d.ts +157 -36
- package/dist/components/Wizard/WizardContext.js +872 -160
- package/dist/components/Wizard/steps/CloudProviderStep.js +192 -107
- package/dist/components/Wizard/steps/DomainStep.js +5 -24
- package/dist/components/Wizard/steps/ExternalServicesStep.d.ts +6 -0
- package/dist/components/Wizard/steps/ExternalServicesStep.js +645 -0
- package/dist/components/Wizard/steps/FeatureConfigStep.d.ts +2 -1
- package/dist/components/Wizard/steps/FeatureConfigStep.js +739 -425
- package/dist/components/Wizard/steps/FeaturesStep.js +31 -35
- package/dist/components/Wizard/steps/ObservabilityStep.d.ts +6 -0
- package/dist/components/Wizard/steps/ObservabilityStep.js +137 -0
- package/dist/components/Wizard/steps/ReviewStep.d.ts +2 -1
- package/dist/components/Wizard/steps/ReviewStep.js +56 -12
- package/dist/components/Wizard/steps/StorageStep.d.ts +9 -0
- package/dist/components/Wizard/steps/StorageStep.js +592 -0
- package/dist/components/Wizard/steps/SupabaseCredentialsStep.js +20 -21
- package/dist/components/Wizard/steps/VersionStep.js +45 -23
- package/dist/components/Wizard/steps/index.d.ts +3 -3
- package/dist/components/Wizard/steps/index.js +3 -3
- package/dist/components/common/CommandApproval.d.ts +12 -0
- package/dist/components/common/CommandApproval.js +91 -0
- package/dist/components/common/DeploymentPicker.d.ts +14 -0
- package/dist/components/common/DeploymentPicker.js +16 -0
- package/dist/components/common/index.d.ts +2 -0
- package/dist/components/common/index.js +2 -0
- package/dist/index.js +94 -62
- package/dist/lib/cloudCli.d.ts +134 -63
- package/dist/lib/cloudCli.js +512 -220
- package/dist/lib/clusterSetupDefaults.d.ts +30 -0
- package/dist/lib/clusterSetupDefaults.js +64 -0
- package/dist/lib/commandApproval.d.ts +26 -0
- package/dist/lib/commandApproval.js +114 -0
- package/dist/lib/config.d.ts +12 -10
- package/dist/lib/config.js +91 -33
- package/dist/lib/configFixtures.d.ts +5 -0
- package/dist/lib/configFixtures.js +513 -0
- package/dist/lib/deploymentHealth.d.ts +32 -0
- package/dist/lib/deploymentHealth.js +157 -0
- package/dist/lib/dns.d.ts +1 -1
- package/dist/lib/dns.js +19 -1
- package/dist/lib/dns.test.d.ts +1 -0
- package/dist/lib/dns.test.js +27 -0
- package/dist/lib/dockerHub.d.ts +12 -1
- package/dist/lib/dockerHub.js +18 -8
- package/dist/lib/helm.d.ts +4 -0
- package/dist/lib/helm.js +16 -0
- package/dist/lib/helmValues.d.ts +25 -0
- package/dist/lib/helmValues.js +1841 -289
- package/dist/lib/helmValues.test.d.ts +1 -0
- package/dist/lib/helmValues.test.js +1012 -0
- package/dist/lib/htpasswd.d.ts +1 -0
- package/dist/lib/htpasswd.js +15 -0
- package/dist/lib/kubernetes.d.ts +124 -17
- package/dist/lib/kubernetes.js +576 -145
- package/dist/lib/secrets.d.ts +23 -0
- package/dist/lib/secrets.js +158 -0
- package/dist/lib/validateValues.d.ts +31 -0
- package/dist/lib/validateValues.js +253 -0
- package/dist/lib/versions.d.ts +82 -11
- package/dist/lib/versions.js +131 -31
- package/dist/lib/versions.test.d.ts +1 -0
- package/dist/lib/versions.test.js +81 -0
- package/dist/lib/wizardSteps.d.ts +14 -0
- package/dist/lib/wizardSteps.js +23 -0
- package/dist/lib/workloadIdentity.d.ts +26 -0
- package/dist/lib/workloadIdentity.js +323 -0
- package/dist/lib/workloadIdentity.test.d.ts +1 -0
- package/dist/lib/workloadIdentity.test.js +57 -0
- package/dist/types/index.d.ts +1860 -164
- package/dist/types/index.js +518 -295
- package/package.json +9 -4
- package/schema/values.schema.json +1934 -0
- package/cluster-setup/aws/cluster.yaml +0 -33
- package/cluster-setup/azure/main.bicep +0 -282
- package/cluster-setup/azure/main.parameters.json +0 -21
- package/dist/components/Wizard/steps/CredentialsStep.d.ts +0 -6
- package/dist/components/Wizard/steps/CredentialsStep.js +0 -22
- package/dist/components/Wizard/steps/DeploymentModeStep.d.ts +0 -5
- package/dist/components/Wizard/steps/DeploymentModeStep.js +0 -26
- package/dist/components/Wizard/steps/TierStep.d.ts +0 -6
- package/dist/components/Wizard/steps/TierStep.js +0 -29
- package/dist/lib/terraform.d.ts +0 -66
- package/dist/lib/terraform.js +0 -754
- package/terraform/aws/main.tf +0 -355
- package/terraform/azure/main.tf +0 -371
- package/terraform/gcp/main.tf +0 -407
package/dist/lib/terraform.js
DELETED
|
@@ -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
|
-
}
|