diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 53277c613..24bfbb557 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -9,7 +9,7 @@ import { Check, AlertChannelSubscription, AlertChannel, CheckGroup, Dashboard, MaintenanceWindow, PrivateLocation, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, Project, ProjectData, Diagnostics, - Session, + Session, StatusPage, StatusPageService, } from '../constructs/index.js' import chalk from 'chalk' import { splitConfigFilePath, getGitInformation } from '../services/util.js' @@ -27,8 +27,30 @@ enum ResourceDeployStatus { UPDATE = 'UPDATE', CREATE = 'CREATE', DELETE = 'DELETE', + // Returned by newer backends for resources removed from code that are kept in + // the account (detached, now managed in the Checkly Webapp) instead of deleted. + DETACHED = 'DETACHED', } +const PRETTY_RESOURCE_TYPES: Record = { + [Check.__checklyType]: 'Check', + [AlertChannel.__checklyType]: 'AlertChannel', + [CheckGroup.__checklyType]: 'CheckGroup', + [MaintenanceWindow.__checklyType]: 'MaintenanceWindow', + [PrivateLocation.__checklyType]: 'PrivateLocation', + [Dashboard.__checklyType]: 'Dashboard', + [StatusPage.__checklyType]: 'StatusPage', + [StatusPageService.__checklyType]: 'StatusPageService', +} + +// Internal resources that users don't create directly. They are reported as +// part of their owning check, so we exclude them from delete previews/guards. +const NON_REPORTED_TYPES = [ + AlertChannelSubscription.__checklyType, + PrivateLocationCheckAssignment.__checklyType, + PrivateLocationGroupAssignment.__checklyType, +] + export default class Deploy extends AuthCommand { static coreCommand = true static hidden = false @@ -56,6 +78,10 @@ export default class Deploy extends AuthCommand { default: true, allowNo: true, }), + 'preserve-resources': Flags.boolean({ + description: 'Detach resources removed from code (keep them and their run history) instead of deleting them.', + default: false, + }), 'force': forceFlag(), 'cancel-in-progress-deployment': Flags.boolean({ 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 { preview, 'cancel-in-progress-deployment': cancelInProgress, 'schedule-on-deploy': scheduleOnDeploy, + 'preserve-resources': preserveResources, output: outputFlag, verbose, config: configFilename, @@ -114,6 +141,9 @@ export default class Deploy extends AuthCommand { scheduleOnDeploy ? 'Schedule checks after deploy' : 'Checks will NOT be scheduled after deploy', + preserveResources + ? 'Detach any resources removed from code, keeping them and their run history for management in the Checkly Webapp' + : 'Delete any resources removed from code, losing their run history. Pass --preserve-resources to detach and keep them instead', ], flags, classification: { @@ -244,6 +274,48 @@ export default class Deploy extends AuthCommand { return } + // Preflight destructive-delete guard. Deletions are only known from the diff, + // which we don't have until after a deploy call. For a non-preview, non-preserve + // run that isn't already forced, do a dry-run first to surface resources that + // would be permanently deleted and require an explicit confirmation. + if (!preview && !preserveResources && !force) { + let deletions: Array<{ resourceType: string, logicalId: string }> = [] + try { + const { data: dryRunData } = await api.projects.deploy( + { ...projectPayload, repoInfo }, + { dryRun: true, scheduleOnDeploy, preserveResources }, + ) + deletions = this.collectDeletions(dryRunData) + } catch (err: any) { + this.style.longError(`Your project could not be deployed.`, err) + this.exit(1) + } + + if (deletions.length) { + this.log(chalk.bold.red('\nThe following resources were removed from code and will be DELETED, losing their run history:')) + for (const { resourceType, logicalId } of deletions) { + this.log(chalk.red(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`)) + } + this.log(chalk.yellow('\nPass --preserve-resources to detach and keep them (and their run history) instead.\n')) + + await this.confirmOrAbort({ + command: 'deploy', + description: 'Delete resources removed from code', + changes: [ + `Permanently delete ${deletions.length} resource(s) removed from code, losing their run history`, + ...deletions.map(({ resourceType, logicalId }) => + `Delete ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`), + ], + flags, + classification: { + readOnly: Deploy.readOnly, + destructive: Deploy.destructive, + idempotent: Deploy.idempotent, + }, + }, { force }) + } + } + try { if (!preview) { this.style.actionStart('Deploying project') @@ -253,6 +325,7 @@ export default class Deploy extends AuthCommand { { dryRun: preview, scheduleOnDeploy, + preserveResources, cancelInProgress, onProgress: preview ? undefined : progress => this.style.actionStatus(`${progress}% complete`), onStatus: preview ? undefined : message => this.style.actionStatus(message), @@ -299,13 +372,30 @@ export default class Deploy extends AuthCommand { } } - private formatPreview (previewData: ProjectDeployResponse, project: Project, verbose = false): string { + private collectDeletions (previewData: ProjectDeployResponse): Array<{ resourceType: string, logicalId: string }> { + return (previewData?.diff ?? []) + .filter(change => + change.action === ResourceDeployStatus.DELETE + && !NON_REPORTED_TYPES.some(t => t === change.type), + ) + .map(({ type, logicalId }) => ({ resourceType: type, logicalId })) + .sort((a, b) => + a.resourceType.localeCompare(b.resourceType) || a.logicalId.localeCompare(b.logicalId), + ) + } + + private formatPreview ( + previewData: ProjectDeployResponse, + project: Project, + verbose = false, + ): string { // Current format of the data is: { checks: { logical-id-1: 'UPDATE' }, groups: { another-logical-id: 'CREATE' } } // We convert it into update: [{ logicalId, resourceType, construct }, ...], create: [], delete: [] // This makes it easier to display. const updating = [] const creating = [] const deleting: Array<{ resourceType: string, logicalId: string }> = [] + const detaching: Array<{ resourceType: string, logicalId: string }> = [] for (const change of previewData?.diff ?? []) { const { type, logicalId, physicalId, action } = change if ([ @@ -325,6 +415,10 @@ export default class Deploy extends AuthCommand { } else if (action === ResourceDeployStatus.DELETE) { // Since the resource is being deleted, the construct isn't in the project. deleting.push({ resourceType: type, logicalId }) + } else if (action === ResourceDeployStatus.DETACHED) { + // Newer backends report detached resources explicitly. The construct + // isn't in the project since it was removed from code. + detaching.push({ resourceType: type, logicalId }) } } @@ -366,7 +460,11 @@ export default class Deploy extends AuthCommand { const sortedDeleting = deleting .sort(compareEntries) - if (!sortedCreating.length && !sortedDeleting.length && !sortedUpdating.length && !skipping.length) { + const sortedDetaching = detaching + .sort(compareEntries) + + if (!sortedCreating.length && !sortedDeleting.length && !sortedDetaching.length + && !sortedUpdating.length && !skipping.length) { 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' } @@ -387,16 +485,15 @@ export default class Deploy extends AuthCommand { } if (sortedDeleting.length) { output.push(chalk.bold.red('Delete:')) - const prettyResourceTypes: Record = { - [Check.__checklyType]: 'Check', - [AlertChannel.__checklyType]: 'AlertChannel', - [CheckGroup.__checklyType]: 'CheckGroup', - [MaintenanceWindow.__checklyType]: 'MaintenanceWindow', - [PrivateLocation.__checklyType]: 'PrivateLocation', - [Dashboard.__checklyType]: 'Dashboard', - } for (const { resourceType, logicalId } of sortedDeleting) { - output.push(` ${prettyResourceTypes[resourceType] ?? resourceType}: ${logicalId}`) + output.push(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`) + } + output.push('') + } + if (sortedDetaching.length) { + output.push(chalk.bold.yellow('Detached (kept in account, now managed in the Checkly Webapp):')) + for (const { resourceType, logicalId } of sortedDetaching) { + output.push(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`) } output.push('') } diff --git a/packages/cli/src/rest/__tests__/projects-deploy.spec.ts b/packages/cli/src/rest/__tests__/projects-deploy.spec.ts new file mode 100644 index 000000000..58faea83a --- /dev/null +++ b/packages/cli/src/rest/__tests__/projects-deploy.spec.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest' +import { Readable } from 'node:stream' +import type { AxiosInstance } from 'axios' +import Projects, { type ProjectSync } from '../projects.js' + +// Build an SSE frame and a readable stream that emits the given frames then ends. +const sse = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` +const sseStream = (...frames: string[]) => ({ data: Readable.from(frames) }) + +const applied = { project: { name: 'p', logicalId: 'p' }, diff: [] } + +function createProjects () { + // A real (non-dry-run) deploy submits, then follows the SSE stream to completion, + // so post returns a deployment id and get yields a terminal 'complete' frame. + const post = vi.fn().mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + const get = vi.fn().mockResolvedValue( + sseStream(sse('complete', { id: 'dep-1', status: 'SUCCEEDED', progress: 100, result: applied, error: null })), + ) + const api = { post, get } as unknown as AxiosInstance + return { projects: new Projects(api), post } +} + +const resources: ProjectSync = { + project: { name: 'p', logicalId: 'p' }, + resources: [], + repoInfo: null, +} + +describe('Projects.deploy query params', () => { + it('omits preserveResources by default', async () => { + const { projects, post } = createProjects() + await projects.deploy(resources) + const url = post.mock.calls[0][0] as string + expect(url).toContain('dryRun=false') + expect(url).toContain('scheduleOnDeploy=true') + expect(url).not.toContain('preserveResources') + }) + + it('forwards preserveResources=true', async () => { + const { projects, post } = createProjects() + await projects.deploy(resources, { dryRun: true, scheduleOnDeploy: false, preserveResources: true }) + const url = post.mock.calls[0][0] as string + expect(url).toContain('dryRun=true') + expect(url).toContain('scheduleOnDeploy=false') + expect(url).toContain('preserveResources=true') + }) +}) diff --git a/packages/cli/src/rest/__tests__/projects.spec.ts b/packages/cli/src/rest/__tests__/projects.spec.ts index 34da23dee..94521840c 100644 --- a/packages/cli/src/rest/__tests__/projects.spec.ts +++ b/packages/cli/src/rest/__tests__/projects.spec.ts @@ -54,6 +54,19 @@ describe('Projects.deploy', () => { expect(data).toEqual(preview) }) + it('omits preserveResources by default and only sends it when opted in', async () => { + const preview = { project: sync.project, diff: [] } + vi.mocked(api.post).mockResolvedValue({ data: preview }) + + await projects.deploy(sync, { dryRun: true }) + expect(vi.mocked(api.post).mock.calls[0][0]).toBe('/v1/projects/deploy?dryRun=true&scheduleOnDeploy=true') + + await projects.deploy(sync, { dryRun: true, preserveResources: true }) + expect(vi.mocked(api.post).mock.calls[1][0]).toBe( + '/v1/projects/deploy?dryRun=true&scheduleOnDeploy=true&preserveResources=true', + ) + }) + it('submits async, follows the SSE stream, reports progress, and returns the applied diff', async () => { vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) vi.mocked(api.get).mockResolvedValue( diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 2d3a24415..85bd0dce9 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -378,9 +378,21 @@ class Projects { */ async deploy ( resources: ProjectSync, - { dryRun = false, scheduleOnDeploy = true, cancelInProgress = false, onProgress, onStatus }: { + { + dryRun = false, + scheduleOnDeploy = true, + preserveResources = false, + cancelInProgress = false, + onProgress, + onStatus, + }: { dryRun?: boolean scheduleOnDeploy?: boolean + /** + * Detach resources removed from code (keep them and their run history) + * instead of deleting them. + */ + preserveResources?: boolean /** * On a 409 (another deployment is already in progress), cancel that * deployment instead of waiting for it to finish, then retry. @@ -402,7 +414,7 @@ class Projects { const deadlineAt = Date.now() + DEPLOY_CONFLICT_WAIT_DEADLINE_MS for (;;) { try { - return await this.submitDeployment(resources, { dryRun, scheduleOnDeploy, onProgress }) + return await this.submitDeployment(resources, { dryRun, scheduleOnDeploy, preserveResources, onProgress }) } catch (err) { if ( dryRun @@ -424,14 +436,19 @@ class Projects { private async submitDeployment ( resources: ProjectSync, - { dryRun, scheduleOnDeploy, onProgress }: { + { dryRun, scheduleOnDeploy, preserveResources, onProgress }: { dryRun: boolean scheduleOnDeploy: boolean + preserveResources: boolean onProgress?: (progress: number) => void }, ): Promise<{ data: ProjectDeployResponse }> { + // Only send preserveResources when the user opted in. The endpoint rejects + // unknown query params, and preserveResources=false is the default (delete) + // behavior, so omitting it keeps default deploys backwards compatible. + const preserveParam = preserveResources ? '&preserveResources=true' : '' const { data } = await this.api.post( - `/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}`, + `/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}${preserveParam}`, resources, { transformRequest: compressJSONPayload }, )