ReviewOS

also looking at this

stacks/ts-cloud

feat(fleet): refuse to destroy a server still holding a moved site's rollback

#182
Closed chrisbbreuer wants to merge feat/destroy-drained-guard into main
4 files +284 -2
packages/ts-cloud/bin/commands/compute-lifecycle.tsmodified+68-2
Changes to packages/ts-cloud/bin/commands/compute-lifecycle.ts
@@ -1,9 +1,65 @@
11import type { CLI } from '@stacksjs/clapp'
2import { resolveCloudProvider } from '@ts-cloud/core'
2import type { CloudDriver, EnvironmentType } from '@ts-cloud/core'
3import { resolveCloudProvider, resolveProjectStackName } from '@ts-cloud/core'
34import * as cli from '../../src/utils/cli'
45import { createCloudDriver } from '../../src/drivers'
6import { buildDrainedSiteScanScript, formatDrainedSiteRefusal, parseDrainedSites } from '../../src/operations/drained-sites'
57import { loadValidatedConfig } from './shared'
68
9/** The flag that authorizes discarding a drained site's files. */
10const 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 */
21async 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
763/**
864 * Lifecycle commands for the lightweight single-server (Forge-style) compute
965 * provisioned by `cloud deploy` when `compute.mode: 'server'`.
@@ -13,7 +69,8 @@ export function registerComputeLifecycleCommands(app: CLI): void {
1369 .command('destroy', 'Destroy the single-server compute (instance + firewall)')
1470 .option('--env <env>', 'Environment', { default: 'production' })
1571 .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 }) => {
1774 cli.header('Destroy Compute')
1875 const config = await loadValidatedConfig()
1976 const environment = (options?.env || 'production') as 'production' | 'staging' | 'development'
@@ -28,6 +85,15 @@ export function registerComputeLifecycleCommands(app: CLI): void {
2885 cli.warn(
2986 `This terminates the ${provider} server for ${config.project.slug}/${environment} and deletes its firewall.`,
3087 )
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
3197 if (!options?.force) {
3298 const ok = await cli.confirm('This is irreversible. Continue?', false)
3399 if (!ok) {