also looking at this
feat(fleet): refuse to destroy a server still holding a moved site's rollback
#182
4 files
+284
-2
| @@ -1,9 +1,65 @@ | ||
| 1 | 1 | import type { CLI } from '@stacksjs/clapp' |
| 2 | import { resolveCloudProvider } from '@ts-cloud/core' | |
| 2 | import type { CloudDriver, EnvironmentType } from '@ts-cloud/core' | |
| 3 | import { resolveCloudProvider, resolveProjectStackName } from '@ts-cloud/core' | |
| 3 | 4 | import * as cli from '../../src/utils/cli' |
| 4 | 5 | import { createCloudDriver } from '../../src/drivers' |
| 6 | import { buildDrainedSiteScanScript, formatDrainedSiteRefusal, parseDrainedSites } from '../../src/operations/drained-sites' | |
| 5 | 7 | import { loadValidatedConfig } from './shared' |
| 6 | 8 | |
| 9 | /** The flag that authorizes discarding a drained site's files. */ | |
| 10 | const DISCARD_FLAG = '--discard-drained-sites' | |
| 11 | ||
| 12 | /** | |
| 13 | * Refuse a teardown that would take a moved site's rollback with it. | |
| 14 | * | |
| 15 | * Returns true when the destroy may proceed. Deliberately permissive about its | |
| 16 | * own failure: a box that cannot be reached, or a driver that cannot run remote | |
| 17 | * commands, produces a note rather than a block — this exists to stop a specific | |
| 18 | * silent loss, not to stand between an operator and an unreachable server they | |
| 19 | * are trying to get rid of. | |
| 20 | */ | |
| 21 | async function drainedSitesAllowTeardown( | |
| 22 | driver: CloudDriver, | |
| 23 | config: Awaited<ReturnType<typeof loadValidatedConfig>>, | |
| 24 | environment: EnvironmentType, | |
| 25 | discard: boolean, | |
| 26 | ): Promise<boolean> { | |
| 27 | if (discard) return true | |
| 28 | ||
| 29 | const slug = config.project.slug | |
| 30 | let output: string | undefined | |
| 31 | try { | |
| 32 | const targets = await driver.findComputeTargets({ | |
| 33 | slug, | |
| 34 | environment, | |
| 35 | role: 'app', | |
| 36 | stackName: resolveProjectStackName(config, environment), | |
| 37 | }) | |
| 38 | if (targets.length === 0) return true | |
| 39 | ||
| 40 | const result = await driver.runRemoteDeploy({ | |
| 41 | targets, | |
| 42 | commands: buildDrainedSiteScanScript(slug), | |
| 43 | comment: `ts-cloud scan drained sites ${slug}`, | |
| 44 | tags: { Project: slug, Environment: environment, Role: 'app' }, | |
| 45 | }) | |
| 46 | if (!result.success) { | |
| 47 | cli.warn(`Could not check the server for moved-off site files: ${result.error || 'unknown error'}`) | |
| 48 | return true | |
| 49 | } | |
| 50 | output = result.perInstance.map((instance) => instance.output ?? '').join('\n') | |
| 51 | } catch (error) { | |
| 52 | cli.warn(`Could not check the server for moved-off site files: ${error instanceof Error ? error.message : String(error)}`) | |
| 53 | return true | |
| 54 | } | |
| 55 | ||
| 56 | const drained = parseDrainedSites(output) | |
| 57 | if (drained.length === 0) return true | |
| 58 | ||
| 59 | cli.error(formatDrainedSiteRefusal(drained, slug, DISCARD_FLAG)) | |
| 60 | return false | |
| 61 | } | |
| 62 | ||
| 7 | 63 | /** |
| 8 | 64 | * Lifecycle commands for the lightweight single-server (Forge-style) compute |
| 9 | 65 | * provisioned by `cloud deploy` when `compute.mode: 'server'`. |
| @@ -13,7 +69,8 @@ export function registerComputeLifecycleCommands(app: CLI): void { | ||
| 13 | 69 | .command('destroy', 'Destroy the single-server compute (instance + firewall)') |
| 14 | 70 | .option('--env <env>', 'Environment', { default: 'production' }) |
| 15 | 71 | .option('--force', 'Skip the confirmation prompt') |
| 16 | .action(async (options?: { env?: string; force?: boolean }) => { | |
| 72 | .option('--discard-drained-sites', 'Destroy even though a moved site left its rollback files here') | |
| 73 | .action(async (options?: { env?: string; force?: boolean; discardDrainedSites?: boolean }) => { | |
| 17 | 74 | cli.header('Destroy Compute') |
| 18 | 75 | const config = await loadValidatedConfig() |
| 19 | 76 | const environment = (options?.env || 'production') as 'production' | 'staging' | 'development' |
| @@ -28,6 +85,15 @@ export function registerComputeLifecycleCommands(app: CLI): void { | ||
| 28 | 85 | cli.warn( |
| 29 | 86 | `This terminates the ${provider} server for ${config.project.slug}/${environment} and deletes its firewall.`, |
| 30 | 87 | ) |
| 88 | ||
| 89 | // Checked BEFORE the prompt: an operator answering "yes" to a generic | |
| 90 | // irreversibility warning has not been told that a moved site's rollback | |
| 91 | // is sitting on this disk. | |
| 92 | if (!(await drainedSitesAllowTeardown(driver, config, environment, !!options?.discardDrainedSites))) { | |
| 93 | process.exitCode = 1 | |
| 94 | return | |
| 95 | } | |
| 96 | ||
| 31 | 97 | if (!options?.force) { |
| 32 | 98 | const ok = await cli.confirm('This is irreversible. Continue?', false) |
| 33 | 99 | if (!ok) { |