| 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Console from "effect/Console"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import { Command, Flag } from "effect/cli"; |
| 7 | import * as Nuke from "../../Alchemist/routes/nuke.ts"; |
| 8 | import * as CliKit from "../../Cli/CliKit/index.ts"; |
| 9 | import { formatElapsed } from "../Format.ts"; |
| 10 | import { |
| 11 | renderNukeScan, |
| 12 | renderNukeDelete, |
| 13 | reviewNuke, |
| 14 | } from "../components/view/Nuke.tsx"; |
| 15 | import { exitDeclined, UserInputError } from "./errors.ts"; |
| 16 | import { |
| 17 | configPath, |
| 18 | envFile, |
| 19 | optionalConfig, |
| 20 | profile, |
| 21 | resolveConfig, |
| 22 | yes, |
| 23 | } from "./flags.ts"; |
| 24 | import { instrumentCommand } from "./instrument.ts"; |
| 25 | |
| 26 | const includeFlag = Flag.String("include").pipe( |
| 27 | Flag.withDescription("Glob of provider IDs to include (repeatable)"), |
| 28 | Flag.atLeast(0), |
| 29 | ); |
| 30 | const excludeFlag = Flag.String("exclude").pipe( |
| 31 | Flag.withDescription("Glob of provider IDs to exclude (repeatable)"), |
| 32 | Flag.atLeast(0), |
| 33 | ); |
| 34 | const filterFlag = Flag.String("filter").pipe( |
| 35 | Flag.withDescription( |
| 36 | "JavaScript expression evaluated with resource in scope; matching resources are excluded from deletion (repeatable)", |
| 37 | ), |
| 38 | Flag.atLeast(0), |
| 39 | ); |
| 40 | const concurrencyFlag = Flag.Int("concurrency").pipe( |
| 41 | Flag.withDescription( |
| 42 | "Maximum providers processed in parallel; 0 is unbounded", |
| 43 | ), |
| 44 | Flag.withDefault(16), |
| 45 | Flag.map((value): number | "unbounded" => (value <= 0 ? "unbounded" : value)), |
| 46 | ); |
| 47 | const timeoutFlag = Flag.Int("timeout").pipe( |
| 48 | Flag.withDescription("Per-provider timeout in seconds"), |
| 49 | Flag.withDefault(120), |
| 50 | Flag.map(Duration.seconds), |
| 51 | ); |
| 52 | const independentFlag = Flag.Boolean("independent").pipe( |
| 53 | Flag.withDescription("Retry each resource independently"), |
| 54 | Flag.withDefault(false), |
| 55 | ); |
| 56 | const retriesFlag = Flag.Int("retries").pipe( |
| 57 | Flag.withDescription("Independent retries per resource"), |
| 58 | Flag.withDefault(10), |
| 59 | ); |
| 60 | const localFlag = Flag.Boolean("local").pipe( |
| 61 | Flag.withDescription("Target only locally emulated providers"), |
| 62 | Flag.withDefault(false), |
| 63 | ); |
| 64 | const dryRunFlag = Flag.Boolean("dry-run").pipe( |
| 65 | Flag.withDescription("Scan and show targets without deleting"), |
| 66 | Flag.withDefault(false), |
| 67 | ); |
| 68 | |
| 69 | // A broken filter must abort the nuke, not silently widen the kill list: |
| 70 | // compile and evaluation errors both fail the command before any deletion. |
| 71 | const compileFilter = (expression: string) => |
| 72 | Effect.try({ |
| 73 | try: () => |
| 74 | new Function( |
| 75 | "scope", |
| 76 | `with (scope) { return (${expression}); }`, |
| 77 | ) as (scope: { resource: Record<string, unknown> }) => unknown, |
| 78 | catch: (cause) => |
| 79 | new UserInputError({ |
| 80 | message: `--filter expression ${JSON.stringify(expression)} failed to compile: ${cause}`, |
| 81 | }), |
| 82 | }).pipe( |
| 83 | Effect.map( |
| 84 | (predicate) => (resource: Record<string, unknown>) => |
| 85 | Effect.try({ |
| 86 | try: () => Boolean(predicate({ resource })), |
| 87 | catch: (cause) => |
| 88 | new UserInputError({ |
| 89 | message: `--filter expression ${JSON.stringify(expression)} threw while evaluating a resource: ${cause}`, |
| 90 | }), |
| 91 | }), |
| 92 | ), |
| 93 | ); |
| 94 | |
| 95 | const nukeCommand = Command.make( |
| 96 | "nuke", |
| 97 | { |
| 98 | config: optionalConfig, |
| 99 | configPath, |
| 100 | envFile, |
| 101 | profile, |
| 102 | yes, |
| 103 | dryRun: dryRunFlag, |
| 104 | concurrency: concurrencyFlag, |
| 105 | timeout: timeoutFlag, |
| 106 | independent: independentFlag, |
| 107 | retries: retriesFlag, |
| 108 | include: includeFlag, |
| 109 | exclude: excludeFlag, |
| 110 | filter: filterFlag, |
| 111 | local: localFlag, |
| 112 | }, |
| 113 | (args) => |
| 114 | resolveConfig(args).pipe( |
| 115 | Effect.flatMap( |
| 116 | instrumentCommand( |
| 117 | "unsafe.nuke", |
| 118 | (args: { profile: string | undefined; main: string }) => ({ |
| 119 | "alchemy.profile": args.profile ?? "", |
| 120 | "alchemy.main": args.main, |
| 121 | }), |
| 122 | )( |
| 123 | Effect.fn(function* (args) { |
| 124 | const scan = yield* Nuke.scan({ |
| 125 | entrypoint: args.main, |
| 126 | profile: args.profile, |
| 127 | envFile: Option.getOrUndefined(args.envFile), |
| 128 | mode: args.local ? "local" : "live", |
| 129 | include: args.include, |
| 130 | exclude: args.exclude, |
| 131 | concurrency: args.concurrency, |
| 132 | providerTimeoutSeconds: Duration.toSeconds(args.timeout), |
| 133 | }).pipe(renderNukeScan()); |
| 134 | const predicates = yield* Effect.forEach( |
| 135 | args.filter, |
| 136 | compileFilter, |
| 137 | ); |
| 138 | const targets: Array<(typeof scan.resources)[number]> = []; |
| 139 | for (const resource of scan.resources) { |
| 140 | const matches = yield* Effect.forEach(predicates, (predicate) => |
| 141 | predicate({ |
| 142 | ...resource.attributes, |
| 143 | Type: resource.providerId, |
| 144 | LogicalId: resource.displayName, |
| 145 | }), |
| 146 | ); |
| 147 | if (!matches.some(Boolean)) targets.push(resource); |
| 148 | } |
| 149 | if (targets.length === 0) { |
| 150 | yield* CliKit.accessors.output.info("Nothing to delete."); |
| 151 | return; |
| 152 | } |
| 153 | const approved = yield* reviewNuke(targets, { |
| 154 | mode: scan.mode, |
| 155 | yes: args.yes, |
| 156 | dryRun: args.dryRun, |
| 157 | }); |
| 158 | if (!approved) { |
| 159 | yield* CliKit.accessors.output.info("Aborted."); |
| 160 | return yield* exitDeclined; |
| 161 | } |
| 162 | if (args.dryRun) { |
| 163 | yield* Console.log("Dry run complete: nothing was deleted."); |
| 164 | return; |
| 165 | } |
| 166 | const deleteStartedAt = yield* Clock.currentTimeMillis; |
| 167 | const result = yield* Nuke.execute({ |
| 168 | scan, |
| 169 | resources: targets, |
| 170 | strategy: args.independent |
| 171 | ? { _tag: "independent", retries: args.retries } |
| 172 | : { _tag: "coordinated" }, |
| 173 | concurrency: args.concurrency, |
| 174 | providerTimeoutSeconds: Duration.toSeconds(args.timeout), |
| 175 | }).pipe(renderNukeDelete(targets, scan.mode)); |
| 176 | const deleteElapsed = |
| 177 | (yield* Clock.currentTimeMillis) - deleteStartedAt; |
| 178 | yield* CliKit.accessors.output.success( |
| 179 | `Deleted ${result.deleted.length} resource(s) over ${result.passes} pass(es) (${formatElapsed(deleteElapsed)}).`, |
| 180 | ); |
| 181 | if (result.held.length > 0) { |
| 182 | yield* CliKit.accessors.output.warning( |
| 183 | `${result.held.length} resource(s) were held back.`, |
| 184 | ); |
| 185 | } |
| 186 | if (result.failed.length > 0) { |
| 187 | yield* CliKit.accessors.output.error( |
| 188 | `${result.failed.length} resource(s) could not be deleted.`, |
| 189 | ); |
| 190 | } |
| 191 | }), |
| 192 | ), |
| 193 | ), |
| 194 | ), |
| 195 | ).pipe( |
| 196 | Command.withDescription( |
| 197 | "Enumerate resources across the stack providers and delete them", |
| 198 | ), |
| 199 | Command.unlisted, |
| 200 | ); |
| 201 | |
| 202 | export const unsafeCommand = Command.make("unsafe", {}).pipe( |
| 203 | Command.withDescription("Dangerous, irreversible operations"), |
| 204 | Command.withSubcommands([nukeCommand]), |
| 205 | Command.unlisted, |
| 206 | ); |