@rulebricks/cli 2.1.2 → 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.
@@ -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
- await terraformDestroy(name);
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" }));
@@ -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
- await terraformDestroy(name);
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
- return (_jsx(BorderBox, { title: "Destruction Complete", children: _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_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)))] })), noClusterCleanup && status.cleanup === "success" && (_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, "/"] }) }))] }) }));
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") {
@@ -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
- * Destroys Terraform infrastructure.
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): Promise<void>;
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
  */
@@ -169,37 +169,77 @@ export async function terraformApply(deploymentName) {
169
169
  }
170
170
  }
171
171
  /**
172
- * Destroys Terraform infrastructure.
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
- // Run init first to ensure terraform is ready (handles partial deployments
179
- // where .terraform folder might be missing or corrupted)
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', ['init', '-upgrade'], {
219
+ await execa('terraform', ['destroy', '-auto-approve'], {
182
220
  cwd: workDir
183
221
  });
222
+ lastError = undefined;
223
+ break;
184
224
  }
185
- catch (initError) {
186
- // If init fails, still try destroy - it might work if state exists
187
- const execaInitError = initError;
188
- if (execaInitError.stdout || execaInitError.stderr) {
189
- await saveLogFile(workDir, 'destroy-init', execaInitError.stdout || '', execaInitError.stderr || '');
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
- catch (error) {
198
- const execaError = error;
199
- if (execaError.stdout || execaError.stderr) {
200
- await saveLogFile(workDir, 'destroy', execaError.stdout || '', execaError.stderr || '');
201
- }
202
- throw new Error(`Terraform destroy failed:\n${getErrorMessage(error, 'Unknown error')}\n\nLogs saved to: ${workDir}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulebricks/cli",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "CLI for deploying and managing private Rulebricks instances",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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