@rulebricks/cli 2.1.1 → 2.1.3
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/dist/commands/deploy.js +15 -3
- package/dist/commands/destroy.js +14 -3
- package/dist/lib/terraform.d.ts +12 -2
- package/dist/lib/terraform.js +60 -20
- package/package.json +1 -1
- package/terraform/aws/main.tf +7 -1
package/dist/commands/deploy.js
CHANGED
|
@@ -5,7 +5,7 @@ import { platform } from "os";
|
|
|
5
5
|
import { BorderBox, Spinner, StatusLine, ThemeProvider, useTheme, Logo, } from "../components/common/index.js";
|
|
6
6
|
import { DNSWaitScreen } from "../components/DNSWaitScreen.js";
|
|
7
7
|
import { loadDeploymentConfig, loadDeploymentState, saveDeploymentState, updateDeploymentStatus, saveTerraformVars, } from "../lib/config.js";
|
|
8
|
-
import { setupTerraformWorkspace, terraformInit, terraformPlan, terraformApply, terraformDestroy, updateKubeconfig, hasTerraformState, isTerraformInstalled, generateTerraformVars, } from "../lib/terraform.js";
|
|
8
|
+
import { setupTerraformWorkspace, terraformInit, terraformPlan, terraformApply, terraformDestroy, cleanupOrphanedResources, updateKubeconfig, hasTerraformState, isTerraformInstalled, generateTerraformVars, } from "../lib/terraform.js";
|
|
9
9
|
import { checkGcpApplicationDefaultCredentials, checkAzureResourceProviders, checkAzureVmQuota, AZURE_TIER_CORES, } from "../lib/cloudCli.js";
|
|
10
10
|
import { installOrUpgradeChart, upgradeChart, isHelmInstalled, } from "../lib/helm.js";
|
|
11
11
|
import { isKubectlInstalled, checkClusterAccessible, } from "../lib/kubernetes.js";
|
|
@@ -32,7 +32,14 @@ function DeployCommandInner({ name, skipInfra, skipDns, version, }) {
|
|
|
32
32
|
const handleCleanup = useCallback(async () => {
|
|
33
33
|
setStep("cleanup-running");
|
|
34
34
|
try {
|
|
35
|
-
|
|
35
|
+
const cloudContext = config?.infrastructure.provider && config?.infrastructure.region
|
|
36
|
+
? {
|
|
37
|
+
provider: config.infrastructure.provider,
|
|
38
|
+
clusterName: config.infrastructure.clusterName || `${name}-cluster`,
|
|
39
|
+
region: config.infrastructure.region,
|
|
40
|
+
}
|
|
41
|
+
: undefined;
|
|
42
|
+
await terraformDestroy(name, cloudContext);
|
|
36
43
|
setStep("cleanup-complete");
|
|
37
44
|
setTimeout(() => exit(), 3000);
|
|
38
45
|
}
|
|
@@ -41,7 +48,7 @@ function DeployCommandInner({ name, skipInfra, skipDns, version, }) {
|
|
|
41
48
|
setStep("cleanup-complete");
|
|
42
49
|
setTimeout(() => exit(), 5000);
|
|
43
50
|
}
|
|
44
|
-
}, [name, exit]);
|
|
51
|
+
}, [name, config, exit]);
|
|
45
52
|
const skipCleanup = useCallback(() => {
|
|
46
53
|
setStep("error");
|
|
47
54
|
}, []);
|
|
@@ -167,6 +174,11 @@ function DeployCommandInner({ name, skipInfra, skipDns, version, }) {
|
|
|
167
174
|
await terraformInit(name);
|
|
168
175
|
setStep("infra-plan");
|
|
169
176
|
await terraformPlan(name);
|
|
177
|
+
// Clean up orphaned cloud resources from prior failed deployments
|
|
178
|
+
// (e.g. CloudWatch log groups that survived an incomplete destroy)
|
|
179
|
+
if (cfg.infrastructure.provider && cfg.infrastructure.region) {
|
|
180
|
+
await cleanupOrphanedResources(cfg.infrastructure.provider, cfg.infrastructure.clusterName || `${name}-cluster`, cfg.infrastructure.region);
|
|
181
|
+
}
|
|
170
182
|
setStep("infra-apply");
|
|
171
183
|
await terraformApply(name);
|
|
172
184
|
setStatus((s) => ({ ...s, infrastructure: "success" }));
|
package/dist/commands/destroy.js
CHANGED
|
@@ -16,6 +16,7 @@ function DestroyCommandInner({ name, cluster, config, force, }) {
|
|
|
16
16
|
const [scope, setScope] = useState(null);
|
|
17
17
|
const [error, setError] = useState(null);
|
|
18
18
|
const [confirmText, setConfirmText] = useState("");
|
|
19
|
+
const [infraError, setInfraError] = useState(null);
|
|
19
20
|
const [status, setStatus] = useState({
|
|
20
21
|
helm: "pending",
|
|
21
22
|
pvc: "pending",
|
|
@@ -163,10 +164,18 @@ function DestroyCommandInner({ name, cluster, config, force, }) {
|
|
|
163
164
|
if (cluster && deploymentScope.hasInfrastructure) {
|
|
164
165
|
setStatus((s) => ({ ...s, infrastructure: "running" }));
|
|
165
166
|
try {
|
|
166
|
-
|
|
167
|
+
const cloudContext = cfg?.infrastructure.provider && cfg?.infrastructure.region
|
|
168
|
+
? {
|
|
169
|
+
provider: cfg.infrastructure.provider,
|
|
170
|
+
clusterName: cfg.infrastructure.clusterName || `${name}-cluster`,
|
|
171
|
+
region: cfg.infrastructure.region,
|
|
172
|
+
}
|
|
173
|
+
: undefined;
|
|
174
|
+
await terraformDestroy(name, cloudContext);
|
|
167
175
|
setStatus((s) => ({ ...s, infrastructure: "success" }));
|
|
168
176
|
}
|
|
169
|
-
catch {
|
|
177
|
+
catch (infraErr) {
|
|
178
|
+
setInfraError(infraErr instanceof Error ? infraErr.message : "Infrastructure destroy failed");
|
|
170
179
|
setStatus((s) => ({ ...s, infrastructure: "error" }));
|
|
171
180
|
}
|
|
172
181
|
}
|
|
@@ -220,7 +229,9 @@ function DestroyCommandInner({ name, cluster, config, force, }) {
|
|
|
220
229
|
const noClusterCleanup = status.helm === "skipped" &&
|
|
221
230
|
status.pvc === "skipped" &&
|
|
222
231
|
status.namespace === "skipped";
|
|
223
|
-
|
|
232
|
+
const hasInfraFailure = status.infrastructure === "error";
|
|
233
|
+
const title = hasInfraFailure ? "Destruction Partially Complete" : "Destruction Complete";
|
|
234
|
+
return (_jsx(BorderBox, { title: title, children: _jsxs(Box, { flexDirection: "column", marginY: 1, children: [hasInfraFailure ? (_jsxs(Text, { color: colors.warning, bold: true, children: ["\u26A0 Deployment \"", name, "\" was partially destroyed"] })) : (_jsxs(Text, { color: colors.success, bold: true, children: ["\u2713 Deployment \"", name, "\" has been destroyed"] })), cleanedItems.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.muted, children: "Cleaned up:" }), cleanedItems.map((item) => (_jsxs(Text, { color: colors.muted, children: [" ", "\u2022 ", item] }, item)))] })), hasInfraFailure && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.error, bold: true, children: "\u2717 Infrastructure destroy failed" }), _jsx(Text, { color: colors.error, children: infraError }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.muted, children: ["Cloud resources may still exist. Run `rulebricks destroy ", name, " ", "--cluster` to retry."] }) })] })), noClusterCleanup && status.cleanup === "success" && !hasInfraFailure && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, dimColor: true, children: "Note: No cluster resources found, only local files were cleaned up." }) })), status.cleanup === "skipped" && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.muted, dimColor: true, children: ["Local configuration files preserved in ~/.rulebricks/deployments/", name, "/"] }) }))] }) }));
|
|
224
235
|
}
|
|
225
236
|
// Destroying screen
|
|
226
237
|
if (step === "destroying") {
|
package/dist/lib/terraform.d.ts
CHANGED
|
@@ -24,10 +24,20 @@ export declare function terraformPlan(deploymentName: string): Promise<void>;
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function terraformApply(deploymentName: string): Promise<void>;
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
27
|
+
* Cleans up orphaned cloud resources that may linger after a failed deploy or
|
|
28
|
+
* incomplete destroy. Best-effort: failures are logged but never thrown.
|
|
29
|
+
*/
|
|
30
|
+
export declare function cleanupOrphanedResources(provider: CloudProvider, clusterName: string, region: string): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Destroys Terraform infrastructure with retry logic.
|
|
28
33
|
* Runs init first to ensure .terraform folder exists (handles partial deployments).
|
|
34
|
+
* After all attempts, sweeps orphaned cloud resources that Terraform doesn't manage.
|
|
29
35
|
*/
|
|
30
|
-
export declare function terraformDestroy(deploymentName: string
|
|
36
|
+
export declare function terraformDestroy(deploymentName: string, cloudContext?: {
|
|
37
|
+
provider: CloudProvider;
|
|
38
|
+
clusterName: string;
|
|
39
|
+
region: string;
|
|
40
|
+
}): Promise<void>;
|
|
31
41
|
/**
|
|
32
42
|
* Gets Terraform outputs
|
|
33
43
|
*/
|
package/dist/lib/terraform.js
CHANGED
|
@@ -169,37 +169,77 @@ export async function terraformApply(deploymentName) {
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
/**
|
|
172
|
-
*
|
|
172
|
+
* Cleans up orphaned cloud resources that may linger after a failed deploy or
|
|
173
|
+
* incomplete destroy. Best-effort: failures are logged but never thrown.
|
|
174
|
+
*/
|
|
175
|
+
export async function cleanupOrphanedResources(provider, clusterName, region) {
|
|
176
|
+
if (provider === 'aws') {
|
|
177
|
+
// The EKS module (or AWS itself) creates /aws/eks/<cluster>/cluster.
|
|
178
|
+
// Since we disabled Terraform management of this log group, we must
|
|
179
|
+
// delete it ourselves to ensure a clean slate.
|
|
180
|
+
const logGroupName = `/aws/eks/${clusterName}/cluster`;
|
|
181
|
+
try {
|
|
182
|
+
await execa('aws', [
|
|
183
|
+
'logs', 'delete-log-group',
|
|
184
|
+
'--log-group-name', logGroupName,
|
|
185
|
+
'--region', region,
|
|
186
|
+
]);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Log group may not exist — that's fine
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// GCP and Azure don't have an equivalent orphan problem today
|
|
193
|
+
}
|
|
194
|
+
const DESTROY_MAX_ATTEMPTS = 3;
|
|
195
|
+
const DESTROY_RETRY_DELAY_MS = 5_000;
|
|
196
|
+
/**
|
|
197
|
+
* Destroys Terraform infrastructure with retry logic.
|
|
173
198
|
* Runs init first to ensure .terraform folder exists (handles partial deployments).
|
|
199
|
+
* After all attempts, sweeps orphaned cloud resources that Terraform doesn't manage.
|
|
174
200
|
*/
|
|
175
|
-
export async function terraformDestroy(deploymentName) {
|
|
201
|
+
export async function terraformDestroy(deploymentName, cloudContext) {
|
|
176
202
|
const workDir = getTerraformDir(deploymentName);
|
|
203
|
+
// Run init first to ensure terraform is ready
|
|
177
204
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
205
|
+
await execa('terraform', ['init', '-upgrade'], {
|
|
206
|
+
cwd: workDir
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (initError) {
|
|
210
|
+
const execaInitError = initError;
|
|
211
|
+
if (execaInitError.stdout || execaInitError.stderr) {
|
|
212
|
+
await saveLogFile(workDir, 'destroy-init', execaInitError.stdout || '', execaInitError.stderr || '');
|
|
213
|
+
}
|
|
214
|
+
// Don't throw — continue to try destroy anyway
|
|
215
|
+
}
|
|
216
|
+
let lastError;
|
|
217
|
+
for (let attempt = 1; attempt <= DESTROY_MAX_ATTEMPTS; attempt++) {
|
|
180
218
|
try {
|
|
181
|
-
await execa('terraform', ['
|
|
219
|
+
await execa('terraform', ['destroy', '-auto-approve'], {
|
|
182
220
|
cwd: workDir
|
|
183
221
|
});
|
|
222
|
+
lastError = undefined;
|
|
223
|
+
break;
|
|
184
224
|
}
|
|
185
|
-
catch (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
225
|
+
catch (error) {
|
|
226
|
+
const execaError = error;
|
|
227
|
+
if (execaError.stdout || execaError.stderr) {
|
|
228
|
+
await saveLogFile(workDir, `destroy-attempt-${attempt}`, execaError.stdout || '', execaError.stderr || '');
|
|
229
|
+
}
|
|
230
|
+
lastError = new Error(`Terraform destroy failed (attempt ${attempt}/${DESTROY_MAX_ATTEMPTS}):\n` +
|
|
231
|
+
`${getErrorMessage(error, 'Unknown error')}\n\nLogs saved to: ${workDir}`);
|
|
232
|
+
if (attempt < DESTROY_MAX_ATTEMPTS) {
|
|
233
|
+
await new Promise((r) => setTimeout(r, DESTROY_RETRY_DELAY_MS));
|
|
190
234
|
}
|
|
191
|
-
// Don't throw - continue to try destroy anyway
|
|
192
235
|
}
|
|
193
|
-
await execa('terraform', ['destroy', '-auto-approve'], {
|
|
194
|
-
cwd: workDir
|
|
195
|
-
});
|
|
196
236
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
throw
|
|
237
|
+
// Best-effort cleanup of orphaned cloud resources regardless of destroy outcome
|
|
238
|
+
if (cloudContext) {
|
|
239
|
+
await cleanupOrphanedResources(cloudContext.provider, cloudContext.clusterName, cloudContext.region);
|
|
240
|
+
}
|
|
241
|
+
if (lastError) {
|
|
242
|
+
throw lastError;
|
|
203
243
|
}
|
|
204
244
|
}
|
|
205
245
|
/**
|
package/package.json
CHANGED
package/terraform/aws/main.tf
CHANGED
|
@@ -142,6 +142,12 @@ module "eks" {
|
|
|
142
142
|
cluster_endpoint_public_access = true
|
|
143
143
|
cluster_endpoint_private_access = true
|
|
144
144
|
|
|
145
|
+
# Disable Terraform-managed CloudWatch log group to prevent
|
|
146
|
+
# ResourceAlreadyExistsException on re-deploy after partial failures.
|
|
147
|
+
# AWS creates the log group automatically if control-plane logging is enabled.
|
|
148
|
+
create_cloudwatch_log_group = false
|
|
149
|
+
cluster_enabled_log_types = []
|
|
150
|
+
|
|
145
151
|
vpc_id = module.vpc.vpc_id
|
|
146
152
|
subnet_ids = module.vpc.private_subnets
|
|
147
153
|
|
|
@@ -150,7 +156,7 @@ module "eks" {
|
|
|
150
156
|
rulebricks = {
|
|
151
157
|
name = "rulebricks-nodes"
|
|
152
158
|
instance_types = [local.config.instance_type]
|
|
153
|
-
ami_type = "
|
|
159
|
+
ami_type = "AL2023_ARM_64_STANDARD" # ARM AMI for Graviton instances
|
|
154
160
|
|
|
155
161
|
min_size = local.config.min_nodes
|
|
156
162
|
max_size = local.config.max_nodes
|