vibecarbon 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/destroy.js +903 -899
- package/src/lib/deploy/compose/index.js +43 -37
- package/src/lib/deploy/compose/single.js +421 -0
- package/src/lib/deploy/image.js +19 -7
- package/src/lib/deploy/k8s/k3s.js +346 -265
- package/src/lib/deploy/orchestrator.js +110 -459
- package/src/lib/deploy/tier-registry.js +30 -0
- package/src/lib/retry.js +59 -0
- package/src/lib/scale-plan.js +159 -0
- package/src/scale.js +101 -131
package/src/destroy.js
CHANGED
|
@@ -32,6 +32,12 @@ import {
|
|
|
32
32
|
} from './lib/cloudflare.js';
|
|
33
33
|
import { c, printBanner } from './lib/colors.js';
|
|
34
34
|
import { loadCredentials, loadProjectConfig, saveProjectConfig } from './lib/config.js';
|
|
35
|
+
import {
|
|
36
|
+
isComposeTier,
|
|
37
|
+
isK8sTier,
|
|
38
|
+
pulumiStackEnvs,
|
|
39
|
+
resolveTier,
|
|
40
|
+
} from './lib/deploy/tier-registry.js';
|
|
35
41
|
import { fetchWithRetry } from './lib/fetch-retry.js';
|
|
36
42
|
import {
|
|
37
43
|
deleteDNSRecord as hetznerDnsDeleteRecord,
|
|
@@ -41,6 +47,7 @@ import { getApiToken, getS3Credentials } from './lib/hetzner-guided-setup.js';
|
|
|
41
47
|
import { perfAsync } from './lib/perf.js';
|
|
42
48
|
import { assertInProjectDir } from './lib/project-guard.js';
|
|
43
49
|
import { HetznerS3Provider, sanitizeBucketName } from './lib/providers/hetzner-s3.js';
|
|
50
|
+
import { pollUntil } from './lib/retry.js';
|
|
44
51
|
import { createTracker } from './lib/tracker.js';
|
|
45
52
|
import { VERSION } from './lib/version.js';
|
|
46
53
|
|
|
@@ -54,6 +61,35 @@ export function requiresProdTypeToConfirm(envName) {
|
|
|
54
61
|
return /^(prod|production)$/i.test(envName);
|
|
55
62
|
}
|
|
56
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Pure planner: derive every teardown target from persisted config, with no
|
|
66
|
+
* network/filesystem access. Returns the canonical tier id plus the derived
|
|
67
|
+
* Pulumi stack envs, cluster names, whether a k8s Pulumi stack exists, and the
|
|
68
|
+
* owned server IPs that gate ownership-filtered DNS deletes.
|
|
69
|
+
*
|
|
70
|
+
* tier dispatch is the ONLY place allowed to look at deployMode/ha, so this
|
|
71
|
+
* delegates to tier-registry (resolveTier / pulumiStackEnvs) rather than
|
|
72
|
+
* re-deriving mode+ha combinations locally.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} envConfig - persisted environment config.
|
|
75
|
+
* @param {object} projectConfig - persisted project config (needs projectName).
|
|
76
|
+
* @param {string} environment - environment name being destroyed.
|
|
77
|
+
* @returns {{ tier: string, stackEnvs: string[], hasPulumiStack: boolean, clusterNames: string[], ownedIps: string[] }}
|
|
78
|
+
*/
|
|
79
|
+
export function planDestroyTargets(envConfig, projectConfig, environment) {
|
|
80
|
+
const tier = resolveTier(envConfig);
|
|
81
|
+
const stackEnvs = pulumiStackEnvs(tier, environment);
|
|
82
|
+
const hasPulumiStack = isK8sTier(tier);
|
|
83
|
+
const projectName = projectConfig.projectName;
|
|
84
|
+
// For HA k8s each sub-cluster has its own network/naming prefix
|
|
85
|
+
// (<project>-<env>-primary / -standby); single tiers get one.
|
|
86
|
+
const clusterNames = stackEnvs.map((stackEnv) => `${projectName}-${stackEnv}`);
|
|
87
|
+
// Owned IPs gate every DNS delete — only records pointing at this env's
|
|
88
|
+
// servers are removed. See deleteDNSRecord doc in lib/cloudflare.js.
|
|
89
|
+
const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
|
|
90
|
+
return { tier, stackEnvs, hasPulumiStack, clusterNames, ownedIps };
|
|
91
|
+
}
|
|
92
|
+
|
|
57
93
|
// ============================================================================
|
|
58
94
|
// CLI ARGUMENT PARSING
|
|
59
95
|
// ============================================================================
|
|
@@ -278,18 +314,23 @@ async function hetznerDeleteServer(apiToken, serverId) {
|
|
|
278
314
|
// Observed during compose restore. The same delete-then-poll fix is
|
|
279
315
|
// applied in destroyComposeHA.
|
|
280
316
|
if (response.status !== 404) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
317
|
+
// Poll GET /servers/{id} until 404 (90s budget, 2s fixed interval). A
|
|
318
|
+
// throwing probe (transient) is treated as falsy and retried; on budget
|
|
319
|
+
// exhaustion we simply stop waiting — the return value below is unaffected.
|
|
320
|
+
await pollUntil(
|
|
321
|
+
async () => {
|
|
284
322
|
const probe = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}`, {
|
|
285
323
|
headers: { Authorization: `Bearer ${apiToken}` },
|
|
286
324
|
});
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
325
|
+
return probe.status === 404;
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
budgetMs: 90_000,
|
|
329
|
+
initialDelayMs: 2_000,
|
|
330
|
+
backoffFactor: 1,
|
|
331
|
+
description: `server ${serverId} deletion`,
|
|
332
|
+
},
|
|
333
|
+
).catch(() => {});
|
|
293
334
|
}
|
|
294
335
|
|
|
295
336
|
return response.status !== 404;
|
|
@@ -303,43 +344,65 @@ async function hetznerDeleteFirewall(apiToken, name) {
|
|
|
303
344
|
// (also fixed identically in destroyComposeHA).
|
|
304
345
|
const headers = { Authorization: `Bearer ${apiToken}` };
|
|
305
346
|
const lookupUrl = `https://api.hetzner.cloud/v1/firewalls?name=${encodeURIComponent(name)}`;
|
|
306
|
-
const deadlineMs = 30_000;
|
|
307
|
-
const start = Date.now();
|
|
308
347
|
let everExisted = false;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
348
|
+
let apiError = null;
|
|
349
|
+
// Delete-until-gone under a 30s budget with a fixed 3s interval. Each probe
|
|
350
|
+
// re-looks-up the firewall (to refresh applied_to), detaches servers, then
|
|
351
|
+
// DELETEs; a 409 (still attached) returns a falsy result so pollUntil backs
|
|
352
|
+
// off and retries. A truthy wrapper object signals "done" — needed because
|
|
353
|
+
// the "firewall gone" outcome can carry `everExisted === false`, which a bare
|
|
354
|
+
// falsy return would misread as "keep polling". A genuine not-found therefore
|
|
355
|
+
// returns the truthy { value: false } wrapper PROMPTLY (pollUntil never
|
|
356
|
+
// throws for it). pollUntil only throws on budget exhaustion, which means the
|
|
357
|
+
// delete never completed — either the lookup/DELETE kept failing (err.cause
|
|
358
|
+
// is the last API error) or the firewall stayed attached (persistent 409).
|
|
359
|
+
// Either way it's a probable LEAK, not a not-found, so we remember the
|
|
360
|
+
// underlying error and let the caller surface it instead of "not found".
|
|
361
|
+
const outcome = await pollUntil(
|
|
362
|
+
async () => {
|
|
363
|
+
const listResp = await fetchWithRetry(lookupUrl, { headers });
|
|
364
|
+
const { firewalls } = await listResp.json();
|
|
365
|
+
const fw = firewalls?.[0];
|
|
366
|
+
if (!fw) return { value: everExisted };
|
|
367
|
+
everExisted = true;
|
|
368
|
+
if (fw.applied_to?.length > 0) {
|
|
369
|
+
const serverResources = fw.applied_to
|
|
370
|
+
.filter((a) => a.type === 'server' && a.server?.id)
|
|
371
|
+
.map((a) => ({ type: 'server', server: { id: a.server.id } }));
|
|
372
|
+
if (serverResources.length > 0) {
|
|
373
|
+
try {
|
|
374
|
+
await fetchWithRetry(
|
|
375
|
+
`https://api.hetzner.cloud/v1/firewalls/${fw.id}/actions/remove_from_resources`,
|
|
376
|
+
{
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
379
|
+
body: JSON.stringify({ remove_from: serverResources }),
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
} catch {
|
|
383
|
+
// Servers may already be gone — proceed to DELETE anyway
|
|
384
|
+
}
|
|
331
385
|
}
|
|
332
386
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
387
|
+
const delResp = await fetchWithRetry(`https://api.hetzner.cloud/v1/firewalls/${fw.id}`, {
|
|
388
|
+
method: 'DELETE',
|
|
389
|
+
headers,
|
|
390
|
+
});
|
|
391
|
+
if (delResp.ok || delResp.status === 404) return { value: true };
|
|
392
|
+
// 409 = still attached; back off and re-look-up to refresh applied_to.
|
|
393
|
+
return null;
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
budgetMs: 30_000,
|
|
397
|
+
initialDelayMs: 3_000,
|
|
398
|
+
backoffFactor: 1,
|
|
399
|
+
description: `firewall ${name} deletion`,
|
|
400
|
+
},
|
|
401
|
+
).catch((err) => {
|
|
402
|
+
apiError = err?.cause ?? err;
|
|
403
|
+
return { value: false };
|
|
404
|
+
});
|
|
405
|
+
return { deleted: outcome.value === true, everExisted, apiError };
|
|
343
406
|
}
|
|
344
407
|
|
|
345
408
|
async function hetznerDeleteSSHKey(apiToken, name) {
|
|
@@ -817,710 +880,402 @@ function destroyOutro(envName, timeStr, results) {
|
|
|
817
880
|
}
|
|
818
881
|
|
|
819
882
|
// ============================================================================
|
|
820
|
-
//
|
|
883
|
+
// SHARED TEARDOWN TAIL
|
|
821
884
|
// ============================================================================
|
|
822
885
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
886
|
+
/**
|
|
887
|
+
* Shared teardown tail run once after the per-tier strategy. Covers, in order:
|
|
888
|
+
* app S3 bucket delete, Pulumi state bucket, backup bucket, (optional) GitHub
|
|
889
|
+
* environment, project-config removal + save, local env-file cleanup,
|
|
890
|
+
* (optional) summary note, and the final outro.
|
|
891
|
+
*
|
|
892
|
+
* Tier-specific divergences are explicit booleans rather than silent unions:
|
|
893
|
+
* - `deleteGithubEnv` — only k8s deletes the GitHub environment.
|
|
894
|
+
* - `showSummary` — only k8s prints the deleted-resources summary note.
|
|
895
|
+
* - `defaultBucketName` — k8s falls back to the predictable deploy-time bucket
|
|
896
|
+
* name (sanitizeBucketName(projectName)) when envConfig.s3.bucket is unset;
|
|
897
|
+
* compose tiers only delete an explicitly-configured bucket.
|
|
898
|
+
*/
|
|
899
|
+
async function finishDestroy({
|
|
900
|
+
envConfig,
|
|
901
|
+
projectConfig,
|
|
902
|
+
environment,
|
|
903
|
+
args,
|
|
904
|
+
results,
|
|
905
|
+
spinner,
|
|
906
|
+
tracker,
|
|
907
|
+
cwd,
|
|
908
|
+
deleteGithubEnv = false,
|
|
909
|
+
showSummary = false,
|
|
910
|
+
defaultBucketName = false,
|
|
911
|
+
}) {
|
|
912
|
+
// 1. Delete the app S3 bucket (created during deploy bootstrap, not part of
|
|
913
|
+
// any Pulumi stack's resource graph). k8s can fall back to the predictable
|
|
914
|
+
// deploy-time name; compose tiers only act on a configured bucket.
|
|
915
|
+
const s3BucketName =
|
|
916
|
+
envConfig.s3?.bucket ||
|
|
917
|
+
(defaultBucketName ? sanitizeBucketName(projectConfig.projectName) : null);
|
|
918
|
+
if (s3BucketName) {
|
|
919
|
+
const s3Region = envConfig.s3?.region || 'fsn1';
|
|
920
|
+
spinner.start(`Deleting S3 bucket: ${s3BucketName}`);
|
|
921
|
+
try {
|
|
922
|
+
const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
|
|
923
|
+
if (s3Creds) {
|
|
924
|
+
const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
|
|
925
|
+
try {
|
|
926
|
+
const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
|
|
927
|
+
if (result.deleted) {
|
|
928
|
+
results.s3Bucket = s3BucketName;
|
|
929
|
+
spinner.stop(
|
|
930
|
+
result.objectsRemoved > 0
|
|
931
|
+
? `S3 bucket deleted (${result.objectsRemoved} objects removed)`
|
|
932
|
+
: 'S3 bucket deleted',
|
|
933
|
+
);
|
|
934
|
+
} else {
|
|
935
|
+
spinner.stop('S3 bucket not found');
|
|
936
|
+
}
|
|
937
|
+
} catch (bucketError) {
|
|
938
|
+
// NoSuchBucket means it was already deleted or never created — not an error
|
|
939
|
+
if (
|
|
940
|
+
bucketError.name === 'NoSuchBucket' ||
|
|
941
|
+
bucketError.$metadata?.httpStatusCode === 404
|
|
942
|
+
) {
|
|
943
|
+
spinner.stop('S3 bucket not found (already deleted or never created)');
|
|
944
|
+
} else {
|
|
945
|
+
throw bucketError;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
} else {
|
|
949
|
+
spinner.stop('S3 bucket skipped (no credentials available)');
|
|
950
|
+
results.issues.push({
|
|
951
|
+
step: 'S3 bucket',
|
|
952
|
+
detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
|
|
953
|
+
hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
} catch (error) {
|
|
957
|
+
spinner.stop(`S3 bucket deletion failed: ${error.message}`);
|
|
958
|
+
results.issues.push({
|
|
959
|
+
step: 'S3 bucket',
|
|
960
|
+
detail: `${s3BucketName} (${s3Region}) not deleted: ${error.message}`,
|
|
961
|
+
hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
|
|
962
|
+
});
|
|
829
963
|
}
|
|
830
|
-
process.stderr.write(`Run ${c.info('vibecarbon destroy -h')} for usage.\n`);
|
|
831
|
-
process.exit(1);
|
|
832
964
|
}
|
|
833
965
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
966
|
+
// 2. State bucket: deleted LAST relative to any Pulumi stack teardown (the
|
|
967
|
+
// strategy already ran `pulumi destroy` + `removeStack` before returning),
|
|
968
|
+
// so tearing the state backend down here is safe.
|
|
969
|
+
await deleteStateBucket(envConfig, projectConfig, spinner, results);
|
|
970
|
+
|
|
971
|
+
// 3. Backup bucket: preserved by default, deleted with --purge-backups.
|
|
972
|
+
await handleBackupBucket(envConfig, projectConfig, args, spinner);
|
|
973
|
+
|
|
974
|
+
// 4. GitHub environment (k8s only).
|
|
975
|
+
if (deleteGithubEnv) {
|
|
976
|
+
spinner.start(`Deleting GitHub environment: ${environment}`);
|
|
977
|
+
try {
|
|
978
|
+
results.github = deleteGitHubEnvironment(environment);
|
|
979
|
+
spinner.stop(
|
|
980
|
+
results.github
|
|
981
|
+
? 'GitHub environment deleted'
|
|
982
|
+
: 'GitHub environment not found (or no access)',
|
|
983
|
+
);
|
|
984
|
+
} catch (error) {
|
|
985
|
+
spinner.stop(`Failed to delete GitHub environment: ${error.message}`);
|
|
986
|
+
}
|
|
841
987
|
}
|
|
842
988
|
|
|
843
|
-
//
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
989
|
+
// 5. Remove the destroyed environment from project config.
|
|
990
|
+
spinner.start('Updating project configuration');
|
|
991
|
+
const updatedConfig = { ...projectConfig };
|
|
992
|
+
if (updatedConfig.environments) {
|
|
993
|
+
delete updatedConfig.environments[environment];
|
|
994
|
+
}
|
|
995
|
+
saveProjectConfig(updatedConfig);
|
|
996
|
+
spinner.stop('Configuration updated');
|
|
997
|
+
|
|
998
|
+
// 6. Local env-file cleanup (idempotent — keys, kubeconfigs, deploy-state).
|
|
999
|
+
cleanupLocalEnvFiles(cwd, environment);
|
|
1000
|
+
|
|
1001
|
+
// 7. Summary note (k8s only).
|
|
1002
|
+
if (showSummary) {
|
|
1003
|
+
const deletedCount =
|
|
1004
|
+
results.servers.length +
|
|
1005
|
+
results.volumes.length +
|
|
1006
|
+
results.firewalls.length +
|
|
1007
|
+
results.sshKeys.length +
|
|
1008
|
+
results.dns.length +
|
|
1009
|
+
results.healthChecks.length +
|
|
1010
|
+
results.loadBalancers.length +
|
|
1011
|
+
(results.s3Bucket ? 1 : 0) +
|
|
1012
|
+
(results.github ? 1 : 0);
|
|
1013
|
+
|
|
1014
|
+
p.note(
|
|
1015
|
+
`
|
|
1016
|
+
${c.success('Deleted resources:')}
|
|
1017
|
+
Servers: ${results.servers.length > 0 ? results.servers.join(', ') : 'None'}
|
|
1018
|
+
Volumes: ${results.volumes.length > 0 ? results.volumes.join(', ') : 'None'}
|
|
1019
|
+
Firewalls: ${results.firewalls.length > 0 ? results.firewalls.join(', ') : 'None'}
|
|
1020
|
+
SSH Keys: ${results.sshKeys.length > 0 ? results.sshKeys.join(', ') : 'None'}
|
|
1021
|
+
DNS Records: ${results.dns.length > 0 ? results.dns.join(', ') : 'None'}
|
|
1022
|
+
Health Checks: ${results.healthChecks.length > 0 ? results.healthChecks.join(', ') : 'None'}
|
|
1023
|
+
Load Balancers: ${results.loadBalancers.length > 0 ? results.loadBalancers.join(', ') : 'None'}
|
|
1024
|
+
S3 Bucket: ${results.s3Bucket || 'None'}
|
|
1025
|
+
GitHub Env: ${results.github ? environment : 'None'}
|
|
857
1026
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
cwd = process.cwd();
|
|
862
|
-
} catch {
|
|
863
|
-
console.error(`\n${c.error('Error:')} Current working directory does not exist.`);
|
|
864
|
-
console.error(
|
|
865
|
-
`This can happen if the project directory was deleted while this command was running.`,
|
|
1027
|
+
${c.dim(`Total: ${deletedCount} resources deleted`)}
|
|
1028
|
+
`,
|
|
1029
|
+
'Destruction Complete',
|
|
866
1030
|
);
|
|
867
|
-
console.error(`\nPlease navigate to a valid directory and try again.`);
|
|
868
|
-
process.exit(1);
|
|
869
1031
|
}
|
|
870
1032
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1033
|
+
const { formatted: timeStr } = tracker.finish();
|
|
1034
|
+
destroyOutro(environment, timeStr, results);
|
|
1035
|
+
}
|
|
874
1036
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1037
|
+
// ============================================================================
|
|
1038
|
+
// PER-TIER DESTROY STRATEGIES
|
|
1039
|
+
// ============================================================================
|
|
878
1040
|
|
|
879
|
-
|
|
1041
|
+
/**
|
|
1042
|
+
* compose-ha: hand the whole teardown (both nodes, firewalls, DNS) to the
|
|
1043
|
+
* compose HA helper. The shared tail handles buckets/config afterwards.
|
|
1044
|
+
*/
|
|
1045
|
+
async function destroyComposeHATier({
|
|
1046
|
+
envConfig,
|
|
1047
|
+
projectConfig,
|
|
1048
|
+
environment,
|
|
1049
|
+
hetznerToken,
|
|
1050
|
+
cloudflareToken,
|
|
1051
|
+
tracker,
|
|
1052
|
+
}) {
|
|
1053
|
+
const { destroyComposeHA } = await import('./lib/deploy/compose/ha.js');
|
|
1054
|
+
|
|
1055
|
+
const s2 = tracker.spinner();
|
|
1056
|
+
s2.start('Destroying Compose HA environment...');
|
|
1057
|
+
try {
|
|
1058
|
+
await destroyComposeHA({
|
|
1059
|
+
projectName: projectConfig.projectName,
|
|
1060
|
+
environment,
|
|
1061
|
+
envConfig,
|
|
1062
|
+
hetznerToken,
|
|
1063
|
+
cloudflareToken,
|
|
1064
|
+
onProgress: (msg) => s2.message(msg),
|
|
1065
|
+
});
|
|
1066
|
+
s2.stop('Compose HA environment destroyed');
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
s2.stop(`Compose HA destruction failed: ${error.message}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
880
1071
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1072
|
+
/**
|
|
1073
|
+
* compose (single VPS): stop the stack, then reap the VPS, firewall, SSH key
|
|
1074
|
+
* and DNS via the Hetzner/Cloudflare APIs directly (no Pulumi teardown).
|
|
1075
|
+
*/
|
|
1076
|
+
async function destroyComposeTier({
|
|
1077
|
+
plan,
|
|
1078
|
+
envConfig,
|
|
1079
|
+
projectConfig,
|
|
1080
|
+
environment,
|
|
1081
|
+
hetznerToken,
|
|
1082
|
+
cloudflareToken,
|
|
1083
|
+
results,
|
|
1084
|
+
spinner: s,
|
|
1085
|
+
cwd,
|
|
1086
|
+
}) {
|
|
1087
|
+
const { ownedIps } = plan;
|
|
1088
|
+
const serverIp = envConfig.servers?.[0]?.ip;
|
|
1089
|
+
const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${environment}`);
|
|
893
1090
|
|
|
894
|
-
if (
|
|
895
|
-
//
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1091
|
+
if (serverIp && existsSync(sshKeyPath)) {
|
|
1092
|
+
// Stop and remove Docker Compose services
|
|
1093
|
+
s.start('Stopping Docker Compose services...');
|
|
1094
|
+
try {
|
|
1095
|
+
const { destroyCompose } = await import('./lib/deploy/compose/index.js');
|
|
1096
|
+
await destroyCompose(serverIp, sshKeyPath, projectConfig.projectName);
|
|
1097
|
+
s.stop('Docker Compose services removed');
|
|
1098
|
+
results.servers.push(`vps-${serverIp}`);
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
s.stop(`Failed to stop services: ${error.message}`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
902
1103
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1104
|
+
// Delete Hetzner VPS if auto-provisioned (has server ID in config)
|
|
1105
|
+
const serverId = envConfig.servers?.[0]?.id;
|
|
1106
|
+
// The `name` field in envConfig.servers is a role label ("master") set by
|
|
1107
|
+
// the orchestrator, not the Hetzner-assigned hostname. Pulumi's compose
|
|
1108
|
+
// program names the server `${projectName}-${environment}`. The
|
|
1109
|
+
// orchestrator persists that under `hetznerName`; configs older than
|
|
1110
|
+
// that change only have role-style names, so derive the Pulumi name as
|
|
1111
|
+
// a deterministic fallback — looking up "master" in Hetzner never
|
|
1112
|
+
// matches and leaks the VPS.
|
|
1113
|
+
const serverHetznerName =
|
|
1114
|
+
envConfig.servers?.[0]?.hetznerName || `${projectConfig.projectName}-${environment}`;
|
|
1115
|
+
if (serverId && hetznerToken) {
|
|
1116
|
+
s.start(`Deleting VPS (${serverIp})...`);
|
|
1117
|
+
try {
|
|
1118
|
+
const deleted = await hetznerDeleteServer(hetznerToken, serverId);
|
|
1119
|
+
s.stop(deleted ? 'VPS deleted' : 'VPS not found');
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
s.stop(`Failed to delete VPS: ${error.message}`);
|
|
1122
|
+
}
|
|
1123
|
+
} else if (!serverId && hetznerToken && serverHetznerName) {
|
|
1124
|
+
// Fallback: find server by name if ID wasn't saved (older deploys
|
|
1125
|
+
// or runs where Pulumi didn't propagate serverId).
|
|
1126
|
+
s.start(`Looking up VPS by name: ${serverHetznerName}...`);
|
|
1127
|
+
try {
|
|
1128
|
+
const servers = await hetznerListServers(hetznerToken);
|
|
1129
|
+
const server = servers.find((sv) => sv.name === serverHetznerName);
|
|
1130
|
+
if (server) {
|
|
1131
|
+
const deleted = await hetznerDeleteServer(hetznerToken, server.id);
|
|
1132
|
+
s.stop(deleted ? 'VPS deleted' : 'VPS not found');
|
|
1133
|
+
} else {
|
|
1134
|
+
s.stop('VPS not found');
|
|
910
1135
|
}
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
s.stop(`Failed to delete VPS: ${error.message}`);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
911
1140
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1141
|
+
// Delete Hetzner firewall.
|
|
1142
|
+
// Pulumi (src/lib/iac/programs/hetzner-compose.js) names the firewall
|
|
1143
|
+
// `${projectName}-${environment}-firewall`
|
|
1144
|
+
// Earlier this used `vibecarbon-${projectName}-${envName}` which never
|
|
1145
|
+
// matched anything Pulumi created — the firewall always leaked, and
|
|
1146
|
+
// the next compose restore re-deploy hit "name is already used
|
|
1147
|
+
// (uniqueness_error)" on Pulumi up. Match the actual Pulumi naming
|
|
1148
|
+
// convention (the equivalent fix exists in destroyComposeHA).
|
|
1149
|
+
const fwName = `${projectConfig.projectName}-${environment}-firewall`;
|
|
1150
|
+
if (hetznerToken) {
|
|
1151
|
+
s.start(`Deleting firewall: ${fwName}`);
|
|
1152
|
+
try {
|
|
1153
|
+
const result = await hetznerDeleteFirewall(hetznerToken, fwName);
|
|
1154
|
+
if (result.deleted) {
|
|
1155
|
+
results.firewalls.push(fwName);
|
|
1156
|
+
s.stop('Firewall deleted');
|
|
1157
|
+
} else if (result.apiError) {
|
|
1158
|
+
// Delete never confirmed within budget (persistent API error or a
|
|
1159
|
+
// firewall that stayed attached) — a probable LEAK, not a not-found.
|
|
1160
|
+
// A leaked firewall fails the NEXT deploy with uniqueness_error, so
|
|
1161
|
+
// surface it as an issue instead of the misleading "not found".
|
|
1162
|
+
s.stop(`Failed to delete firewall: ${result.apiError.message}`);
|
|
1163
|
+
results.issues.push({
|
|
1164
|
+
step: 'firewall',
|
|
1165
|
+
detail: `${fwName} may still exist (delete did not complete): ${result.apiError.message}`,
|
|
1166
|
+
hint: 'Delete it via the Hetzner console — a leaked firewall makes the next deploy fail with "name is already used (uniqueness_error)".',
|
|
1167
|
+
});
|
|
1168
|
+
} else {
|
|
1169
|
+
s.stop('Firewall not found');
|
|
928
1170
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
initialValue: false, // Default to NO for safety
|
|
934
|
-
});
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
s.stop(`Failed to delete firewall: ${error.message}`);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
935
1175
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1176
|
+
// Delete Hetzner SSH key.
|
|
1177
|
+
// Pulumi names the key
|
|
1178
|
+
// `${projectName}-${environment}-${location}-key`
|
|
1179
|
+
// — the location suffix is what kept the old `vibecarbon-` prefix from
|
|
1180
|
+
// ever matching. Same root cause as the firewall above.
|
|
1181
|
+
const composeRegion = envConfig.region || envConfig.servers?.[0]?.region;
|
|
1182
|
+
const hetznerSshKeyName = composeRegion
|
|
1183
|
+
? `${projectConfig.projectName}-${environment}-${composeRegion}-key`
|
|
1184
|
+
: null;
|
|
1185
|
+
if (hetznerToken && hetznerSshKeyName) {
|
|
1186
|
+
s.start(`Deleting SSH key: ${hetznerSshKeyName}`);
|
|
1187
|
+
try {
|
|
1188
|
+
const deleted = await hetznerDeleteSSHKey(hetznerToken, hetznerSshKeyName);
|
|
1189
|
+
if (deleted) results.sshKeys.push(hetznerSshKeyName);
|
|
1190
|
+
s.stop(deleted ? 'SSH key deleted' : 'SSH key not found');
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
s.stop(`Failed to delete SSH key: ${error.message}`);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
940
1195
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
// "Missing Hetzner Cloud API token" mid-destroy. The s3Config
|
|
945
|
-
// must match the one findOrphanPulumiStacks used to discover
|
|
946
|
-
// these stacks, otherwise resolveBackendUrl picks a different
|
|
947
|
-
// backend and the destroy can't find them.
|
|
948
|
-
const orphanCreds = loadCredentials();
|
|
949
|
-
const orphanApiToken =
|
|
950
|
-
process.env.HETZNER_API_TOKEN ||
|
|
951
|
-
orphanCreds?.hetzner?.apiToken ||
|
|
952
|
-
orphanCreds?.hetznerApiToken ||
|
|
953
|
-
'';
|
|
954
|
-
const orphanS3Config =
|
|
955
|
-
projectConfig?.s3Config && orphanCreds?.s3
|
|
956
|
-
? {
|
|
957
|
-
...projectConfig.s3Config,
|
|
958
|
-
accessKey: orphanCreds.s3.accessKey,
|
|
959
|
-
secretKey: orphanCreds.s3.secretKey,
|
|
960
|
-
}
|
|
961
|
-
: null;
|
|
1196
|
+
// DNS cleanup is ownership-filtered (plan.ownedIps): only records pointing at
|
|
1197
|
+
// this env's server IPs are deleted. Shared zones (e.g. parallel e2e
|
|
1198
|
+
// scenarios on the same root domain) used to lose neighbours' records here.
|
|
962
1199
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1200
|
+
// Delete Cloudflare DNS if configured
|
|
1201
|
+
if (cloudflareToken && envConfig.dns?.cloudflareZoneId && envConfig.domain) {
|
|
1202
|
+
const zoneId = envConfig.dns.cloudflareZoneId;
|
|
1203
|
+
|
|
1204
|
+
s.start(`Deleting DNS record: ${envConfig.domain}`);
|
|
1205
|
+
try {
|
|
1206
|
+
const result = await cloudflareDeleteDNSRecord(
|
|
1207
|
+
cloudflareToken,
|
|
1208
|
+
zoneId,
|
|
1209
|
+
envConfig.domain,
|
|
1210
|
+
ownedIps,
|
|
1211
|
+
);
|
|
1212
|
+
if (result.deleted > 0) results.dns.push(envConfig.domain);
|
|
1213
|
+
if (result.skipped > 0) {
|
|
1214
|
+
s.stop(
|
|
1215
|
+
`DNS record preserved (${result.skipped} unowned target(s): ${result.skippedTargets.join(', ')})`,
|
|
966
1216
|
);
|
|
967
|
-
|
|
1217
|
+
} else {
|
|
1218
|
+
s.stop(result.deleted > 0 ? 'DNS record deleted' : 'DNS record not found');
|
|
968
1219
|
}
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
s.stop(`DNS deletion failed: ${error.message}`);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
969
1224
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
p.log.error('No environments found in .vibecarbon.json');
|
|
989
|
-
p.log.info('Nothing to destroy.');
|
|
990
|
-
process.exit(1);
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
p.log.info(`Project: ${c.bold(projectConfig.projectName)}`);
|
|
994
|
-
|
|
995
|
-
// Select environment to destroy
|
|
996
|
-
const envNames = Object.keys(projectConfig.environments);
|
|
997
|
-
let envName = args.env;
|
|
998
|
-
|
|
999
|
-
if (!envName) {
|
|
1000
|
-
if (envNames.length === 1) {
|
|
1001
|
-
envName = envNames[0];
|
|
1002
|
-
} else {
|
|
1003
|
-
envName = await p.select({
|
|
1004
|
-
message: 'Which environment do you want to destroy?',
|
|
1005
|
-
options: envNames.map((name) => ({
|
|
1006
|
-
value: name,
|
|
1007
|
-
label: name,
|
|
1008
|
-
hint: projectConfig.environments[name].servers?.map((s) => s.ip).join(', '),
|
|
1009
|
-
})),
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
if (p.isCancel(envName)) {
|
|
1015
|
-
p.cancel('Operation cancelled.');
|
|
1016
|
-
process.exit(0);
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
const envConfig = projectConfig.environments[envName];
|
|
1020
|
-
if (!envConfig) {
|
|
1021
|
-
p.log.error(`Environment '${envName}' not found`);
|
|
1022
|
-
p.log.info(`Available environments: ${envNames.join(', ')}`);
|
|
1023
|
-
process.exit(1);
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// Show what will be deleted
|
|
1027
|
-
const serverList =
|
|
1028
|
-
envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name} (${s.ip})`).join('\n') ||
|
|
1029
|
-
' None';
|
|
1030
|
-
|
|
1031
|
-
p.note(
|
|
1032
|
-
`
|
|
1033
|
-
${c.danger('This will permanently delete:')}
|
|
1034
|
-
|
|
1035
|
-
${c.bold('Servers:')}
|
|
1036
|
-
${serverList}
|
|
1037
|
-
|
|
1038
|
-
${c.bold('Firewalls:')}
|
|
1039
|
-
${envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name}-fw`).join('\n') || ' None'}
|
|
1040
|
-
|
|
1041
|
-
${c.bold('SSH Keys:')}
|
|
1042
|
-
${c.error('•')} vibecarbon-${projectConfig.projectName}-${envName}
|
|
1043
|
-
|
|
1044
|
-
${
|
|
1045
|
-
envConfig.dns?.provider === 'cloudflare'
|
|
1046
|
-
? `${c.bold('Cloudflare Resources:')}
|
|
1047
|
-
${c.error('•')} DNS record: ${envConfig.domain}
|
|
1048
|
-
${c.error('•')} Health checks
|
|
1049
|
-
${envConfig.ha ? `${c.error('•')} Wildcard DNS record` : ''}
|
|
1050
|
-
`
|
|
1051
|
-
: ''
|
|
1052
|
-
}
|
|
1053
|
-
${
|
|
1054
|
-
envConfig.s3?.bucket
|
|
1055
|
-
? `${c.bold('S3 Storage:')}
|
|
1056
|
-
${c.error('•')} Bucket: ${envConfig.s3.bucket} (all objects will be deleted)
|
|
1057
|
-
`
|
|
1058
|
-
: ''
|
|
1059
|
-
}${
|
|
1060
|
-
envConfig.backupS3?.bucket
|
|
1061
|
-
? `${c.bold('S3 Backups:')}
|
|
1062
|
-
${args.purgeBackups ? `${c.error('•')} Bucket: ${envConfig.backupS3.bucket} (WILL BE DELETED — --purge-backups)` : `${c.success('•')} Bucket: ${envConfig.backupS3.bucket} (PRESERVED for recovery)`}
|
|
1063
|
-
`
|
|
1064
|
-
: ''
|
|
1065
|
-
}
|
|
1066
|
-
${c.bold('GitHub:')}
|
|
1067
|
-
${c.error('•')} Environment: ${envName}
|
|
1068
|
-
|
|
1069
|
-
${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
1070
|
-
`,
|
|
1071
|
-
`Destroying: ${envName}`,
|
|
1072
|
-
);
|
|
1073
|
-
|
|
1074
|
-
const needsProdConfirm = requiresProdTypeToConfirm(envName);
|
|
1075
|
-
|
|
1076
|
-
// First prompt: only when not --yes.
|
|
1077
|
-
if (!args.yes) {
|
|
1078
|
-
const confirmed = await p.confirm({
|
|
1079
|
-
message: `Are you sure you want to destroy the ${c.bold(envName)} environment?`,
|
|
1080
|
-
initialValue: false,
|
|
1081
|
-
});
|
|
1082
|
-
if (!confirmed || p.isCancel(confirmed)) {
|
|
1083
|
-
p.cancel('Operation cancelled.');
|
|
1084
|
-
process.exit(0);
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// Type-to-confirm: runs whenever NOT --yes, OR the env is prod/production
|
|
1089
|
-
// (even with --yes). Protects against `--env prod --yes` typos in CI/scripts.
|
|
1090
|
-
if (!args.yes || needsProdConfirm) {
|
|
1091
|
-
const confirmSlug = `${projectConfig.projectName}-${envName}`;
|
|
1092
|
-
if (args.yes && needsProdConfirm) {
|
|
1093
|
-
p.log.warn(
|
|
1094
|
-
`Destroying a production environment still requires type-to-confirm, even with --yes.`,
|
|
1095
|
-
);
|
|
1096
|
-
}
|
|
1097
|
-
const doubleConfirm = await p.text({
|
|
1098
|
-
message: `Type "${confirmSlug}" to confirm:`,
|
|
1099
|
-
validate: (v) => (v !== confirmSlug ? `Please type "${confirmSlug}" to confirm` : undefined),
|
|
1100
|
-
});
|
|
1101
|
-
if (p.isCancel(doubleConfirm)) {
|
|
1102
|
-
p.cancel('Operation cancelled.');
|
|
1103
|
-
process.exit(0);
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
// Offer to create a backup before destroying
|
|
1108
|
-
const serverIp = envConfig.servers?.[0]?.ip;
|
|
1109
|
-
const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${envName}`);
|
|
1110
|
-
if (serverIp && existsSync(sshKeyPath) && !args.yes) {
|
|
1111
|
-
const offerBackup = await p.confirm({
|
|
1112
|
-
message: 'Create a backup before destroying? (recommended)',
|
|
1113
|
-
initialValue: true,
|
|
1114
|
-
});
|
|
1115
|
-
|
|
1116
|
-
if (!p.isCancel(offerBackup) && offerBackup) {
|
|
1117
|
-
const backupSpinner = p.spinner();
|
|
1118
|
-
backupSpinner.start('Creating backup...');
|
|
1119
|
-
try {
|
|
1120
|
-
if (envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha') {
|
|
1121
|
-
const { backupCompose } = await import('./lib/deploy/compose/index.js');
|
|
1122
|
-
await backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
|
|
1123
|
-
retain: envConfig.backup?.retentionDays,
|
|
1124
|
-
});
|
|
1125
|
-
backupSpinner.stop('wal-g base backup pushed to S3');
|
|
1126
|
-
}
|
|
1127
|
-
} catch (backupError) {
|
|
1128
|
-
backupSpinner.stop(`Backup failed: ${backupError.message}`);
|
|
1129
|
-
p.log.warn('Continuing with destroy...');
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
// Get API tokens (env var → credentials file → interactive prompt)
|
|
1135
|
-
const hetznerToken = await getApiToken(projectConfig.projectName);
|
|
1136
|
-
if (!hetznerToken) {
|
|
1137
|
-
p.log.error('Hetzner API token is required');
|
|
1138
|
-
process.exit(1);
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
let cloudflareToken = null;
|
|
1142
|
-
if (envConfig.dns?.provider === 'cloudflare' && envConfig.dns?.cloudflareZoneId) {
|
|
1143
|
-
// Check env var → credentials file → prompt
|
|
1144
|
-
cloudflareToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
1145
|
-
|
|
1146
|
-
if (!cloudflareToken) {
|
|
1147
|
-
const creds = loadCredentials();
|
|
1148
|
-
if (creds.cloudflare?.apiToken) {
|
|
1149
|
-
cloudflareToken = creds.cloudflare.apiToken;
|
|
1150
|
-
p.log.info('✓ Using Cloudflare API token from ~/.vibecarbon/credentials.json');
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
if (!cloudflareToken) {
|
|
1155
|
-
cloudflareToken = await p.password({
|
|
1156
|
-
message: 'Enter Cloudflare API token (for DNS cleanup)',
|
|
1157
|
-
validate: (v) => (v.length < 10 ? 'API token is required' : undefined),
|
|
1158
|
-
});
|
|
1159
|
-
|
|
1160
|
-
if (p.isCancel(cloudflareToken)) {
|
|
1161
|
-
p.cancel('Operation cancelled.');
|
|
1162
|
-
process.exit(0);
|
|
1163
|
-
}
|
|
1164
|
-
} else if (process.env.CLOUDFLARE_API_TOKEN) {
|
|
1165
|
-
p.log.info('✓ Using Cloudflare API token from CLOUDFLARE_API_TOKEN environment variable');
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
// ========== DESTRUCTION ==========
|
|
1170
|
-
|
|
1171
|
-
const tracker = createTracker('destroy', { environment: envName });
|
|
1172
|
-
const s = tracker.spinner();
|
|
1173
|
-
const results = {
|
|
1174
|
-
servers: [],
|
|
1175
|
-
volumes: [],
|
|
1176
|
-
firewalls: [],
|
|
1177
|
-
sshKeys: [],
|
|
1178
|
-
dns: [],
|
|
1179
|
-
healthChecks: [],
|
|
1180
|
-
loadBalancers: [],
|
|
1181
|
-
s3Bucket: null,
|
|
1182
|
-
github: false,
|
|
1183
|
-
// Sub-step failures that didn't abort destroy but left state behind
|
|
1184
|
-
// (e.g., S3 bucket couldn't be emptied). The outro surfaces these so the
|
|
1185
|
-
// operator doesn't miss them, and the process exits non-zero so CI sees it.
|
|
1186
|
-
issues: [],
|
|
1187
|
-
};
|
|
1188
|
-
|
|
1189
|
-
// ========== COMPOSE-HA DESTRUCTION (if deployed via Docker Compose HA) ==========
|
|
1190
|
-
if (envConfig.deployMode === 'compose-ha') {
|
|
1191
|
-
const { destroyComposeHA } = await import('./lib/deploy/compose/ha.js');
|
|
1192
|
-
|
|
1193
|
-
const s2 = tracker.spinner();
|
|
1194
|
-
s2.start('Destroying Compose HA environment...');
|
|
1195
|
-
try {
|
|
1196
|
-
await destroyComposeHA({
|
|
1197
|
-
projectName: projectConfig.projectName,
|
|
1198
|
-
environment: envName,
|
|
1199
|
-
envConfig,
|
|
1200
|
-
hetznerToken,
|
|
1201
|
-
cloudflareToken,
|
|
1202
|
-
onProgress: (msg) => s2.message(msg),
|
|
1203
|
-
});
|
|
1204
|
-
s2.stop('Compose HA environment destroyed');
|
|
1205
|
-
} catch (error) {
|
|
1206
|
-
s2.stop(`Compose HA destruction failed: ${error.message}`);
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
// Delete S3 bucket if configured
|
|
1210
|
-
if (envConfig.s3?.bucket) {
|
|
1211
|
-
const s3BucketName = envConfig.s3.bucket;
|
|
1212
|
-
const s3Region = envConfig.s3?.region || 'fsn1';
|
|
1213
|
-
s2.start(`Deleting S3 bucket: ${s3BucketName}`);
|
|
1214
|
-
try {
|
|
1215
|
-
const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
|
|
1216
|
-
if (s3Creds) {
|
|
1217
|
-
const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
|
|
1218
|
-
try {
|
|
1219
|
-
const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
|
|
1220
|
-
s2.stop(result.deleted ? 'S3 bucket deleted' : 'S3 bucket not found');
|
|
1221
|
-
} catch (bucketError) {
|
|
1222
|
-
if (
|
|
1223
|
-
bucketError.name === 'NoSuchBucket' ||
|
|
1224
|
-
bucketError.$metadata?.httpStatusCode === 404
|
|
1225
|
-
) {
|
|
1226
|
-
s2.stop('S3 bucket not found (already deleted)');
|
|
1227
|
-
} else {
|
|
1228
|
-
s2.stop(`S3 bucket deletion failed: ${bucketError.message}`);
|
|
1229
|
-
results.issues.push({
|
|
1230
|
-
step: 'S3 bucket',
|
|
1231
|
-
detail: `${s3BucketName} (${s3Region}) not deleted: ${bucketError.message}`,
|
|
1232
|
-
hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
|
|
1233
|
-
});
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
} else {
|
|
1237
|
-
s2.stop('S3 bucket skipped (no credentials available)');
|
|
1238
|
-
results.issues.push({
|
|
1239
|
-
step: 'S3 bucket',
|
|
1240
|
-
detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
|
|
1241
|
-
hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
|
|
1242
|
-
});
|
|
1243
|
-
}
|
|
1244
|
-
} catch (error) {
|
|
1245
|
-
s2.stop(`S3 bucket deletion failed: ${error.message}`);
|
|
1246
|
-
results.issues.push({
|
|
1247
|
-
step: 'S3 bucket',
|
|
1248
|
-
detail: `${s3BucketName} not deleted: ${error.message}`,
|
|
1249
|
-
hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket.',
|
|
1250
|
-
});
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
// State bucket: deleted AFTER Compose-HA teardown (both stacks are torn
|
|
1255
|
-
// down inside destroyComposeHA above), so it can't yank the backend early.
|
|
1256
|
-
await deleteStateBucket(envConfig, projectConfig, s2, results);
|
|
1257
|
-
|
|
1258
|
-
// Backup bucket: preserved by default, deleted with --purge-backups.
|
|
1259
|
-
await handleBackupBucket(envConfig, projectConfig, args, s2);
|
|
1260
|
-
|
|
1261
|
-
// Update project config to remove environment
|
|
1262
|
-
const updatedConfig = { ...projectConfig };
|
|
1263
|
-
if (updatedConfig.environments) {
|
|
1264
|
-
delete updatedConfig.environments[envName];
|
|
1265
|
-
}
|
|
1266
|
-
saveProjectConfig(updatedConfig);
|
|
1267
|
-
|
|
1268
|
-
cleanupLocalEnvFiles(cwd, envName);
|
|
1269
|
-
|
|
1270
|
-
const { formatted: timeStr } = tracker.finish();
|
|
1271
|
-
destroyOutro(envName, timeStr, results);
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
// ========== COMPOSE DESTRUCTION (if deployed via Docker Compose) ==========
|
|
1276
|
-
if (envConfig.deployMode === 'compose') {
|
|
1277
|
-
const serverIp = envConfig.servers?.[0]?.ip;
|
|
1278
|
-
const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${envName}`);
|
|
1279
|
-
|
|
1280
|
-
if (serverIp && existsSync(sshKeyPath)) {
|
|
1281
|
-
// Stop and remove Docker Compose services
|
|
1282
|
-
s.start('Stopping Docker Compose services...');
|
|
1283
|
-
try {
|
|
1284
|
-
const { destroyCompose } = await import('./lib/deploy/compose/index.js');
|
|
1285
|
-
await destroyCompose(serverIp, sshKeyPath, projectConfig.projectName);
|
|
1286
|
-
s.stop('Docker Compose services removed');
|
|
1287
|
-
results.servers.push(`vps-${serverIp}`);
|
|
1288
|
-
} catch (error) {
|
|
1289
|
-
s.stop(`Failed to stop services: ${error.message}`);
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
// Delete Hetzner VPS if auto-provisioned (has server ID in config)
|
|
1294
|
-
const serverId = envConfig.servers?.[0]?.id;
|
|
1295
|
-
// The `name` field in envConfig.servers is a role label ("master") set by
|
|
1296
|
-
// the orchestrator, not the Hetzner-assigned hostname. Pulumi's compose
|
|
1297
|
-
// program names the server `${projectName}-${environment}`. The
|
|
1298
|
-
// orchestrator persists that under `hetznerName`; configs older than
|
|
1299
|
-
// that change only have role-style names, so derive the Pulumi name as
|
|
1300
|
-
// a deterministic fallback — looking up "master" in Hetzner never
|
|
1301
|
-
// matches and leaks the VPS.
|
|
1302
|
-
const serverHetznerName =
|
|
1303
|
-
envConfig.servers?.[0]?.hetznerName || `${projectConfig.projectName}-${envName}`;
|
|
1304
|
-
if (serverId && hetznerToken) {
|
|
1305
|
-
s.start(`Deleting VPS (${serverIp})...`);
|
|
1306
|
-
try {
|
|
1307
|
-
const deleted = await hetznerDeleteServer(hetznerToken, serverId);
|
|
1308
|
-
s.stop(deleted ? 'VPS deleted' : 'VPS not found');
|
|
1309
|
-
} catch (error) {
|
|
1310
|
-
s.stop(`Failed to delete VPS: ${error.message}`);
|
|
1311
|
-
}
|
|
1312
|
-
} else if (!serverId && hetznerToken && serverHetznerName) {
|
|
1313
|
-
// Fallback: find server by name if ID wasn't saved (older deploys
|
|
1314
|
-
// or runs where Pulumi didn't propagate serverId).
|
|
1315
|
-
s.start(`Looking up VPS by name: ${serverHetznerName}...`);
|
|
1316
|
-
try {
|
|
1317
|
-
const servers = await hetznerListServers(hetznerToken);
|
|
1318
|
-
const server = servers.find((sv) => sv.name === serverHetznerName);
|
|
1319
|
-
if (server) {
|
|
1320
|
-
const deleted = await hetznerDeleteServer(hetznerToken, server.id);
|
|
1321
|
-
s.stop(deleted ? 'VPS deleted' : 'VPS not found');
|
|
1322
|
-
} else {
|
|
1323
|
-
s.stop('VPS not found');
|
|
1324
|
-
}
|
|
1325
|
-
} catch (error) {
|
|
1326
|
-
s.stop(`Failed to delete VPS: ${error.message}`);
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
// Delete Hetzner firewall.
|
|
1331
|
-
// Pulumi (src/lib/iac/programs/hetzner-compose.js) names the firewall
|
|
1332
|
-
// `${projectName}-${environment}-firewall`
|
|
1333
|
-
// Earlier this used `vibecarbon-${projectName}-${envName}` which never
|
|
1334
|
-
// matched anything Pulumi created — the firewall always leaked, and
|
|
1335
|
-
// the next compose restore re-deploy hit "name is already used
|
|
1336
|
-
// (uniqueness_error)" on Pulumi up. Match the actual Pulumi naming
|
|
1337
|
-
// convention (the equivalent fix exists in destroyComposeHA).
|
|
1338
|
-
const fwName = `${projectConfig.projectName}-${envName}-firewall`;
|
|
1339
|
-
if (hetznerToken) {
|
|
1340
|
-
s.start(`Deleting firewall: ${fwName}`);
|
|
1341
|
-
try {
|
|
1342
|
-
const deleted = await hetznerDeleteFirewall(hetznerToken, fwName);
|
|
1343
|
-
if (deleted) results.firewalls.push(fwName);
|
|
1344
|
-
s.stop(deleted ? 'Firewall deleted' : 'Firewall not found');
|
|
1345
|
-
} catch (error) {
|
|
1346
|
-
s.stop(`Failed to delete firewall: ${error.message}`);
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
// Delete Hetzner SSH key.
|
|
1351
|
-
// Pulumi names the key
|
|
1352
|
-
// `${projectName}-${environment}-${location}-key`
|
|
1353
|
-
// — the location suffix is what kept the old `vibecarbon-` prefix from
|
|
1354
|
-
// ever matching. Same root cause as the firewall above.
|
|
1355
|
-
const composeRegion = envConfig.region || envConfig.servers?.[0]?.region;
|
|
1356
|
-
const hetznerSshKeyName = composeRegion
|
|
1357
|
-
? `${projectConfig.projectName}-${envName}-${composeRegion}-key`
|
|
1358
|
-
: null;
|
|
1359
|
-
if (hetznerToken && hetznerSshKeyName) {
|
|
1360
|
-
s.start(`Deleting SSH key: ${hetznerSshKeyName}`);
|
|
1361
|
-
try {
|
|
1362
|
-
const deleted = await hetznerDeleteSSHKey(hetznerToken, hetznerSshKeyName);
|
|
1363
|
-
if (deleted) results.sshKeys.push(hetznerSshKeyName);
|
|
1364
|
-
s.stop(deleted ? 'SSH key deleted' : 'SSH key not found');
|
|
1365
|
-
} catch (error) {
|
|
1366
|
-
s.stop(`Failed to delete SSH key: ${error.message}`);
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
// DNS cleanup is ownership-filtered: only records pointing at this env's
|
|
1371
|
-
// server IPs are deleted. Shared zones (e.g. parallel e2e scenarios
|
|
1372
|
-
// on the same root domain) used to lose neighbours' records here — see
|
|
1373
|
-
// the deleteDNSRecord doc for context.
|
|
1374
|
-
const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
|
|
1375
|
-
|
|
1376
|
-
// Delete Cloudflare DNS if configured
|
|
1377
|
-
if (cloudflareToken && envConfig.dns?.cloudflareZoneId && envConfig.domain) {
|
|
1378
|
-
const zoneId = envConfig.dns.cloudflareZoneId;
|
|
1379
|
-
|
|
1380
|
-
s.start(`Deleting DNS record: ${envConfig.domain}`);
|
|
1381
|
-
try {
|
|
1382
|
-
const result = await cloudflareDeleteDNSRecord(
|
|
1383
|
-
cloudflareToken,
|
|
1384
|
-
zoneId,
|
|
1385
|
-
envConfig.domain,
|
|
1386
|
-
ownedIps,
|
|
1387
|
-
);
|
|
1388
|
-
if (result.deleted > 0) results.dns.push(envConfig.domain);
|
|
1389
|
-
if (result.skipped > 0) {
|
|
1390
|
-
s.stop(
|
|
1391
|
-
`DNS record preserved (${result.skipped} unowned target(s): ${result.skippedTargets.join(', ')})`,
|
|
1392
|
-
);
|
|
1393
|
-
} else {
|
|
1394
|
-
s.stop(result.deleted > 0 ? 'DNS record deleted' : 'DNS record not found');
|
|
1395
|
-
}
|
|
1396
|
-
} catch (error) {
|
|
1397
|
-
s.stop(`DNS deletion failed: ${error.message}`);
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
// Delete Hetzner DNS if configured
|
|
1402
|
-
if (envConfig.dns?.provider === 'hetzner' && envConfig.dns?.hetznerZoneId && envConfig.domain) {
|
|
1403
|
-
const zoneId = envConfig.dns.hetznerZoneId;
|
|
1404
|
-
s.start(`Deleting Hetzner DNS record: ${envConfig.domain}`);
|
|
1405
|
-
try {
|
|
1406
|
-
const zone = await hetznerDnsGetZone(hetznerToken, zoneId);
|
|
1407
|
-
const [root, wildcard] = await Promise.all([
|
|
1408
|
-
hetznerDnsDeleteRecord(hetznerToken, zoneId, zone.name, envConfig.domain, ownedIps),
|
|
1409
|
-
hetznerDnsDeleteRecord(
|
|
1410
|
-
hetznerToken,
|
|
1411
|
-
zoneId,
|
|
1412
|
-
zone.name,
|
|
1413
|
-
`*.${envConfig.domain}`,
|
|
1414
|
-
ownedIps,
|
|
1415
|
-
),
|
|
1416
|
-
]);
|
|
1417
|
-
if (root.deleted || wildcard.deleted) results.dns.push(envConfig.domain);
|
|
1418
|
-
const preserved = [root, wildcard].filter((r) => r.skipped);
|
|
1419
|
-
if (preserved.length > 0) {
|
|
1420
|
-
const targets = preserved.flatMap((p) => p.skippedTargets);
|
|
1421
|
-
s.stop(`Hetzner DNS records preserved (unowned targets: ${targets.join(', ')})`);
|
|
1422
|
-
} else {
|
|
1423
|
-
s.stop('Hetzner DNS records deleted');
|
|
1424
|
-
}
|
|
1425
|
-
} catch (error) {
|
|
1426
|
-
s.stop(`Hetzner DNS cleanup failed: ${error.message}`);
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
// Delete S3 bucket if configured
|
|
1431
|
-
if (envConfig.s3?.bucket) {
|
|
1432
|
-
const s3BucketName = envConfig.s3.bucket;
|
|
1433
|
-
const s3Region = envConfig.s3?.region || 'fsn1';
|
|
1434
|
-
s.start(`Deleting S3 bucket: ${s3BucketName}`);
|
|
1435
|
-
try {
|
|
1436
|
-
const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
|
|
1437
|
-
if (s3Creds) {
|
|
1438
|
-
const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
|
|
1439
|
-
try {
|
|
1440
|
-
const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
|
|
1441
|
-
if (result.deleted) results.s3Bucket = s3BucketName;
|
|
1442
|
-
s.stop(result.deleted ? 'S3 bucket deleted' : 'S3 bucket not found');
|
|
1443
|
-
} catch (bucketError) {
|
|
1444
|
-
if (
|
|
1445
|
-
bucketError.name === 'NoSuchBucket' ||
|
|
1446
|
-
bucketError.$metadata?.httpStatusCode === 404
|
|
1447
|
-
) {
|
|
1448
|
-
s.stop('S3 bucket not found (already deleted or never created)');
|
|
1449
|
-
} else {
|
|
1450
|
-
throw bucketError;
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
} else {
|
|
1454
|
-
s.stop('S3 bucket skipped (no credentials available)');
|
|
1455
|
-
results.issues.push({
|
|
1456
|
-
step: 'S3 bucket',
|
|
1457
|
-
detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
|
|
1458
|
-
hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
|
|
1459
|
-
});
|
|
1460
|
-
}
|
|
1461
|
-
} catch (error) {
|
|
1462
|
-
s.stop(`S3 bucket deletion failed: ${error.message}`);
|
|
1463
|
-
results.issues.push({
|
|
1464
|
-
step: 'S3 bucket',
|
|
1465
|
-
detail: `${s3BucketName} (${s3Region}) not deleted: ${error.message}`,
|
|
1466
|
-
hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
|
|
1467
|
-
});
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
// State bucket: deleted after the compose teardown above. (Compose destroy
|
|
1472
|
-
// reaps resources via the Hetzner API rather than `pulumi destroy`, so
|
|
1473
|
-
// there's no in-flight stack op to protect — but ordering it here keeps the
|
|
1474
|
-
// "state bucket last" invariant uniform across all destroy paths.)
|
|
1475
|
-
await deleteStateBucket(envConfig, projectConfig, s, results);
|
|
1476
|
-
|
|
1477
|
-
// Backup bucket: preserved by default, deleted with --purge-backups.
|
|
1478
|
-
await handleBackupBucket(envConfig, projectConfig, args, s);
|
|
1479
|
-
|
|
1480
|
-
// Clean up local SSH key files
|
|
1481
|
-
const sshKeyFiles = [sshKeyPath, `${sshKeyPath}.pub`];
|
|
1482
|
-
for (const f of sshKeyFiles) {
|
|
1483
|
-
if (existsSync(f)) {
|
|
1484
|
-
try {
|
|
1485
|
-
rmSync(f);
|
|
1486
|
-
} catch {
|
|
1487
|
-
// Non-fatal
|
|
1488
|
-
}
|
|
1225
|
+
// Delete Hetzner DNS if configured
|
|
1226
|
+
if (envConfig.dns?.provider === 'hetzner' && envConfig.dns?.hetznerZoneId && envConfig.domain) {
|
|
1227
|
+
const zoneId = envConfig.dns.hetznerZoneId;
|
|
1228
|
+
s.start(`Deleting Hetzner DNS record: ${envConfig.domain}`);
|
|
1229
|
+
try {
|
|
1230
|
+
const zone = await hetznerDnsGetZone(hetznerToken, zoneId);
|
|
1231
|
+
const [root, wildcard] = await Promise.all([
|
|
1232
|
+
hetznerDnsDeleteRecord(hetznerToken, zoneId, zone.name, envConfig.domain, ownedIps),
|
|
1233
|
+
hetznerDnsDeleteRecord(hetznerToken, zoneId, zone.name, `*.${envConfig.domain}`, ownedIps),
|
|
1234
|
+
]);
|
|
1235
|
+
if (root.deleted || wildcard.deleted) results.dns.push(envConfig.domain);
|
|
1236
|
+
const preserved = [root, wildcard].filter((r) => r.skipped);
|
|
1237
|
+
if (preserved.length > 0) {
|
|
1238
|
+
const targets = preserved.flatMap((p) => p.skippedTargets);
|
|
1239
|
+
s.stop(`Hetzner DNS records preserved (unowned targets: ${targets.join(', ')})`);
|
|
1240
|
+
} else {
|
|
1241
|
+
s.stop('Hetzner DNS records deleted');
|
|
1489
1242
|
}
|
|
1243
|
+
} catch (error) {
|
|
1244
|
+
s.stop(`Hetzner DNS cleanup failed: ${error.message}`);
|
|
1490
1245
|
}
|
|
1491
|
-
|
|
1492
|
-
// Update project config to remove environment
|
|
1493
|
-
const updatedConfig = { ...projectConfig };
|
|
1494
|
-
if (updatedConfig.environments) {
|
|
1495
|
-
delete updatedConfig.environments[envName];
|
|
1496
|
-
}
|
|
1497
|
-
saveProjectConfig(updatedConfig);
|
|
1498
|
-
|
|
1499
|
-
cleanupLocalEnvFiles(cwd, envName);
|
|
1500
|
-
|
|
1501
|
-
const { formatted: timeStr } = tracker.finish();
|
|
1502
|
-
destroyOutro(envName, timeStr, results);
|
|
1503
|
-
return;
|
|
1504
1246
|
}
|
|
1247
|
+
}
|
|
1505
1248
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1249
|
+
/**
|
|
1250
|
+
* k8s / k8s-ha: Cloudflare + Hetzner DNS, then Pulumi-managed infra teardown
|
|
1251
|
+
* (volume pre-scan, CCM LB cleanup, CSI release, CA-worker sweep, `pulumi
|
|
1252
|
+
* destroy` per stack, orphan sweep, HA shared-key delete), then orphaned CSI
|
|
1253
|
+
* volume cleanup. plan.stackEnvs already differs for k8s vs k8s-ha, so a single
|
|
1254
|
+
* function serves both.
|
|
1255
|
+
*/
|
|
1256
|
+
async function destroyK8sTier({
|
|
1257
|
+
plan,
|
|
1258
|
+
envConfig,
|
|
1259
|
+
projectConfig,
|
|
1260
|
+
environment,
|
|
1261
|
+
hetznerToken,
|
|
1262
|
+
cloudflareToken,
|
|
1263
|
+
results,
|
|
1264
|
+
spinner: s,
|
|
1265
|
+
cwd,
|
|
1266
|
+
}) {
|
|
1267
|
+
const { stackEnvs, clusterNames, hasPulumiStack, ownedIps } = plan;
|
|
1268
|
+
const isHAK8s = plan.tier === 'k8s-ha';
|
|
1511
1269
|
|
|
1512
1270
|
// Volume IDs collected from ALL cluster servers (master, supabase, workers).
|
|
1513
|
-
// Hetzner CSI names volumes pvc-<uuid> — not the cluster name — so we must
|
|
1514
|
-
// before the servers are deleted rather than
|
|
1271
|
+
// Hetzner CSI names volumes pvc-<uuid> — not the cluster name — so we must
|
|
1272
|
+
// collect IDs before the servers are deleted rather than matching by
|
|
1273
|
+
// name/label afterwards.
|
|
1515
1274
|
const allClusterVolumeIds = [];
|
|
1516
|
-
// Datacenter location names for all servers in the cluster (e.g. "fsn1"
|
|
1517
|
-
// Used to find pvc-* volumes created by CSI that were detached before destroy
|
|
1275
|
+
// Datacenter location names for all servers in the cluster (e.g. "fsn1").
|
|
1276
|
+
// Used to find pvc-* volumes created by CSI that were detached before destroy.
|
|
1518
1277
|
const clusterLocations = new Set();
|
|
1519
1278
|
|
|
1520
|
-
// Owned IPs gate every DNS delete — only records pointing at this env's
|
|
1521
|
-
// servers are removed. See deleteDNSRecord doc in lib/cloudflare.js.
|
|
1522
|
-
const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
|
|
1523
|
-
|
|
1524
1279
|
// 1. Delete Cloudflare resources first (while servers still exist for reference)
|
|
1525
1280
|
if (cloudflareToken && envConfig.dns?.cloudflareZoneId) {
|
|
1526
1281
|
const zoneId = envConfig.dns.cloudflareZoneId;
|
|
@@ -1624,18 +1379,11 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
|
1624
1379
|
// Pulumi owns the servers, firewalls, SSH keys, and networks — `pulumi
|
|
1625
1380
|
// destroy` tears them all down in one pass.
|
|
1626
1381
|
|
|
1627
|
-
//
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
]
|
|
1633
|
-
: [`${projectConfig.projectName}-${envName}`];
|
|
1634
|
-
|
|
1635
|
-
// Pre-scan: collect volume IDs from ALL servers in the cluster network(s) before deletion.
|
|
1636
|
-
// Hetzner CSI driver names volumes with PVC UUIDs (e.g. pvc-xxxxxxxx), not the cluster
|
|
1637
|
-
// name, so name/label matching in hetznerCleanupOrphanedVolumes is unreliable. By
|
|
1638
|
-
// collecting the IDs now we can delete them reliably after Pulumi tears the servers down.
|
|
1382
|
+
// Pre-scan: collect volume IDs from ALL servers in the cluster network(s)
|
|
1383
|
+
// before deletion. Hetzner CSI driver names volumes with PVC UUIDs (e.g.
|
|
1384
|
+
// pvc-xxxxxxxx), not the cluster name, so name/label matching in
|
|
1385
|
+
// hetznerCleanupOrphanedVolumes is unreliable. By collecting the IDs now we
|
|
1386
|
+
// can delete them reliably after Pulumi tears the servers down.
|
|
1639
1387
|
s.start('Scanning cluster servers for attached volumes');
|
|
1640
1388
|
try {
|
|
1641
1389
|
const networks = await hetznerListNetworks(hetznerToken);
|
|
@@ -1667,8 +1415,8 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
|
1667
1415
|
}
|
|
1668
1416
|
|
|
1669
1417
|
// Clean up CCM-created load balancers before Pulumi destroys the network.
|
|
1670
|
-
// The cluster network still exists at this point, so matchesNetwork is
|
|
1671
|
-
// Once the network is gone, matchesNetwork always returns false.
|
|
1418
|
+
// The cluster network still exists at this point, so matchesNetwork is
|
|
1419
|
+
// reliable. Once the network is gone, matchesNetwork always returns false.
|
|
1672
1420
|
// HA: fan out across primary + standby — clusters are isolated.
|
|
1673
1421
|
if (clusterNames.length > 0) {
|
|
1674
1422
|
console.log(`Cleaning up Hetzner load balancers across ${clusterNames.length} cluster(s)...`);
|
|
@@ -1704,10 +1452,10 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
|
1704
1452
|
// HA: release CSI volumes on primary + standby in parallel — each
|
|
1705
1453
|
// cluster's controller is independent.
|
|
1706
1454
|
console.log(
|
|
1707
|
-
`Releasing CSI volumes via cluster finalizers across ${
|
|
1455
|
+
`Releasing CSI volumes via cluster finalizers across ${stackEnvs.length} cluster(s)...`,
|
|
1708
1456
|
);
|
|
1709
1457
|
await Promise.allSettled(
|
|
1710
|
-
|
|
1458
|
+
stackEnvs.map(async (stackEnv) => {
|
|
1711
1459
|
const kubeconfigPath = join(cwd, '.vibecarbon', `kubeconfig-${stackEnv}`);
|
|
1712
1460
|
const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
|
|
1713
1461
|
try {
|
|
@@ -1753,11 +1501,9 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
|
1753
1501
|
// For HA, sweep both primary and standby cluster networks.
|
|
1754
1502
|
// HA: sweep CA-spawned workers on primary + standby in parallel — each
|
|
1755
1503
|
// cluster's CA tags its workers with a distinct cluster name.
|
|
1756
|
-
console.log(
|
|
1757
|
-
`Sweeping cluster-autoscaler workers across ${pulumiStackEnvs.length} cluster(s)...`,
|
|
1758
|
-
);
|
|
1504
|
+
console.log(`Sweeping cluster-autoscaler workers across ${stackEnvs.length} cluster(s)...`);
|
|
1759
1505
|
await Promise.allSettled(
|
|
1760
|
-
|
|
1506
|
+
stackEnvs.map(async (stackEnv) => {
|
|
1761
1507
|
const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
|
|
1762
1508
|
const caClusterName = `${projectConfig.projectName}-${stackEnv}`;
|
|
1763
1509
|
try {
|
|
@@ -1787,10 +1533,10 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
|
1787
1533
|
// so there's no shared state to race on. Saves ~200s on k8s-ha critical
|
|
1788
1534
|
// path (Pulumi destroy is ~100s/stack and was strictly sequential).
|
|
1789
1535
|
console.log(
|
|
1790
|
-
`Destroying infrastructure via Pulumi across ${
|
|
1536
|
+
`Destroying infrastructure via Pulumi across ${stackEnvs.length} stack(s) — this may take a few minutes...`,
|
|
1791
1537
|
);
|
|
1792
1538
|
await Promise.allSettled(
|
|
1793
|
-
|
|
1539
|
+
stackEnvs.map(async (stackEnv) => {
|
|
1794
1540
|
const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
|
|
1795
1541
|
try {
|
|
1796
1542
|
await perfAsync(`destroy.pulumi.${stackEnv}`, async () =>
|
|
@@ -1858,7 +1604,7 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
|
1858
1604
|
// (RCA from k8s-ha 2026-04-30: post-destroy sweep flagged the same
|
|
1859
1605
|
// key as REGRESSION every run).
|
|
1860
1606
|
if (isHAK8s && hetznerToken) {
|
|
1861
|
-
const haSshKeyName = `${projectConfig.projectName}-${
|
|
1607
|
+
const haSshKeyName = `${projectConfig.projectName}-${environment}-ha-key`;
|
|
1862
1608
|
s.start(`Deleting shared HA SSH key: ${haSshKeyName}`);
|
|
1863
1609
|
try {
|
|
1864
1610
|
const deleted = await hetznerDeleteSSHKey(hetznerToken, haSshKeyName);
|
|
@@ -1870,190 +1616,448 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
|
1870
1616
|
}
|
|
1871
1617
|
}
|
|
1872
1618
|
|
|
1873
|
-
|
|
1619
|
+
// Clean up Kubernetes-managed resources (created by CCM/CSI at runtime,
|
|
1620
|
+
// not by the Pulumi program). These aren't in any stack's resource graph.
|
|
1621
|
+
s.start('Cleaning up orphaned volumes');
|
|
1622
|
+
try {
|
|
1623
|
+
const serverNames = (envConfig.servers || []).map((sv) => sv.name);
|
|
1624
|
+
const knownVolumeIds = [...new Set(allClusterVolumeIds)];
|
|
1625
|
+
const deletedVolumes = await hetznerCleanupOrphanedVolumes(
|
|
1626
|
+
hetznerToken,
|
|
1627
|
+
projectConfig.projectName,
|
|
1628
|
+
environment,
|
|
1629
|
+
serverNames,
|
|
1630
|
+
knownVolumeIds,
|
|
1631
|
+
[...clusterLocations],
|
|
1632
|
+
);
|
|
1633
|
+
results.volumes = deletedVolumes;
|
|
1634
|
+
s.stop(
|
|
1635
|
+
deletedVolumes.length > 0
|
|
1636
|
+
? `Deleted ${deletedVolumes.length} orphaned volume(s)`
|
|
1637
|
+
: 'No orphaned volumes found',
|
|
1638
|
+
);
|
|
1639
|
+
} catch (error) {
|
|
1640
|
+
s.stop(`Volume cleanup failed: ${error.message}`);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Tier → strategy dispatch table. k8s and k8s-ha share one function; the plan's
|
|
1646
|
+
* stackEnvs/clusterNames already encode the primary+standby fan-out.
|
|
1647
|
+
*/
|
|
1648
|
+
const DESTROY_STRATEGIES = {
|
|
1649
|
+
compose: destroyComposeTier,
|
|
1650
|
+
'compose-ha': destroyComposeHATier,
|
|
1651
|
+
k8s: destroyK8sTier,
|
|
1652
|
+
'k8s-ha': destroyK8sTier,
|
|
1653
|
+
};
|
|
1654
|
+
|
|
1655
|
+
// ============================================================================
|
|
1656
|
+
// MAIN DESTRUCTION FLOW
|
|
1657
|
+
// ============================================================================
|
|
1658
|
+
|
|
1659
|
+
async function main() {
|
|
1660
|
+
const { values, positional, errors } = parseFlags(process.argv.slice(2), SPEC);
|
|
1661
|
+
|
|
1662
|
+
if (errors.length > 0) {
|
|
1663
|
+
for (const e of errors) {
|
|
1664
|
+
process.stderr.write(`${c.error('✗')} ${e}\n`);
|
|
1665
|
+
}
|
|
1666
|
+
process.stderr.write(`Run ${c.info('vibecarbon destroy -h')} for usage.\n`);
|
|
1667
|
+
process.exit(1);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
if (values.h) {
|
|
1671
|
+
process.stdout.write(renderHelp(SPEC));
|
|
1672
|
+
process.exit(0);
|
|
1673
|
+
}
|
|
1674
|
+
if (values.v) {
|
|
1675
|
+
console.log(`destroy-vibecarbon v${VERSION}`);
|
|
1676
|
+
process.exit(0);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Build the legacy `args` struct that the orchestration code below
|
|
1680
|
+
// reads (env / yes / destroyOrphans / purgeBackups). Field renames
|
|
1681
|
+
// happen at the boundary so the orchestration stays untouched —
|
|
1682
|
+
// each downstream branch is hundreds of lines and well-tested.
|
|
1683
|
+
const envSeed =
|
|
1684
|
+
/** @type {string|undefined} */ (positional.env) ||
|
|
1685
|
+
/** @type {string|null} */ (values.env) ||
|
|
1686
|
+
null;
|
|
1687
|
+
const args = {
|
|
1688
|
+
env: envSeed,
|
|
1689
|
+
yes: !!values.y,
|
|
1690
|
+
destroyOrphans: !!values.orphans,
|
|
1691
|
+
purgeBackups: !!values.purge,
|
|
1692
|
+
};
|
|
1693
|
+
|
|
1694
|
+
// Check if current working directory exists
|
|
1695
|
+
let cwd;
|
|
1696
|
+
try {
|
|
1697
|
+
cwd = process.cwd();
|
|
1698
|
+
} catch {
|
|
1699
|
+
console.error(`\n${c.error('Error:')} Current working directory does not exist.`);
|
|
1700
|
+
console.error(
|
|
1701
|
+
`This can happen if the project directory was deleted while this command was running.`,
|
|
1702
|
+
);
|
|
1703
|
+
console.error(`\nPlease navigate to a valid directory and try again.`);
|
|
1704
|
+
process.exit(1);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Project guard runs before banner so an accidental `vibecarbon
|
|
1708
|
+
// destroy` from a parent directory emits the canonical message.
|
|
1709
|
+
assertInProjectDir(cwd);
|
|
1710
|
+
|
|
1711
|
+
console.clear();
|
|
1712
|
+
printBanner();
|
|
1713
|
+
p.intro(`${c.bold('vibecarbon destroy')} ${c.dim(`v${VERSION}`)}`);
|
|
1714
|
+
|
|
1715
|
+
const projectConfig = loadProjectConfig(cwd);
|
|
1716
|
+
|
|
1717
|
+
// TTY guard: if the operator seeded an env, no env prompt fires;
|
|
1718
|
+
// otherwise we'd open a picker that hangs off-TTY.
|
|
1719
|
+
const envCount = Object.keys(projectConfig?.environments || {}).length;
|
|
1720
|
+
requireTTYOrFlags({
|
|
1721
|
+
requirements: [
|
|
1722
|
+
{
|
|
1723
|
+
flag: 'env',
|
|
1724
|
+
description: 'name an environment to destroy',
|
|
1725
|
+
satisfied: !!envSeed || envCount <= 1,
|
|
1726
|
+
},
|
|
1727
|
+
],
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
if (!projectConfig.environments || Object.keys(projectConfig.environments).length === 0) {
|
|
1731
|
+
// Check for orphan Pulumi stacks (deployment interrupted before config save)
|
|
1732
|
+
const orphanScan = p.spinner();
|
|
1733
|
+
orphanScan.start('Scanning for orphan Pulumi stacks...');
|
|
1734
|
+
const orphans = await findOrphanPulumiStacks(projectConfig);
|
|
1735
|
+
orphanScan.stop(
|
|
1736
|
+
orphans.length > 0 ? `Found ${orphans.length} orphan stack(s)` : 'No orphan stacks found',
|
|
1737
|
+
);
|
|
1738
|
+
|
|
1739
|
+
if (orphans.length > 0) {
|
|
1740
|
+
p.log.warn('No environments found in .vibecarbon.json');
|
|
1741
|
+
p.log.info(
|
|
1742
|
+
`Found ${orphans.length} orphan Pulumi stack(s) — likely from interrupted deployments:`,
|
|
1743
|
+
);
|
|
1744
|
+
for (const orphan of orphans) {
|
|
1745
|
+
p.log.info(` • ${c.bold(orphan.name)}`);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// Auto-destroying orphans is dangerous: when projectConfig.s3Config is
|
|
1749
|
+
// null (e.g., a typo in the saved config), listStacks falls back to
|
|
1750
|
+
// the local file:// backend at ~/.vibecarbon/pulumi-state/, which is
|
|
1751
|
+
// GLOBAL across projects/scenarios. A scenario's --yes destroy then
|
|
1752
|
+
// nukes other scenarios' stacks (observed 2026-04-26 batch run #5:
|
|
1753
|
+
// compose's final-destroy destroyed compose-ha's e2-primary/e2-standby
|
|
1754
|
+
// mid-deploy because compose's empty config let the orphan path fire).
|
|
1755
|
+
//
|
|
1756
|
+
// The auto-destroy used to honor --yes, but cross-scenario blast is
|
|
1757
|
+
// worse than leaving local state around. Require an explicit flag now.
|
|
1758
|
+
// For interactive use, prompt as before.
|
|
1759
|
+
p.log.info('');
|
|
1760
|
+
if (!args.destroyOrphans) {
|
|
1761
|
+
p.log.warn('Skipping orphan cleanup. Re-run with -orphans to remove these stacks.');
|
|
1762
|
+
p.outro('Done (orphans not destroyed)');
|
|
1763
|
+
process.exit(0);
|
|
1764
|
+
}
|
|
1765
|
+
const shouldDestroy = args.yes
|
|
1766
|
+
? true
|
|
1767
|
+
: await p.confirm({
|
|
1768
|
+
message: `Destroy these ${orphans.length} orphan stack(s)?`,
|
|
1769
|
+
initialValue: false, // Default to NO for safety
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
if (p.isCancel(shouldDestroy) || !shouldDestroy) {
|
|
1773
|
+
p.cancel('Operation cancelled.');
|
|
1774
|
+
process.exit(0);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// Load the HCLOUD token + S3 backend config the same way the
|
|
1778
|
+
// main destroy path does. Pulumi's hcloud provider needs a valid
|
|
1779
|
+
// token to delete resources; an empty token surfaces as
|
|
1780
|
+
// "Missing Hetzner Cloud API token" mid-destroy. The s3Config
|
|
1781
|
+
// must match the one findOrphanPulumiStacks used to discover
|
|
1782
|
+
// these stacks, otherwise resolveBackendUrl picks a different
|
|
1783
|
+
// backend and the destroy can't find them.
|
|
1784
|
+
const orphanCreds = loadCredentials();
|
|
1785
|
+
const orphanApiToken =
|
|
1786
|
+
process.env.HETZNER_API_TOKEN ||
|
|
1787
|
+
orphanCreds?.hetzner?.apiToken ||
|
|
1788
|
+
orphanCreds?.hetznerApiToken ||
|
|
1789
|
+
'';
|
|
1790
|
+
const orphanS3Config =
|
|
1791
|
+
projectConfig?.s3Config && orphanCreds?.s3
|
|
1792
|
+
? {
|
|
1793
|
+
...projectConfig.s3Config,
|
|
1794
|
+
accessKey: orphanCreds.s3.accessKey,
|
|
1795
|
+
secretKey: orphanCreds.s3.secretKey,
|
|
1796
|
+
}
|
|
1797
|
+
: null;
|
|
1798
|
+
|
|
1799
|
+
if (!orphanApiToken) {
|
|
1800
|
+
p.log.error(
|
|
1801
|
+
'No Hetzner API token found in HETZNER_API_TOKEN env var or ~/.vibecarbon/credentials.json — cannot destroy orphan stacks.',
|
|
1802
|
+
);
|
|
1803
|
+
process.exit(1);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const orphanSpinner = p.spinner();
|
|
1807
|
+
for (const orphan of orphans) {
|
|
1808
|
+
orphanSpinner.start(`Destroying orphan stack ${c.bold(orphan.name)} (pulumi destroy)...`);
|
|
1809
|
+
try {
|
|
1810
|
+
await destroyOrphanPulumiStack(orphan, {
|
|
1811
|
+
apiToken: orphanApiToken,
|
|
1812
|
+
s3Config: orphanS3Config,
|
|
1813
|
+
});
|
|
1814
|
+
orphanSpinner.stop(`Destroyed: ${orphan.name}`);
|
|
1815
|
+
} catch (error) {
|
|
1816
|
+
orphanSpinner.error(`Failed to destroy ${orphan.name}: ${error.message}`);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
p.outro('Orphan cleanup complete');
|
|
1821
|
+
process.exit(0);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
p.log.error('No environments found in .vibecarbon.json');
|
|
1825
|
+
p.log.info('Nothing to destroy.');
|
|
1826
|
+
process.exit(1);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
p.log.info(`Project: ${c.bold(projectConfig.projectName)}`);
|
|
1874
1830
|
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
for (const server of envConfig.servers || []) {
|
|
1879
|
-
s.start(`Deleting server: ${server.name} (${server.ip})`);
|
|
1880
|
-
try {
|
|
1881
|
-
const deleted = await hetznerDeleteServer(hetznerToken, server.id);
|
|
1882
|
-
if (deleted) results.servers.push(server.name);
|
|
1883
|
-
s.stop(deleted ? `Server ${server.name} deleted` : `Server ${server.name} not found`);
|
|
1884
|
-
} catch (error) {
|
|
1885
|
-
s.stop(`Failed to delete ${server.name}: ${error.message}`);
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1831
|
+
// Select environment to destroy
|
|
1832
|
+
const envNames = Object.keys(projectConfig.environments);
|
|
1833
|
+
let envName = args.env;
|
|
1888
1834
|
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1835
|
+
if (!envName) {
|
|
1836
|
+
if (envNames.length === 1) {
|
|
1837
|
+
envName = envNames[0];
|
|
1838
|
+
} else {
|
|
1839
|
+
envName = await p.select({
|
|
1840
|
+
message: 'Which environment do you want to destroy?',
|
|
1841
|
+
options: envNames.map((name) => ({
|
|
1842
|
+
value: name,
|
|
1843
|
+
label: name,
|
|
1844
|
+
hint: projectConfig.environments[name].servers?.map((s) => s.ip).join(', '),
|
|
1845
|
+
})),
|
|
1846
|
+
});
|
|
1900
1847
|
}
|
|
1848
|
+
}
|
|
1901
1849
|
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
// This branch only runs when Pulumi state is missing, so we have to
|
|
1907
|
-
// know the location to find the key. Fall back to the prefix-only
|
|
1908
|
-
// form for older deploys that may not have a region in envConfig.
|
|
1909
|
-
const k8sRegion = envConfig.region || envConfig.servers?.[0]?.region;
|
|
1910
|
-
const sshKeyName = k8sRegion
|
|
1911
|
-
? `${projectConfig.projectName}-${envName}-${k8sRegion}-key`
|
|
1912
|
-
: null;
|
|
1913
|
-
if (sshKeyName) {
|
|
1914
|
-
s.start(`Deleting SSH key: ${sshKeyName}`);
|
|
1915
|
-
try {
|
|
1916
|
-
const deleted = await hetznerDeleteSSHKey(hetznerToken, sshKeyName);
|
|
1917
|
-
if (deleted) results.sshKeys.push(sshKeyName);
|
|
1918
|
-
s.stop(deleted ? 'SSH key deleted' : 'SSH key not found');
|
|
1919
|
-
} catch (error) {
|
|
1920
|
-
s.stop(`Failed to delete SSH key: ${error.message}`);
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
} // end manual API cleanup
|
|
1850
|
+
if (p.isCancel(envName)) {
|
|
1851
|
+
p.cancel('Operation cancelled.');
|
|
1852
|
+
process.exit(0);
|
|
1853
|
+
}
|
|
1924
1854
|
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1855
|
+
const envConfig = projectConfig.environments[envName];
|
|
1856
|
+
if (!envConfig) {
|
|
1857
|
+
p.log.error(`Environment '${envName}' not found`);
|
|
1858
|
+
p.log.info(`Available environments: ${envNames.join(', ')}`);
|
|
1859
|
+
process.exit(1);
|
|
1860
|
+
}
|
|
1928
1861
|
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
s.
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1862
|
+
// Show what will be deleted
|
|
1863
|
+
const serverList =
|
|
1864
|
+
envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name} (${s.ip})`).join('\n') ||
|
|
1865
|
+
' None';
|
|
1866
|
+
|
|
1867
|
+
p.note(
|
|
1868
|
+
`
|
|
1869
|
+
${c.danger('This will permanently delete:')}
|
|
1870
|
+
|
|
1871
|
+
${c.bold('Servers:')}
|
|
1872
|
+
${serverList}
|
|
1873
|
+
|
|
1874
|
+
${c.bold('Firewalls:')}
|
|
1875
|
+
${envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name}-fw`).join('\n') || ' None'}
|
|
1876
|
+
|
|
1877
|
+
${c.bold('SSH Keys:')}
|
|
1878
|
+
${c.error('•')} vibecarbon-${projectConfig.projectName}-${envName}
|
|
1879
|
+
|
|
1880
|
+
${
|
|
1881
|
+
envConfig.dns?.provider === 'cloudflare'
|
|
1882
|
+
? `${c.bold('Cloudflare Resources:')}
|
|
1883
|
+
${c.error('•')} DNS record: ${envConfig.domain}
|
|
1884
|
+
${c.error('•')} Health checks
|
|
1885
|
+
${envConfig.ha ? `${c.error('•')} Wildcard DNS record` : ''}
|
|
1886
|
+
`
|
|
1887
|
+
: ''
|
|
1888
|
+
}
|
|
1889
|
+
${
|
|
1890
|
+
envConfig.s3?.bucket
|
|
1891
|
+
? `${c.bold('S3 Storage:')}
|
|
1892
|
+
${c.error('•')} Bucket: ${envConfig.s3.bucket} (all objects will be deleted)
|
|
1893
|
+
`
|
|
1894
|
+
: ''
|
|
1895
|
+
}${
|
|
1896
|
+
envConfig.backupS3?.bucket
|
|
1897
|
+
? `${c.bold('S3 Backups:')}
|
|
1898
|
+
${args.purgeBackups ? `${c.error('•')} Bucket: ${envConfig.backupS3.bucket} (WILL BE DELETED — --purge-backups)` : `${c.success('•')} Bucket: ${envConfig.backupS3.bucket} (PRESERVED for recovery)`}
|
|
1899
|
+
`
|
|
1900
|
+
: ''
|
|
1901
|
+
}
|
|
1902
|
+
${c.bold('GitHub:')}
|
|
1903
|
+
${c.error('•')} Environment: ${envName}
|
|
1904
|
+
|
|
1905
|
+
${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
1906
|
+
`,
|
|
1907
|
+
`Destroying: ${envName}`,
|
|
1908
|
+
);
|
|
1909
|
+
|
|
1910
|
+
const needsProdConfirm = requiresProdTypeToConfirm(envName);
|
|
1911
|
+
|
|
1912
|
+
// First prompt: only when not --yes.
|
|
1913
|
+
if (!args.yes) {
|
|
1914
|
+
const confirmed = await p.confirm({
|
|
1915
|
+
message: `Are you sure you want to destroy the ${c.bold(envName)} environment?`,
|
|
1916
|
+
initialValue: false,
|
|
1917
|
+
});
|
|
1918
|
+
if (!confirmed || p.isCancel(confirmed)) {
|
|
1919
|
+
p.cancel('Operation cancelled.');
|
|
1920
|
+
process.exit(0);
|
|
1921
|
+
}
|
|
1949
1922
|
}
|
|
1950
1923
|
|
|
1951
|
-
//
|
|
1952
|
-
//
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
const
|
|
1961
|
-
|
|
1962
|
-
|
|
1924
|
+
// Type-to-confirm: runs whenever NOT --yes, OR the env is prod/production
|
|
1925
|
+
// (even with --yes). Protects against `--env prod --yes` typos in CI/scripts.
|
|
1926
|
+
if (!args.yes || needsProdConfirm) {
|
|
1927
|
+
const confirmSlug = `${projectConfig.projectName}-${envName}`;
|
|
1928
|
+
if (args.yes && needsProdConfirm) {
|
|
1929
|
+
p.log.warn(
|
|
1930
|
+
`Destroying a production environment still requires type-to-confirm, even with --yes.`,
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
const doubleConfirm = await p.text({
|
|
1934
|
+
message: `Type "${confirmSlug}" to confirm:`,
|
|
1935
|
+
validate: (v) => (v !== confirmSlug ? `Please type "${confirmSlug}" to confirm` : undefined),
|
|
1936
|
+
});
|
|
1937
|
+
if (p.isCancel(doubleConfirm)) {
|
|
1938
|
+
p.cancel('Operation cancelled.');
|
|
1939
|
+
process.exit(0);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// Offer to create a backup before destroying
|
|
1944
|
+
const serverIp = envConfig.servers?.[0]?.ip;
|
|
1945
|
+
const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${envName}`);
|
|
1946
|
+
if (serverIp && existsSync(sshKeyPath) && !args.yes) {
|
|
1947
|
+
const offerBackup = await p.confirm({
|
|
1948
|
+
message: 'Create a backup before destroying? (recommended)',
|
|
1949
|
+
initialValue: true,
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
if (!p.isCancel(offerBackup) && offerBackup) {
|
|
1953
|
+
const backupSpinner = p.spinner();
|
|
1954
|
+
backupSpinner.start('Creating backup...');
|
|
1963
1955
|
try {
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
: 'S3 bucket deleted',
|
|
1971
|
-
);
|
|
1972
|
-
}
|
|
1973
|
-
} catch (bucketError) {
|
|
1974
|
-
// NoSuchBucket means it was already deleted or never created — not an error
|
|
1975
|
-
if (bucketError.name === 'NoSuchBucket' || bucketError.$metadata?.httpStatusCode === 404) {
|
|
1976
|
-
s.stop('S3 bucket not found (already deleted or never created)');
|
|
1977
|
-
} else {
|
|
1978
|
-
throw bucketError;
|
|
1956
|
+
if (isComposeTier(resolveTier(envConfig))) {
|
|
1957
|
+
const { backupCompose } = await import('./lib/deploy/compose/index.js');
|
|
1958
|
+
await backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
|
|
1959
|
+
retain: envConfig.backup?.retentionDays,
|
|
1960
|
+
});
|
|
1961
|
+
backupSpinner.stop('wal-g base backup pushed to S3');
|
|
1979
1962
|
}
|
|
1963
|
+
} catch (backupError) {
|
|
1964
|
+
backupSpinner.stop(`Backup failed: ${backupError.message}`);
|
|
1965
|
+
p.log.warn('Continuing with destroy...');
|
|
1980
1966
|
}
|
|
1981
|
-
} else {
|
|
1982
|
-
s.stop('S3 bucket skipped (no credentials available)');
|
|
1983
|
-
results.issues.push({
|
|
1984
|
-
step: 'S3 bucket',
|
|
1985
|
-
detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
|
|
1986
|
-
hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
|
|
1987
|
-
});
|
|
1988
1967
|
}
|
|
1989
|
-
} catch (error) {
|
|
1990
|
-
s.stop(`S3 bucket deletion failed: ${error.message}`);
|
|
1991
|
-
results.issues.push({
|
|
1992
|
-
step: 'S3 bucket',
|
|
1993
|
-
detail: `${s3BucketName} (${s3Region}) not deleted: ${error.message}`,
|
|
1994
|
-
hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
|
|
1995
|
-
});
|
|
1996
1968
|
}
|
|
1997
1969
|
|
|
1998
|
-
//
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
1970
|
+
// Get API tokens (env var → credentials file → interactive prompt)
|
|
1971
|
+
const hetznerToken = await getApiToken(projectConfig.projectName);
|
|
1972
|
+
if (!hetznerToken) {
|
|
1973
|
+
p.log.error('Hetzner API token is required');
|
|
1974
|
+
process.exit(1);
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
let cloudflareToken = null;
|
|
1978
|
+
if (envConfig.dns?.provider === 'cloudflare' && envConfig.dns?.cloudflareZoneId) {
|
|
1979
|
+
// Check env var → credentials file → prompt
|
|
1980
|
+
cloudflareToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
2004
1981
|
|
|
2005
|
-
|
|
2006
|
-
|
|
1982
|
+
if (!cloudflareToken) {
|
|
1983
|
+
const creds = loadCredentials();
|
|
1984
|
+
if (creds.cloudflare?.apiToken) {
|
|
1985
|
+
cloudflareToken = creds.cloudflare.apiToken;
|
|
1986
|
+
p.log.info('✓ Using Cloudflare API token from ~/.vibecarbon/credentials.json');
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
2007
1989
|
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
1990
|
+
if (!cloudflareToken) {
|
|
1991
|
+
cloudflareToken = await p.password({
|
|
1992
|
+
message: 'Enter Cloudflare API token (for DNS cleanup)',
|
|
1993
|
+
validate: (v) => (v.length < 10 ? 'API token is required' : undefined),
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
if (p.isCancel(cloudflareToken)) {
|
|
1997
|
+
p.cancel('Operation cancelled.');
|
|
1998
|
+
process.exit(0);
|
|
1999
|
+
}
|
|
2000
|
+
} else if (process.env.CLOUDFLARE_API_TOKEN) {
|
|
2001
|
+
p.log.info('✓ Using Cloudflare API token from CLOUDFLARE_API_TOKEN environment variable');
|
|
2002
|
+
}
|
|
2017
2003
|
}
|
|
2018
2004
|
|
|
2019
|
-
//
|
|
2020
|
-
s.start('Updating project configuration');
|
|
2021
|
-
delete projectConfig.environments[envName];
|
|
2022
|
-
saveProjectConfig(projectConfig);
|
|
2023
|
-
s.stop('Configuration updated');
|
|
2024
|
-
|
|
2025
|
-
// Summary
|
|
2026
|
-
const deletedCount =
|
|
2027
|
-
results.servers.length +
|
|
2028
|
-
results.volumes.length +
|
|
2029
|
-
results.firewalls.length +
|
|
2030
|
-
results.sshKeys.length +
|
|
2031
|
-
results.dns.length +
|
|
2032
|
-
results.healthChecks.length +
|
|
2033
|
-
results.loadBalancers.length +
|
|
2034
|
-
(results.s3Bucket ? 1 : 0) +
|
|
2035
|
-
(results.github ? 1 : 0);
|
|
2005
|
+
// ========== DESTRUCTION ==========
|
|
2036
2006
|
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2007
|
+
const tracker = createTracker('destroy', { environment: envName });
|
|
2008
|
+
const s = tracker.spinner();
|
|
2009
|
+
const results = {
|
|
2010
|
+
servers: [],
|
|
2011
|
+
volumes: [],
|
|
2012
|
+
firewalls: [],
|
|
2013
|
+
sshKeys: [],
|
|
2014
|
+
dns: [],
|
|
2015
|
+
healthChecks: [],
|
|
2016
|
+
loadBalancers: [],
|
|
2017
|
+
s3Bucket: null,
|
|
2018
|
+
github: false,
|
|
2019
|
+
// Sub-step failures that didn't abort destroy but left state behind
|
|
2020
|
+
// (e.g., S3 bucket couldn't be emptied). The outro surfaces these so the
|
|
2021
|
+
// operator doesn't miss them, and the process exits non-zero so CI sees it.
|
|
2022
|
+
issues: [],
|
|
2023
|
+
};
|
|
2049
2024
|
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2025
|
+
// ========== TIER DISPATCH ==========
|
|
2026
|
+
// Pure planner derives every teardown target from persisted config; the
|
|
2027
|
+
// per-tier strategy runs the infra teardown, then a single shared tail
|
|
2028
|
+
// handles buckets/config/summary. k8s and k8s-ha share destroyK8sTier —
|
|
2029
|
+
// the plan's stackEnvs/clusterNames already encode the HA fan-out.
|
|
2030
|
+
const plan = planDestroyTargets(envConfig, projectConfig, envName);
|
|
2031
|
+
const isK8s = isK8sTier(plan.tier);
|
|
2032
|
+
|
|
2033
|
+
await DESTROY_STRATEGIES[plan.tier]({
|
|
2034
|
+
plan,
|
|
2035
|
+
envConfig,
|
|
2036
|
+
projectConfig,
|
|
2037
|
+
environment: envName,
|
|
2038
|
+
args,
|
|
2039
|
+
results,
|
|
2040
|
+
hetznerToken,
|
|
2041
|
+
cloudflareToken,
|
|
2042
|
+
cwd,
|
|
2043
|
+
spinner: s,
|
|
2044
|
+
tracker,
|
|
2045
|
+
});
|
|
2054
2046
|
|
|
2055
|
-
|
|
2056
|
-
|
|
2047
|
+
await finishDestroy({
|
|
2048
|
+
envConfig,
|
|
2049
|
+
projectConfig,
|
|
2050
|
+
environment: envName,
|
|
2051
|
+
args,
|
|
2052
|
+
results,
|
|
2053
|
+
spinner: s,
|
|
2054
|
+
tracker,
|
|
2055
|
+
cwd,
|
|
2056
|
+
// k8s-only tail steps (compose/compose-ha never did these):
|
|
2057
|
+
deleteGithubEnv: isK8s,
|
|
2058
|
+
showSummary: isK8s,
|
|
2059
|
+
defaultBucketName: isK8s,
|
|
2060
|
+
});
|
|
2057
2061
|
}
|
|
2058
2062
|
|
|
2059
2063
|
// ============================================================================
|