| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Formatter from "effect/Formatter"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import picomatch from "picomatch"; |
| 7 | import { |
| 8 | isProviderCollectionService, |
| 9 | isProviderService, |
| 10 | providerForMode, |
| 11 | type ProviderService, |
| 12 | } from "./Provider.ts"; |
| 13 | import type { ProviderMode } from "./ProviderMode.ts"; |
| 14 | import { stronglyConnectedComponents } from "./Util/scc.ts"; |
| 15 | |
| 16 | // Account-wide teardown: enumerate everything the registered providers can see |
| 17 | // and delete it, ignoring stack state entirely. This is the engine behind |
| 18 | // `alchemy nuke` — a blunt instrument for cleaning a test account, not part of |
| 19 | // any stack's lifecycle. |
| 20 | |
| 21 | /** A provider call that failed. These are collected into results, never thrown. */ |
| 22 | export interface ProviderFailure { |
| 23 | readonly provider: string; |
| 24 | readonly operation: "list" | "delete"; |
| 25 | readonly message: string; |
| 26 | } |
| 27 | |
| 28 | /** One cloud object a provider's `list()` turned up, bound to its provider. */ |
| 29 | export interface Target { |
| 30 | readonly providerId: string; |
| 31 | readonly displayName: string; |
| 32 | readonly attributes: Record<string, unknown>; |
| 33 | readonly provider: ProviderService; |
| 34 | } |
| 35 | |
| 36 | export interface DiscoverOptions { |
| 37 | readonly mode: ProviderMode; |
| 38 | /** Provider-id globs to include. Omitted means every provider. */ |
| 39 | readonly include?: ReadonlyArray<string>; |
| 40 | /** Provider-id globs to exclude. Applied after {@link DiscoverOptions.include}. */ |
| 41 | readonly exclude?: ReadonlyArray<string>; |
| 42 | } |
| 43 | |
| 44 | export interface ListOptions extends DiscoverOptions { |
| 45 | /** The built provider context — what a stack's `providers` layer produced. */ |
| 46 | readonly context: Context.Context<never>; |
| 47 | readonly concurrency?: number | "unbounded"; |
| 48 | readonly timeoutSeconds?: number; |
| 49 | /** Called once with the selected provider count before enumeration starts. */ |
| 50 | readonly onScan?: (total: number) => Effect.Effect<void>; |
| 51 | /** Called when a provider starts resolving/listing, to identify slow scans. */ |
| 52 | readonly onProviderStarted?: (provider: string) => Effect.Effect<void>; |
| 53 | /** |
| 54 | * Called as each provider's listing settles (0 resources when it failed), |
| 55 | * for progress reporting — scans across many providers are the slowest |
| 56 | * part of a nuke and would otherwise be silent until the summary. |
| 57 | */ |
| 58 | readonly onProvider?: ( |
| 59 | provider: string, |
| 60 | resources: number, |
| 61 | error?: string, |
| 62 | ) => Effect.Effect<void>; |
| 63 | } |
| 64 | |
| 65 | export type Strategy = |
| 66 | /** Delete in dependency order, retrying a wave until it stops making progress. */ |
| 67 | | { readonly _tag: "coordinated" } |
| 68 | /** Delete everything at once, retrying each object on its own. */ |
| 69 | | { readonly _tag: "independent"; readonly retries: number }; |
| 70 | |
| 71 | export interface DestroyOptions { |
| 72 | readonly targets: ReadonlyArray<Target>; |
| 73 | readonly context: Context.Context<never>; |
| 74 | readonly strategy: Strategy; |
| 75 | readonly concurrency?: number | "unbounded"; |
| 76 | readonly timeoutSeconds?: number; |
| 77 | /** Called before each coordinated pass, or once for independent deletion. */ |
| 78 | readonly onPass?: (pass: number) => Effect.Effect<void>; |
| 79 | /** Called before each deletion attempt. */ |
| 80 | readonly onDeleting?: (resource: Target) => Effect.Effect<void>; |
| 81 | /** Called when dependency failures prevent a resource from being attempted. */ |
| 82 | readonly onHeld?: ( |
| 83 | resource: Target, |
| 84 | blockedBy: ReadonlyArray<string>, |
| 85 | ) => Effect.Effect<void>; |
| 86 | /** Called as each object is confirmed gone, for progress reporting. */ |
| 87 | readonly onDeleted?: (resource: Target) => Effect.Effect<void>; |
| 88 | /** Called as a deletion attempt fails permanently (the run keeps going). */ |
| 89 | readonly onFailed?: ( |
| 90 | resource: Target, |
| 91 | message: string, |
| 92 | ) => Effect.Effect<void>; |
| 93 | } |
| 94 | |
| 95 | export interface Result { |
| 96 | readonly requested: number; |
| 97 | readonly deleted: ReadonlyArray<Target>; |
| 98 | readonly failed: ReadonlyArray<{ |
| 99 | readonly resource: Target; |
| 100 | readonly failure: ProviderFailure; |
| 101 | }>; |
| 102 | /** Skipped because something they depend on could not be deleted first. */ |
| 103 | readonly held: ReadonlyArray<{ |
| 104 | readonly resource: Target; |
| 105 | readonly blockedBy: ReadonlyArray<string>; |
| 106 | }>; |
| 107 | readonly passes: number; |
| 108 | } |
| 109 | |
| 110 | interface DiscoveredProvider { |
| 111 | readonly id: string; |
| 112 | readonly provider: ProviderService; |
| 113 | } |
| 114 | |
| 115 | const failure = ( |
| 116 | provider: string, |
| 117 | operation: ProviderFailure["operation"], |
| 118 | cause: unknown, |
| 119 | ): ProviderFailure => ({ |
| 120 | provider, |
| 121 | operation, |
| 122 | message: typeof cause === "string" ? cause : Formatter.format(cause), |
| 123 | }); |
| 124 | |
| 125 | /** Deletions here run out-of-band, so nothing reports through an apply session. */ |
| 126 | const silent = { |
| 127 | emit: () => Effect.void, |
| 128 | done: () => Effect.void, |
| 129 | note: () => Effect.void, |
| 130 | }; |
| 131 | |
| 132 | /** |
| 133 | * Walk the built provider context for everything deletable. A provider opts |
| 134 | * out with `nuke.skip` (not ours to delete) or `nuke.singleton` (an |
| 135 | * account-level object that has to survive). |
| 136 | * |
| 137 | * Dual registrations (`ProviderLayer.dual`) build nothing until asked and |
| 138 | * keep their `nuke` metadata on the variants, so each provider is resolved |
| 139 | * to the scan mode's variant first — mode-agnostic providers resolve to |
| 140 | * themselves in a live scan and are not part of a local one. |
| 141 | */ |
| 142 | const discover = Effect.fn(function* ( |
| 143 | context: Context.Context<never>, |
| 144 | { mode, include, exclude }: DiscoverOptions, |
| 145 | ) { |
| 146 | const registered = new Map<string, ProviderService>(); |
| 147 | for (const [key, value] of context.mapUnsafe.entries()) { |
| 148 | if (isProviderCollectionService(value)) { |
| 149 | for (const [id, provider] of Object.entries(value.providers)) { |
| 150 | registered.set(id, provider); |
| 151 | } |
| 152 | } else if ( |
| 153 | typeof key === "string" && |
| 154 | key.includes(".") && |
| 155 | isProviderService(value) |
| 156 | ) { |
| 157 | registered.set(key, value); |
| 158 | } |
| 159 | } |
| 160 | const included = include?.length ? picomatch([...include]) : () => true; |
| 161 | const excluded = exclude?.length ? picomatch([...exclude]) : () => false; |
| 162 | const output: DiscoveredProvider[] = []; |
| 163 | for (const [id, registration] of registered) { |
| 164 | if (!included(id) || excluded(id)) continue; |
| 165 | if (mode === "local" && registration.modes === undefined) continue; |
| 166 | const provider = yield* providerForMode(registration, mode); |
| 167 | if (provider.nuke?.singleton || provider.nuke?.skip) continue; |
| 168 | output.push({ id, provider }); |
| 169 | } |
| 170 | return output.sort((a, b) => a.id.localeCompare(b.id)); |
| 171 | }); |
| 172 | const nameKeys = [ |
| 173 | "workerName", |
| 174 | "functionName", |
| 175 | "bucketName", |
| 176 | "tableName", |
| 177 | "queueName", |
| 178 | "repositoryName", |
| 179 | "databaseName", |
| 180 | "projectName", |
| 181 | "domainName", |
| 182 | "hostname", |
| 183 | "displayName", |
| 184 | "name", |
| 185 | ]; |
| 186 | |
| 187 | const displayName = (attributes: Record<string, unknown>) => |
| 188 | nameKeys |
| 189 | .map((key) => attributes[key]) |
| 190 | .find( |
| 191 | (value): value is string => typeof value === "string" && value.length > 0, |
| 192 | ) ?? |
| 193 | Object.values(attributes).find( |
| 194 | (value): value is string => typeof value === "string" && value.length > 0, |
| 195 | ) ?? |
| 196 | "unknown"; |
| 197 | |
| 198 | const groupBy = <T>(items: ReadonlyArray<T>, key: (item: T) => string) => { |
| 199 | const groups = new Map<string, T[]>(); |
| 200 | for (const item of items) { |
| 201 | const id = key(item); |
| 202 | groups.set(id, [...(groups.get(id) ?? []), item]); |
| 203 | } |
| 204 | return groups; |
| 205 | }; |
| 206 | |
| 207 | const addEdge = (edges: Map<string, Set<string>>, from: string, to: string) => { |
| 208 | const values = edges.get(from) ?? new Set<string>(); |
| 209 | values.add(to); |
| 210 | edges.set(from, values); |
| 211 | }; |
| 212 | |
| 213 | /** Ask every selected provider what exists. Provider failures are collected, not thrown. */ |
| 214 | export const list = ( |
| 215 | options: ListOptions, |
| 216 | ): Effect.Effect<{ |
| 217 | readonly resources: ReadonlyArray<Target>; |
| 218 | readonly failures: ReadonlyArray<ProviderFailure>; |
| 219 | }> => |
| 220 | Effect.gen(function* () { |
| 221 | const failures: ProviderFailure[] = []; |
| 222 | const resources: Target[] = []; |
| 223 | const providers = yield* discover(options.context, options).pipe( |
| 224 | Effect.provide(options.context), |
| 225 | ); |
| 226 | yield* options.onScan?.(providers.length) ?? Effect.void; |
| 227 | yield* Effect.forEach( |
| 228 | providers, |
| 229 | ({ id, provider }) => |
| 230 | Effect.gen(function* () { |
| 231 | yield* options.onProviderStarted?.(id) ?? Effect.void; |
| 232 | const result = yield* Effect.result( |
| 233 | Effect.gen(function* () { |
| 234 | const listed = yield* provider |
| 235 | .list() |
| 236 | .pipe( |
| 237 | Effect.timeout( |
| 238 | Duration.seconds(options.timeoutSeconds ?? 120), |
| 239 | ), |
| 240 | ); |
| 241 | return { provider, listed }; |
| 242 | }).pipe(Effect.provide(options.context)), |
| 243 | ); |
| 244 | if (result._tag === "Failure") { |
| 245 | const error = failure(id, "list", result.failure); |
| 246 | failures.push(error); |
| 247 | yield* options.onProvider?.(id, 0, error.message) ?? Effect.void; |
| 248 | return; |
| 249 | } |
| 250 | for (const raw of result.success.listed) { |
| 251 | const attributes = (raw ?? {}) as Record<string, unknown>; |
| 252 | resources.push({ |
| 253 | providerId: id, |
| 254 | displayName: displayName(attributes), |
| 255 | attributes, |
| 256 | provider: result.success.provider, |
| 257 | }); |
| 258 | } |
| 259 | yield* ( |
| 260 | options.onProvider?.(id, result.success.listed.length) ?? |
| 261 | Effect.void |
| 262 | ); |
| 263 | }), |
| 264 | { concurrency: options.concurrency ?? 16, discard: true }, |
| 265 | ); |
| 266 | return { resources, failures }; |
| 267 | }); |
| 268 | |
| 269 | /** Delete the given targets under the chosen {@link Strategy}. */ |
| 270 | export const destroy = ({ |
| 271 | targets, |
| 272 | context, |
| 273 | strategy, |
| 274 | concurrency = 16, |
| 275 | timeoutSeconds = 120, |
| 276 | onDeleted = () => Effect.void, |
| 277 | onFailed = () => Effect.void, |
| 278 | onPass = () => Effect.void, |
| 279 | onDeleting = () => Effect.void, |
| 280 | onHeld = () => Effect.void, |
| 281 | }: DestroyOptions): Effect.Effect<Result> => |
| 282 | Effect.gen(function* () { |
| 283 | const deleted: Target[] = []; |
| 284 | const failed: Array<Result["failed"][number]> = []; |
| 285 | const held: Array<Result["held"][number]> = []; |
| 286 | let passes = 0; |
| 287 | // Account-wide scans have no state rows: delete must derive identity from |
| 288 | // `output` alone — `olds` just mirrors it and `instanceId` is unavailable. |
| 289 | const attempt = (resource: Target) => |
| 290 | Effect.gen(function* () { |
| 291 | yield* onDeleting(resource); |
| 292 | return yield* resource.provider |
| 293 | .delete({ |
| 294 | id: resource.displayName, |
| 295 | fqn: resource.displayName, |
| 296 | instanceId: "", |
| 297 | olds: resource.attributes as never, |
| 298 | output: resource.attributes as never, |
| 299 | session: silent, |
| 300 | bindings: [], |
| 301 | force: true, |
| 302 | }) |
| 303 | .pipe( |
| 304 | Effect.timeout(Duration.seconds(timeoutSeconds)), |
| 305 | Effect.provide(context), |
| 306 | ); |
| 307 | }); |
| 308 | |
| 309 | if (strategy._tag === "independent") { |
| 310 | passes = 1; |
| 311 | yield* onPass(passes); |
| 312 | yield* Effect.forEach( |
| 313 | targets, |
| 314 | (resource) => |
| 315 | Effect.result( |
| 316 | attempt(resource).pipe( |
| 317 | Effect.retry({ |
| 318 | schedule: Schedule.min([ |
| 319 | Schedule.exponential("1 second"), |
| 320 | Schedule.spaced("15 seconds"), |
| 321 | ]), |
| 322 | times: strategy.retries, |
| 323 | }), |
| 324 | ), |
| 325 | ).pipe( |
| 326 | Effect.tap((result) => { |
| 327 | if (result._tag === "Success") { |
| 328 | deleted.push(resource); |
| 329 | return onDeleted(resource); |
| 330 | } |
| 331 | const why = failure( |
| 332 | resource.providerId, |
| 333 | "delete", |
| 334 | result.failure, |
| 335 | ); |
| 336 | failed.push({ resource, failure: why }); |
| 337 | return onFailed(resource, why.message); |
| 338 | }), |
| 339 | ), |
| 340 | { concurrency: concurrency, discard: true }, |
| 341 | ); |
| 342 | } else { |
| 343 | const typeIds = [...new Set(targets.map(({ providerId }) => providerId))]; |
| 344 | const providerOf = new Map( |
| 345 | targets.map(({ providerId, provider }) => [providerId, provider]), |
| 346 | ); |
| 347 | const successors = new Map<string, Set<string>>(); |
| 348 | const predecessors = new Map<string, Set<string>>(); |
| 349 | for (const id of typeIds) { |
| 350 | const globs = providerOf.get(id)?.nuke?.dependsOn; |
| 351 | if (!globs?.length) continue; |
| 352 | const matches = picomatch([...globs]); |
| 353 | for (const other of typeIds) { |
| 354 | if (other === id || !matches(other)) continue; |
| 355 | addEdge(successors, id, other); |
| 356 | addEdge(predecessors, other, id); |
| 357 | } |
| 358 | } |
| 359 | const grouped = stronglyConnectedComponents( |
| 360 | typeIds, |
| 361 | (id) => successors.get(id) ?? [], |
| 362 | ); |
| 363 | const componentOf = new Map<string, number>(); |
| 364 | grouped.forEach((component, index) => |
| 365 | component.forEach((id) => componentOf.set(id, index)), |
| 366 | ); |
| 367 | const layerOf = grouped.map(() => 0); |
| 368 | for (let index = grouped.length - 1; index >= 0; index--) { |
| 369 | for (const id of grouped[index]!) { |
| 370 | for (const predecessor of predecessors.get(id) ?? []) { |
| 371 | const predecessorComponent = componentOf.get(predecessor)!; |
| 372 | if (predecessorComponent !== index) { |
| 373 | layerOf[index] = Math.max( |
| 374 | layerOf[index]!, |
| 375 | layerOf[predecessorComponent]! + 1, |
| 376 | ); |
| 377 | } |
| 378 | } |
| 379 | } |
| 380 | } |
| 381 | const waves: string[][] = []; |
| 382 | grouped.forEach((component, index) => |
| 383 | (waves[layerOf[index]!] ??= []).push(...component), |
| 384 | ); |
| 385 | const byType = groupBy(targets, ({ providerId }) => providerId); |
| 386 | const remainingCount = new Map( |
| 387 | [...byType].map(([id, resources]) => [id, resources.length]), |
| 388 | ); |
| 389 | for (const wave of waves) { |
| 390 | let runnable: Target[] = []; |
| 391 | const heldTypes = new Set<string>(); |
| 392 | for (const typeId of wave) { |
| 393 | const resources = byType.get(typeId) ?? []; |
| 394 | const blockers = [...(predecessors.get(typeId) ?? [])].filter( |
| 395 | (predecessor) => |
| 396 | componentOf.get(predecessor) !== componentOf.get(typeId) && |
| 397 | (remainingCount.get(predecessor) ?? 0) > 0, |
| 398 | ); |
| 399 | if (blockers.length > 0) { |
| 400 | heldTypes.add(typeId); |
| 401 | yield* Effect.forEach(resources, (resource) => |
| 402 | onHeld(resource, blockers), |
| 403 | ); |
| 404 | held.push( |
| 405 | ...resources.map((resource) => ({ |
| 406 | resource, |
| 407 | blockedBy: blockers, |
| 408 | })), |
| 409 | ); |
| 410 | } else runnable = [...runnable, ...resources]; |
| 411 | } |
| 412 | let remaining = runnable; |
| 413 | while (remaining.length > 0) { |
| 414 | passes += 1; |
| 415 | yield* onPass(passes); |
| 416 | const results = yield* Effect.forEach( |
| 417 | remaining, |
| 418 | (resource) => |
| 419 | Effect.gen(function* () { |
| 420 | const result = yield* Effect.result(attempt(resource)); |
| 421 | if (result._tag === "Success") { |
| 422 | deleted.push(resource); |
| 423 | yield* onDeleted(resource); |
| 424 | } |
| 425 | return { resource, result }; |
| 426 | }), |
| 427 | { concurrency: concurrency }, |
| 428 | ); |
| 429 | const next: Target[] = []; |
| 430 | for (const { resource, result } of results) { |
| 431 | if (result._tag === "Failure") next.push(resource); |
| 432 | } |
| 433 | if (next.length === remaining.length) { |
| 434 | for (const { resource, result } of results) { |
| 435 | if (result._tag !== "Failure") continue; |
| 436 | const why = failure( |
| 437 | resource.providerId, |
| 438 | "delete", |
| 439 | result.failure, |
| 440 | ); |
| 441 | failed.push({ resource, failure: why }); |
| 442 | yield* onFailed(resource, why.message); |
| 443 | } |
| 444 | break; |
| 445 | } |
| 446 | remaining = next; |
| 447 | } |
| 448 | const failuresByType = groupBy( |
| 449 | failed.map(({ resource }) => resource), |
| 450 | ({ providerId }) => providerId, |
| 451 | ); |
| 452 | for (const typeId of wave) { |
| 453 | // Held types were never attempted: keep their full count so waves |
| 454 | // gated on them stay blocked instead of seeing a spurious zero. |
| 455 | if (heldTypes.has(typeId)) continue; |
| 456 | remainingCount.set(typeId, failuresByType.get(typeId)?.length ?? 0); |
| 457 | } |
| 458 | } |
| 459 | } |
| 460 | return { |
| 461 | requested: targets.length, |
| 462 | deleted, |
| 463 | failed, |
| 464 | held, |
| 465 | passes, |
| 466 | } satisfies Result; |
| 467 | }); |