Skip to content

Commit 522049f

Browse files
martzoukosclaude
andauthored
feat(cli): add deploy --preserve-resources + destructive-delete guard (AI-426) [ship] (#1390)
* feat(cli): add deploy --preserve-resources + destructive-delete guard (AI-426) `checkly deploy --preserve-resources` detaches resources removed from code (kept in the account, returned to UI management) instead of deleting them, forwarding preserveResources to the deploy endpoint and rendering the removed resources as "Detached" in the preview. Default deploy now guards against silent data loss: for an interactive, non-forced run a preflight dry-run surfaces any resources that would be permanently deleted and requires an explicit confirmation (skipped by --force). The pre-deploy summary also advertises --preserve-resources. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli): read DETACHED deploy action from backend (AI-426) The deploy preview now reads an explicit DETACHED action from the diff instead of inferring detachment solely from the client-side --preserve-resources flag. Detached resources render in their own "Detached (kept in account, now UI-managed)" section, separate from "Delete:". Backwards compatible both ways: older backends that still return DELETE for preserved resources are folded into the detached bucket when --preserve-resources is set, and older CLIs simply ignore the unknown DETACHED action (no crash, since the action field is an unvalidated string matched by an if/else chain). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(cli): address deploy --preserve-resources review feedback (AI-426) - Add StatusPage and StatusPageService to PRETTY_RESOURCE_TYPES so they render with friendly names in delete/detach previews and guards. - Reword user-facing "UI" references to "Checkly Webapp". - Replace the em dash before --preserve-resources with a period for readability. - Remove the transitional DELETE-to-detach display fallback in formatPreview (and its now-unused preserveResources param); the backend ships before the CLI and always emits DETACHED. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cli): only send preserveResources deploy param when opted in (AI-426) The /v1/projects/deploy endpoint rejects unknown query params, and the deploy-side backend support for preserveResources is not deployed yet, so sending preserveResources=false on every deploy made all deploys fail with `"preserveResources" is not allowed` (all e2e deploy tests red). preserveResources=false is identical to the backend's default (delete resources removed from code), so omit the param unless the user opted in with --preserve-resources. Default deploys are now byte-for-byte identical to before the feature; the param only rides along when explicitly requested (which requires the deploy-side backend support to ship first). Also fix the merged projects-deploy unit test to drive the async event-stream deploy, and update assertions to the conditional param. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 07b14d6 commit 522049f

4 files changed

Lines changed: 190 additions & 16 deletions

File tree

packages/cli/src/commands/deploy.ts

Lines changed: 109 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
Check, AlertChannelSubscription, AlertChannel, CheckGroup, Dashboard,
1010
MaintenanceWindow, PrivateLocation, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment,
1111
Project, ProjectData, Diagnostics,
12-
Session,
12+
Session, StatusPage, StatusPageService,
1313
} from '../constructs/index.js'
1414
import chalk from 'chalk'
1515
import { splitConfigFilePath, getGitInformation } from '../services/util.js'
@@ -27,8 +27,30 @@ enum ResourceDeployStatus {
2727
UPDATE = 'UPDATE',
2828
CREATE = 'CREATE',
2929
DELETE = 'DELETE',
30+
// Returned by newer backends for resources removed from code that are kept in
31+
// the account (detached, now managed in the Checkly Webapp) instead of deleted.
32+
DETACHED = 'DETACHED',
3033
}
3134

