Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 109 additions & 12 deletions packages/cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string, string> = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this missing StatusPages?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good catch. Status pages and Status page services.

[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
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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,
Expand All @@ -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: {
Expand Down Expand Up @@ -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')
Expand All @@ -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),
Expand Down Expand Up @@ -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 ([
Expand All @@ -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 })
}
}

Expand Down Expand Up @@ -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'
}

Expand All @@ -387,16 +485,15 @@ export default class Deploy extends AuthCommand {
}
if (sortedDeleting.length) {
output.push(chalk.bold.red('Delete:'))
const prettyResourceTypes: Record<string, string> = {
[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('')
}
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/src/rest/__tests__/projects-deploy.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
13 changes: 13 additions & 0 deletions packages/cli/src/rest/__tests__/projects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
25 changes: 21 additions & 4 deletions packages/cli/src/rest/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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<ProjectDeployResponse | ProjectDeployment>(
`/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}`,
`/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}${preserveParam}`,
resources,
{ transformRequest: compressJSONPayload },
)
Expand Down
Loading