35+
const PRETTY_RESOURCE_TYPES: Record<string, string> = {
36+
[Check.__checklyType]: 'Check',
37+
[AlertChannel.__checklyType]: 'AlertChannel',
38+
[CheckGroup.__checklyType]: 'CheckGroup',
39+
[MaintenanceWindow.__checklyType]: 'MaintenanceWindow',
40+
[PrivateLocation.__checklyType]: 'PrivateLocation',
41+
[Dashboard.__checklyType]: 'Dashboard',
42+
[StatusPage.__checklyType]: 'StatusPage',
43+
[StatusPageService.__checklyType]: 'StatusPageService',
44+
}
45+
46+
// Internal resources that users don't create directly. They are reported as
47+
// part of their owning check, so we exclude them from delete previews/guards.
48+
const NON_REPORTED_TYPES = [
49+
AlertChannelSubscription.__checklyType,
50+
PrivateLocationCheckAssignment.__checklyType,
51+
PrivateLocationGroupAssignment.__checklyType,
52+
]
53+
3254
export default class Deploy extends AuthCommand {
3355
static coreCommand = true
3456
static hidden = false
@@ -56,6 +78,10 @@ export default class Deploy extends AuthCommand {
5678
default: true,
5779
allowNo: true,
5880
}),
81+
'preserve-resources': Flags.boolean({
82+
description: 'Detach resources removed from code (keep them and their run history) instead of deleting them.',
83+
default: false,
84+
}),
5985
'force': forceFlag(),
6086
'cancel-in-progress-deployment': Flags.boolean({
6187
description: 'If a deployment for this project is already in progress, cancel it instead of waiting for it to finish.',
@@ -90,6 +116,7 @@ export default class Deploy extends AuthCommand {
90116
preview,
91117
'cancel-in-progress-deployment': cancelInProgress,
92118
'schedule-on-deploy': scheduleOnDeploy,
119+
'preserve-resources': preserveResources,
93120
output: outputFlag,
94121
verbose,
95122
config: configFilename,
@@ -114,6 +141,9 @@ export default class Deploy extends AuthCommand {
114141
scheduleOnDeploy
115142
? 'Schedule checks after deploy'
116143
: 'Checks will NOT be scheduled after deploy',
144+
preserveResources
145+
? 'Detach any resources removed from code, keeping them and their run history for management in the Checkly Webapp'
146+
: 'Delete any resources removed from code, losing their run history. Pass --preserve-resources to detach and keep them instead',
117147
],
118148
flags,
119149
classification: {
@@ -244,6 +274,48 @@ export default class Deploy extends AuthCommand {
244274
return
245275
}
246276

277+
// Preflight destructive-delete guard. Deletions are only known from the diff,
278+
// which we don't have until after a deploy call. For a non-preview, non-preserve
279+
// run that isn't already forced, do a dry-run first to surface resources that
280+
// would be permanently deleted and require an explicit confirmation.
281+
if (!preview && !preserveResources && !force) {
282+
let deletions: Array<{ resourceType: string, logicalId: string }> = []
283+
try {
284+
const { data: dryRunData } = await api.projects.deploy(
285+
{ ...projectPayload, repoInfo },
286+
{ dryRun: true, scheduleOnDeploy, preserveResources },
287+
)
288+
deletions = this.collectDeletions(dryRunData)
289+
} catch (err: any) {
290+
this.style.longError(`Your project could not be deployed.`, err)
291+
this.exit(1)
292+
}
293+
294+
if (deletions.length) {
295+
this.log(chalk.bold.red('\nThe following resources were removed from code and will be DELETED, losing their run history:'))
296+
for (const { resourceType, logicalId } of deletions) {
297+
this.log(chalk.red(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`))
298+
}
299+
this.log(chalk.yellow('\nPass --preserve-resources to detach and keep them (and their run history) instead.\n'))
300+
301+
await this.confirmOrAbort({
302+
command: 'deploy',
303+
description: 'Delete resources removed from code',
304+
changes: [
305+
`Permanently delete ${deletions.length} resource(s) removed from code, losing their run history`,
306+
...deletions.map(({ resourceType, logicalId }) =>
307+
`Delete ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`),
308+
],
309+
flags,
310+
classification: {
311+
readOnly: Deploy.readOnly,
312+
destructive: Deploy.destructive,
313+
idempotent: Deploy.idempotent,
314+
},
315+
}, { force })
316+
}
317+
}
318+
247319
try {
248320
if (!preview) {
249321
this.style.actionStart('Deploying project')
@@ -253,6 +325,7 @@ export default class Deploy extends AuthCommand {
253325
{
254326
dryRun: preview,
255327
scheduleOnDeploy,
328+
preserveResources,
256329
cancelInProgress,
257330
onProgress: preview ? undefined : progress => this.style.actionStatus(`${progress}% complete`),
258331
onStatus: preview ? undefined : message => this.style.actionStatus(message),
@@ -299,13 +372,30 @@ export default class Deploy extends AuthCommand {
299372
}
300373
}
301374

302-
private formatPreview (previewData: ProjectDeployResponse, project: Project, verbose = false): string {
375+
private collectDeletions (previewData: ProjectDeployResponse): Array<{ resourceType: string, logicalId: string }> {
376+
return (previewData?.diff ?? [])
377+
.filter(change =>
378+
change.action === ResourceDeployStatus.DELETE
379+
&& !NON_REPORTED_TYPES.some(t => t === change.type),
380+
)
381+
.map(({ type, logicalId }) => ({ resourceType: type, logicalId }))
382+
.sort((a, b) =>
383+
a.resourceType.localeCompare(b.resourceType) || a.logicalId.localeCompare(b.logicalId),
384+
)
385+
}
386+
387+
private formatPreview (
388+
previewData: ProjectDeployResponse,
389+
project: Project,
390+
verbose = false,
391+
): string {
303392
// Current format of the data is: { checks: { logical-id-1: 'UPDATE' }, groups: { another-logical-id: 'CREATE' } }
304393
// We convert it into update: [{ logicalId, resourceType, construct }, ...], create: [], delete: []
305394
// This makes it easier to display.
306395
const updating = []
307396
const creating = []
308397
const deleting: Array<{ resourceType: string, logicalId: string }> = []
398+
const detaching: Array<{ resourceType: string, logicalId: string }> = []
309399
for (const change of previewData?.diff ?? []) {
310400
const { type, logicalId, physicalId, action } = change
311401
if ([
@@ -325,6 +415,10 @@ export default class Deploy extends AuthCommand {
325415
} else if (action === ResourceDeployStatus.DELETE) {
326416
// Since the resource is being deleted, the construct isn't in the project.
327417
deleting.push({ resourceType: type, logicalId })
418+
} else if (action === ResourceDeployStatus.DETACHED) {
419+
// Newer backends report detached resources explicitly. The construct
420+
// isn't in the project since it was removed from code.
421+
detaching.push({ resourceType: type, logicalId })
328422
}
329423
}
330424

@@ -366,7 +460,11 @@ export default class Deploy extends AuthCommand {
366460
const sortedDeleting = deleting
367461
.sort(compareEntries)
368462

369-
if (!sortedCreating.length && !sortedDeleting.length && !sortedUpdating.length && !skipping.length) {
463+
const sortedDetaching = detaching
464+
.sort(compareEntries)
465+
466+
if (!sortedCreating.length && !sortedDeleting.length && !sortedDetaching.length
467+
&& !sortedUpdating.length && !skipping.length) {
370468
return '\nNo checks were detected. More information on how to set up a Checkly CLI project is available at https://checklyhq.com/docs/cli/.\n'
371469
}
372470

@@ -387,16 +485,15 @@ export default class Deploy extends AuthCommand {
387485
}
388486
if (sortedDeleting.length) {
389487
output.push(chalk.bold.red('Delete:'))
390-
const prettyResourceTypes: Record<string, string> = {
391-
[Check.__checklyType]: 'Check',
392-
[AlertChannel.__checklyType]: 'AlertChannel',
393-
[CheckGroup.__checklyType]: 'CheckGroup',
394-
[MaintenanceWindow.__checklyType]: 'MaintenanceWindow',
395-
[PrivateLocation.__checklyType]: 'PrivateLocation',
396-
[Dashboard.__checklyType]: 'Dashboard',
397-
}
398488
for (const { resourceType, logicalId } of sortedDeleting) {
399-
output.push(` ${prettyResourceTypes[resourceType] ?? resourceType}: ${logicalId}`)
489+
output.push(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`)
490+
}
491+
output.push('')
492+
}
493+
if (sortedDetaching.length) {
494+
output.push(chalk.bold.yellow('Detached (kept in account, now managed in the Checkly Webapp):'))
495+
for (const { resourceType, logicalId } of sortedDetaching) {
496+
output.push(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`)
400497
}
401498
output.push('')
402499
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { Readable } from 'node:stream'
3+
import type { AxiosInstance } from 'axios'
4+
import Projects, { type ProjectSync } from '../projects.js'
5+
6+
// Build an SSE frame and a readable stream that emits the given frames then ends.
7+
const sse = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
8+
const sseStream = (...frames: string[]) => ({ data: Readable.from(frames) })
9+
10+
const applied = { project: { name: 'p', logicalId: 'p' }, diff: [] }
11+
12+
function createProjects () {
13+
// A real (non-dry-run) deploy submits, then follows the SSE stream to completion,
14+
// so post returns a deployment id and get yields a terminal 'complete' frame.
15+
const post = vi.fn().mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } })
16+
const get = vi.fn().mockResolvedValue(
17+
sseStream(sse('complete', { id: 'dep-1', status: 'SUCCEEDED', progress: 100, result: applied, error: null })),
18+
)
19+
const api = { post, get } as unknown as AxiosInstance
20+
return { projects: new Projects(api), post }
21+
}
22+
23+
const resources: ProjectSync = {
24+
project: { name: 'p', logicalId: 'p' },
25+
resources: [],
26+
repoInfo: null,
27+
}
28+
29+
describe('Projects.deploy query params', () => {
30+
it('omits preserveResources by default', async () => {
31+
const { projects, post } = createProjects()
32+
await projects.deploy(resources)
33+
const url = post.mock.calls[0][0] as string
34+
expect(url).toContain('dryRun=false')
35+
expect(url).toContain('scheduleOnDeploy=true')
36+
expect(url).not.toContain('preserveResources')
37+
})
38+
39+
it('forwards preserveResources=true', async () => {
40+
const { projects, post } = createProjects()
41+
await projects.deploy(resources, { dryRun: true, scheduleOnDeploy: false, preserveResources: true })
42+
const url = post.mock.calls[0][0] as string
43+
expect(url).toContain('dryRun=true')
44+
expect(url).toContain('scheduleOnDeploy=false')
45+
expect(url).toContain('preserveResources=true')
46+
})
47+
})

packages/cli/src/rest/__tests__/projects.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ describe('Projects.deploy', () => {
5454
expect(data).toEqual(preview)
5555
})
5656

57+
it('omits preserveResources by default and only sends it when opted in', async () => {
58+
const preview = { project: sync.project, diff: [] }
59+
vi.mocked(api.post).mockResolvedValue({ data: preview })
60+
61+
await projects.deploy(sync, { dryRun: true })
62+
expect(vi.mocked(api.post).mock.calls[0][0]).toBe('/v1/projects/deploy?dryRun=true&scheduleOnDeploy=true')
63+
64+
await projects.deploy(sync, { dryRun: true, preserveResources: true })
65+
expect(vi.mocked(api.post).mock.calls[1][0]).toBe(
66+
'/v1/projects/deploy?dryRun=true&scheduleOnDeploy=true&preserveResources=true',
67+
)
68+
})
69+
5770
it('submits async, follows the SSE stream, reports progress, and returns the applied diff', async () => {
5871
vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } })
5972
vi.mocked(api.get).mockResolvedValue(

packages/cli/src/rest/projects.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -378,9 +378,21 @@ class Projects {
378378
*/
379379
async deploy (
380380
resources: ProjectSync,
381-
{ dryRun = false, scheduleOnDeploy = true, cancelInProgress = false, onProgress, onStatus }: {
381+
{
382+
dryRun = false,
383+
scheduleOnDeploy = true,
384+
preserveResources = false,
385+
cancelInProgress = false,
386+
onProgress,
387+
onStatus,
388+
}: {
382389
dryRun?: boolean
383390
scheduleOnDeploy?: boolean
391+
/**
392+
* Detach resources removed from code (keep them and their run history)
393+
* instead of deleting them.
394+
*/
395+
preserveResources?: boolean
384396
/**
385397
* On a 409 (another deployment is already in progress), cancel that
386398
* deployment instead of waiting for it to finish, then retry.
@@ -402,7 +414,7 @@ class Projects {
402414
const deadlineAt = Date.now() + DEPLOY_CONFLICT_WAIT_DEADLINE_MS
403415
for (;;) {
404416
try {
405-
return await this.submitDeployment(resources, { dryRun, scheduleOnDeploy, onProgress })
417+
return await this.submitDeployment(resources, { dryRun, scheduleOnDeploy, preserveResources, onProgress })
406418
} catch (err) {
407419
if (
408420
dryRun
@@ -424,14 +436,19 @@ class Projects {
424436

425437
private async submitDeployment (
426438
resources: ProjectSync,
427-
{ dryRun, scheduleOnDeploy, onProgress }: {
439+
{ dryRun, scheduleOnDeploy, preserveResources, onProgress }: {
428440
dryRun: boolean
429441
scheduleOnDeploy: boolean
442+
preserveResources: boolean
430443
onProgress?: (progress: number) => void
431444
},
432445
): Promise<{ data: ProjectDeployResponse }> {
446+
// Only send preserveResources when the user opted in. The endpoint rejects
447+
// unknown query params, and preserveResources=false is the default (delete)
448+
// behavior, so omitting it keeps default deploys backwards compatible.
449+
const preserveParam = preserveResources ? '&preserveResources=true' : ''
433450
const { data } = await this.api.post<ProjectDeployResponse | ProjectDeployment>(
434-
`/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}`,
451+
`/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}${preserveParam}`,
435452
resources,
436453
{ transformRequest: compressJSONPayload },
437454
)

0 commit comments

Comments
 (0)