diff --git a/packages/cli/e2e/__tests__/deploy.spec.ts b/packages/cli/e2e/__tests__/deploy.spec.ts index eebe046aa..8463b0569 100644 --- a/packages/cli/e2e/__tests__/deploy.spec.ts +++ b/packages/cli/e2e/__tests__/deploy.spec.ts @@ -402,11 +402,13 @@ Skip (testOnly): // The check should only be listed under "Delete" and not "Skip". expect(stdout).toContain( `Delete: - Check: testonly-true-check - -Update and Unchanged: - ApiCheck: not-testonly-default-check - ApiCheck: not-testonly-false-check`) + Check: testonly-true-check`) + // The two surviving checks are unchanged between the deploys. An API that + // reports the deploy diff says so and they are counted; an older one + // reports every retained resource as an update and they are listed. + expect(stdout).toMatch( + /(Unchanged: 2)|(Update:\n {4}ApiCheck: not-testonly-default-check\n {4}ApiCheck: not-testonly-false-check)/, + ) // --output without --verbose should not show name or id expect(stdout).not.toContain('name:') expect(stdout).not.toContain('id:') diff --git a/packages/cli/src/ai-context/references/communicate.md b/packages/cli/src/ai-context/references/communicate.md index 7e23a3812..6d9745d0c 100644 --- a/packages/cli/src/ai-context/references/communicate.md +++ b/packages/cli/src/ai-context/references/communicate.md @@ -31,6 +31,8 @@ The `confirmCommand` omits flags left at their default, so a bare `npx checkly d A command that picks its own target before confirming writes that target back into the `confirmCommand`. `import commit` and `import cancel` do this: run without `--plan-id` they select the only candidate plan themselves, and confirm as `checkly import commit --plan-id="" --force` — carrying a flag you never passed. That is deliberate: the pinned ID guarantees the approved run acts on the plan whose `changes` you showed the user, not on whatever happens to be pending by then. Run the `confirmCommand` exactly as returned; do not strip the flag or fall back to the bare command. +`deploy` pins the same way, with `--plan-token`: the token identifies the state of the account the plan was computed against, and the pinned run refuses to deploy — writing nothing — if anything changed in between. Re-run `checkly deploy` in that case; it computes a fresh plan to confirm. Do not carry a token over from an earlier run, and do not re-send a refused one. + ## Available Commands Parse and read further reference documentation when tasked with any of the following: diff --git a/packages/cli/src/ai-context/references/configure.md b/packages/cli/src/ai-context/references/configure.md index c0575b00d..5b93a3b3e 100644 --- a/packages/cli/src/ai-context/references/configure.md +++ b/packages/cli/src/ai-context/references/configure.md @@ -75,7 +75,8 @@ Run `npx checkly skills manage plan` for the full reference. ## Deploying - Deploy checks using the `npx checkly deploy` command. Use `--output` to see the created, updated, and deleted resources. Use `--verbose` to also include each resource's name and physical ID (UUID), which is useful for programmatically referencing deployed resources (e.g. `npx checkly checks get `). -- Use `--preview` to see what a deploy would change without applying it. +- Use `--preview` to see which resources a deploy would create, update, delete or keep, without applying it. The machine-readable forms (`--dry-run`, and the `confirmation_required` envelope) additionally carry the individual properties that would change. +- Use `--prune-relations` to also delete the alert channel subscriptions and private location assignments on this project's checks and groups that the project does not manage. Without it they are only reported. ### Deleted resources @@ -87,14 +88,16 @@ This matters when the local project isn't the whole picture — a partial checko `deploy` is a write command: without `--force` it returns exit code 2 and a `confirmation_required` envelope. Present its `changes` to the user and run the `confirmCommand` verbatim only after they approve. -That confirmation happens **before the project is parsed**, so it cannot tell you which resources would be deleted — its `changes` only warn that deletion is possible. There is a second, itemised guard that lists each doomed resource by name, but it is skipped by `--force`, and `--force` is exactly what the `confirmCommand` carries. **An agent following the confirmation protocol never sees that list.** It is a prompt for humans deploying by hand. +The confirmation happens **after** the project has been parsed and Checkly has been asked what the deploy would change, so it describes the actual deploy: every resource to be deleted is named in `changes`, and the envelope carries a `preview` object with the machine-readable plan — one entry per resource, with the properties that would change. Nothing has been uploaded or written at that point. Show the user the deletions before you run the `confirmCommand`. -So when resources may have been removed from the code, don't rely on the confirmation to surface it — preview first: +The envelope's `preview.planToken` identifies the plan, and the `confirmCommand` carries it as `--plan-token`. The confirming run therefore applies that plan against the same Checkly state and aborts if the account moved in between; it does not pin your local code, so an edit you make between the two runs is previewed again and deployed without a second prompt (see "Commands that pin a resolved target" in the `communicate` skill). + +To look without confirming anything: ```bash npx checkly deploy --preview ``` -This is a dry run: nothing is applied and nothing is confirmed. It prints the resources that would be created, updated, and deleted (add `--verbose` for names and IDs). Show the user what would be deleted, and only then deploy. +Nothing is applied and nothing is confirmed. It prints the resources that would be created, updated, deleted and kept, and the plan token (add `--verbose` for names and IDs). `--dry-run` does the same for machine consumption: it prints the `dry_run` envelope, with the same `preview` object, and exits 0. Run `npx checkly skills communicate` for the full protocol. diff --git a/packages/cli/src/commands/__tests__/confirm-flow-deploy.spec.ts b/packages/cli/src/commands/__tests__/confirm-flow-deploy.spec.ts index a7c98b618..6f635d37d 100644 --- a/packages/cli/src/commands/__tests__/confirm-flow-deploy.spec.ts +++ b/packages/cli/src/commands/__tests__/confirm-flow-deploy.spec.ts @@ -1,13 +1,15 @@ +import path from 'node:path' + import { Parser } from '@oclif/core' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../../helpers/cli-mode', () => ({ detectCliMode: vi.fn(() => 'agent'), })) vi.mock('../../rest/api', () => ({ - runtimes: { getAll: vi.fn() }, - projects: { deploy: vi.fn() }, + runtimes: { getAll: vi.fn().mockResolvedValue([]) }, + projects: { preview: vi.fn(), deploy: vi.fn() }, validateAuthentication: vi.fn().mockResolvedValue({ name: 'Test Account' }), })) @@ -24,6 +26,7 @@ vi.mock('../../services/checkly-config-loader', async () => { constructs: [], diagnostics: new Diagnostics(), }), + resolveDependencyCacheVersion: vi.fn(), } }) @@ -31,7 +34,8 @@ vi.mock('../../services/project-parser', () => ({ parseProject: vi.fn(), })) -vi.mock('../../services/util', () => ({ +vi.mock('../../services/util', async importOriginal => ({ + ...await importOriginal(), splitConfigFilePath: vi.fn().mockReturnValue({ configDirectory: '.', configFilenames: ['checkly.config.ts'], @@ -40,6 +44,21 @@ vi.mock('../../services/util', () => ({ getGitRepoRoot: vi.fn(), })) +// Hoisted, because the mock factory below is hoisted above the module body. +const { storeBundle } = vi.hoisted(() => ({ storeBundle: vi.fn() })) + +vi.mock('../../services/check-parser/bundler', () => ({ + Bundler: { + createForWorkspace: vi.fn().mockResolvedValue({ + // Not empty, so the code bundle upload is on the table and the tests can + // assert when it happens. + isEmpty: false, + updateMarker: vi.fn(), + finalize: vi.fn().mockResolvedValue({ archiveFile: 'bundle.tgz', store: storeBundle }), + }), + }, +})) + vi.mock('prompts', () => ({ default: vi.fn(() => Promise.resolve({ confirm: true })), })) @@ -47,7 +66,17 @@ vi.mock('prompts', () => ({ import { detectCliMode } from '../../helpers/cli-mode.js' import { buildConfirmCommand } from '../../helpers/command-preview.js' import * as api from '../../rest/api.js' +import { + type DiffEntry, + ProjectPlanStaleError, + ProjectPreviewNotSupportedError, +} from '../../rest/projects.js' +import { Ok } from '../../services/check-parser/package-files/result.js' import { parseProject } from '../../services/project-parser.js' +import { getGitRepoRoot } from '../../services/util.js' +import { EmailAlertChannel } from '../../constructs/email-alert-channel.js' +import { Project } from '../../constructs/project.js' +import { Session } from '../../constructs/session.js' import { AuthCommand } from '../authCommand.js' import Deploy from '../deploy.js' @@ -82,11 +111,53 @@ function createConfirmContext () { } } -function createCommandContext (parsed: unknown) { +const DEFAULT_FLAGS = { + 'force': false, + 'preview': false, + 'dry-run': false, + 'plan-token': undefined, + 'prune-relations': false, + 'output': false, + 'verbose': false, + 'config': undefined, + 'schedule-on-deploy': true, + 'preserve-resources': false, + 'cancel-in-progress-deployment': false, + 'verify-runtime-dependencies': true, + 'debug-bundle': false, + 'debug-bundle-output-file': './debug-bundle.json', +} + +const DEFAULT_METADATA = { + 'preview': { setFromDefault: true }, + 'dry-run': { setFromDefault: true }, + 'prune-relations': { setFromDefault: true }, + 'output': { setFromDefault: true }, + 'verbose': { setFromDefault: true }, + 'schedule-on-deploy': { setFromDefault: true }, + 'preserve-resources': { setFromDefault: true }, + 'cancel-in-progress-deployment': { setFromDefault: true }, + 'verify-runtime-dependencies': { setFromDefault: true }, + 'debug-bundle': { setFromDefault: true }, + 'debug-bundle-output-file': { setFromDefault: true }, +} + +function createCommandContext (flags: Record = {}) { const logged: string[] = [] let exitCodeValue: number | undefined + const typed = Object.keys(flags) return { - parse: vi.fn().mockResolvedValue(parsed), + parse: vi.fn().mockResolvedValue({ + flags: { ...DEFAULT_FLAGS, ...flags }, + // A flag the test passes explicitly is one the user typed, so it is not + // marked as coming from a default — which is what decides whether the + // echoed confirmCommand repeats it. + metadata: { + flags: Object.fromEntries( + Object.entries(DEFAULT_METADATA).filter(([key]) => !typed.includes(key)), + ), + }, + }), log: vi.fn((msg?: string) => { if (msg) logged.push(msg) }), @@ -95,9 +166,14 @@ function createCommandContext (parsed: unknown) { throw new Error(`EXIT_${code}`) }), confirmOrAbort: AuthCommand.prototype.confirmOrAbort, + validateProject: (AuthCommand.prototype as any).validateProject, + formatPreview: (Deploy.prototype as any).formatPreview, + collectDeletions: (Deploy.prototype as any).collectDeletions, style: { outputFormat: undefined, + diagnostics: vi.fn(), actionStart: vi.fn(), + actionStatus: vi.fn(), actionSuccess: vi.fn(), actionFailure: vi.fn(), longError: vi.fn(), @@ -114,6 +190,53 @@ function createCommandContext (parsed: unknown) { } } +const repoRoot = path.resolve('/home/user/repo') + +function declareProject () { + Session.reset() + Session.workspace = Ok({} as any) + const project = new Project('my-project', { name: 'My Project' }) + Session.project = project + new EmailAlertChannel('ops', { address: 'ops@example.com' }) + vi.mocked(parseProject).mockResolvedValue(project) + return project +} + +/** The same project, with its constructs declared in a file inside `root`. */ +function declareProjectIn (root: string) { + Session.reset() + Session.workspace = Ok({} as any) + const project = new Project('my-project', { name: 'My Project' }) + Session.project = project + Session.checkFileAbsolutePath = path.join(root, 'src', 'alerts.ts') + new EmailAlertChannel('ops', { address: 'ops@example.com' }) + Session.checkFileAbsolutePath = undefined + vi.mocked(parseProject).mockResolvedValue(project) + return project +} + +const PLAN_TOKEN = 'v1.AAAAAAAAAAAAAAAAAAAAAA' + +const CHANGED: DiffEntry = { + type: 'alert-channel', + logicalId: 'ops', + physicalId: 42, + action: 'UPDATE', + changes: [{ path: '/config/address', origin: 'code', before: 'old@example.com', after: 'ops@example.com' }], + before: { type: 'EMAIL', config: { address: 'old@example.com' } }, +} + +const DELETED: DiffEntry = { + type: 'check', + logicalId: 'gone', + physicalId: 7, + action: 'DELETE', +} + +function planResolves (diff: DiffEntry[] = [CHANGED, DELETED]) { + vi.mocked(api.projects.preview).mockResolvedValue({ planToken: PLAN_TOKEN, diff }) +} + const deployPreview = { command: 'deploy', description: 'Deploy project to Checkly', @@ -129,6 +252,14 @@ const deployPreview = { describe('deploy confirmation flow', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(detectCliMode).mockReturnValue('agent') + storeBundle.mockResolvedValue({ key: 'stored-bundle-key' }) + vi.mocked(api.projects.deploy).mockResolvedValue({ data: { project: {} as any, diff: [] } }) + declareProject() + }) + + afterEach(() => { + Session.reset() }) it('has correct metadata', () => { @@ -138,7 +269,6 @@ describe('deploy confirmation flow', () => { }) it('exits 2 in agent mode without --force', async () => { - vi.mocked(detectCliMode).mockReturnValue('agent') const ctx = createConfirmContext() await expect( @@ -152,7 +282,6 @@ describe('deploy confirmation flow', () => { }) it('passes through with --force in agent mode', async () => { - vi.mocked(detectCliMode).mockReturnValue('agent') const ctx = createConfirmContext() await ctx.confirmOrAbort.call(ctx as any, deployPreview, { force: true }) @@ -160,48 +289,445 @@ describe('deploy confirmation flow', () => { expect(ctx.exit).not.toHaveBeenCalled() }) - it('exits before parsing or bundling in agent mode without --force', async () => { - vi.mocked(detectCliMode).mockReturnValue('agent') - const ctx = createCommandContext({ - flags: { - 'force': false, - 'preview': false, - 'output': false, - 'verbose': false, - 'config': undefined, - 'schedule-on-deploy': true, - 'verify-runtime-dependencies': true, - 'debug-bundle': false, - 'debug-bundle-output-file': './debug-bundle.json', - }, - metadata: { - flags: { - 'preview': { setFromDefault: true }, - 'output': { setFromDefault: true }, - 'verbose': { setFromDefault: true }, - 'schedule-on-deploy': { setFromDefault: true }, - 'verify-runtime-dependencies': { setFromDefault: true }, - 'debug-bundle': { setFromDefault: true }, - 'debug-bundle-output-file': { setFromDefault: true }, - }, - }, - }) + it('asks once, with the plan and the token, and uploads nothing until then', async () => { + planResolves() + const ctx = createCommandContext() - await expect( - Deploy.prototype.run.call(ctx as any), - ).rejects.toThrow('EXIT_2') + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_2') - expect(ctx.style.actionStart).not.toHaveBeenCalled() - expect(api.runtimes.getAll).not.toHaveBeenCalled() - expect(parseProject).not.toHaveBeenCalled() + // One preview, at the detail level this envelope carries: agent mode prints + // the changed properties, so the values behind them are worth fetching. + expect(api.projects.preview).toHaveBeenCalledOnce() + expect(vi.mocked(api.projects.preview).mock.calls[0][1]).toMatchObject({ detail: 'full' }) - expect(ctx.logged).toHaveLength(1) - const output = JSON.parse(ctx.logged[0]) + const output = JSON.parse(ctx.logged[ctx.logged.length - 1]) expect(output.status).toBe('confirmation_required') - expect(output.command).toBe('deploy') - expect(output.classification.idempotent).toBe(true) + // The plan, in the envelope, next to the lines describing it. + expect(output.preview.planToken).toBe(PLAN_TOKEN) + expect(output.preview.diff).toHaveLength(2) + expect(output.changes).toContain('Permanently delete Check: gone, losing its run history') + expect(output.changes).toContain('Update AlertChannel: ops') + // Re-running as told deploys the plan that was shown, not a newer one. + expect(output.confirmCommand).toContain(`--plan-token="${PLAN_TOKEN}"`) expect(output.confirmCommand).toContain('--force') - expect(output.confirmCommand).not.toContain('--no-preview') + + // Nothing was written and nothing was uploaded. + expect(api.projects.deploy).not.toHaveBeenCalled() + expect(storeBundle).not.toHaveBeenCalled() + }) + + it('leaves the resources\' full state out of the envelope', async () => { + planResolves() + const ctx = createCommandContext() + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_2') + + const output = JSON.parse(ctx.logged[ctx.logged.length - 1]) + const entry = output.preview.diff.find((candidate: DiffEntry) => candidate.logicalId === 'ops') + expect(entry).not.toHaveProperty('before') + // The changed properties themselves stay: that is the point of the plan. + expect(entry.changes).toEqual(CHANGED.changes) + }) + + it('leaves a value too large for the envelope out of it, whatever its shape', async () => { + const script = 'a'.repeat(400) + const variables = Array.from({ length: 40 }, (_, index) => ({ key: `K${index}`, value: 'v'.repeat(20) })) + planResolves([{ + type: 'check', + logicalId: 'chk', + physicalId: 1, + action: 'UPDATE', + changes: [ + { path: '/script', origin: 'code', before: 'console.log(1)', after: script }, + { path: '/environmentVariables', origin: 'code', before: [], after: variables }, + ], + }]) + const ctx = createCommandContext() + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_2') + + const output = JSON.parse(ctx.logged[ctx.logged.length - 1]) + const [scriptChange, variablesChange] = output.preview.diff[0].changes + // A long string and a large collection are both summarized; the small + // values next to them are kept. + expect(scriptChange.after).toEqual({ $omitted: script.length }) + expect(scriptChange.before).toBe('console.log(1)') + expect(variablesChange.after).toEqual({ $omitted: JSON.stringify(variables).length }) + expect(variablesChange.before).toEqual([]) + }) + + it('deploys the previewed plan, pinned to its token, with --force', async () => { + planResolves() + const ctx = createCommandContext({ force: true }) + + await Deploy.prototype.run.call(ctx as any) + + expect(api.projects.preview).toHaveBeenCalledOnce() + expect(api.projects.deploy).toHaveBeenCalledOnce() + expect(vi.mocked(api.projects.deploy).mock.calls[0][1]).toMatchObject({ planToken: PLAN_TOKEN }) + // The upload happens once the plan has been accepted, before the deploy. + expect(storeBundle).toHaveBeenCalledOnce() + }) + + it('prints the plan and its token under --preview, and deploys nothing', async () => { + planResolves() + const ctx = createCommandContext({ preview: true }) + + await Deploy.prototype.run.call(ctx as any) + + expect(ctx.logged.join('\n')).toContain(`Plan token: ${PLAN_TOKEN}`) + expect(api.projects.deploy).not.toHaveBeenCalled() + expect(storeBundle).not.toHaveBeenCalled() + }) + + it('prints the dry_run envelope with the plan and exits 0', async () => { + planResolves() + const ctx = createCommandContext({ 'dry-run': true }) + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_0') + + const output = JSON.parse(ctx.logged[ctx.logged.length - 1]) + expect(output.status).toBe('dry_run') + expect(output.preview.planToken).toBe(PLAN_TOKEN) + expect(api.projects.deploy).not.toHaveBeenCalled() + }) + + it('aborts when the plan no longer matches the token the user pinned', async () => { + planResolves() + const ctx = createCommandContext({ 'force': true, 'plan-token': 'v1.BBBBBBBBBBBBBBBBBBBBBB' }) + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_1') + + expect(api.projects.deploy).not.toHaveBeenCalled() + expect(storeBundle).not.toHaveBeenCalled() + expect(ctx.style.longError).toHaveBeenCalledWith( + expect.stringContaining('no longer matches the plan'), + expect.any(String), + ) + }) + + it('deploys with the token when it matches', async () => { + planResolves() + const ctx = createCommandContext({ 'force': true, 'plan-token': PLAN_TOKEN }) + + await Deploy.prototype.run.call(ctx as any) + + expect(vi.mocked(api.projects.deploy).mock.calls[0][1]).toMatchObject({ planToken: PLAN_TOKEN }) + }) + + it('refuses --prune-relations against an API without the preview endpoint', async () => { + vi.mocked(api.projects.preview).mockRejectedValue(new ProjectPreviewNotSupportedError()) + const ctx = createCommandContext({ 'force': true, 'prune-relations': true }) + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_1') + + expect(api.projects.deploy).not.toHaveBeenCalled() + expect(ctx.style.longError).toHaveBeenCalledWith( + expect.stringContaining('cannot prune relations'), + expect.any(String), + ) + }) + + it('deploys without a plan token when the preview fails', async () => { + // One resource Checkly cannot report on must not make the project + // undeployable: the deploy goes ahead, just unpinned. + vi.mocked(api.projects.preview).mockRejectedValue(new Error('Could not read resource check:api-check')) + const ctx = createCommandContext({ force: true }) + + await Deploy.prototype.run.call(ctx as any) + + expect(ctx.style.longWarning).toHaveBeenCalledWith( + expect.stringContaining('Could not check what this deploy would change'), + expect.any(String), + ) + expect(api.projects.deploy).toHaveBeenCalledOnce() + expect(vi.mocked(api.projects.deploy).mock.calls[0][1]).toMatchObject({ planToken: undefined }) + }) + + it('aborts when the user pinned a plan the API cannot check', async () => { + vi.mocked(api.projects.preview).mockRejectedValue(new ProjectPreviewNotSupportedError()) + const ctx = createCommandContext({ 'force': true, 'plan-token': PLAN_TOKEN }) + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_1') + + expect(api.projects.deploy).not.toHaveBeenCalled() + }) + + it('deploys a payload an API without the preview endpoint accepts', async () => { + vi.mocked(api.projects.preview).mockRejectedValue(new ProjectPreviewNotSupportedError()) + // The payload has to carry the fields before stripping them can mean + // anything: a repository root makes every construct report its sourceFile. + vi.mocked(getGitRepoRoot).mockReturnValue(repoRoot) + declareProjectIn(repoRoot) + const ctx = createCommandContext({ force: true }) + + await Deploy.prototype.run.call(ctx as any) + + expect(api.projects.deploy).toHaveBeenCalledOnce() + const [payload, options] = vi.mocked(api.projects.deploy).mock.calls[0] + expect(options).toMatchObject({ planToken: undefined }) + // None of the fields that arrived with the preview endpoint are sent: an + // older API rejects a key it does not know rather than ignoring it. + const [resource] = payload.resources + expect(resource).not.toHaveProperty('sourceFile') + expect(resource.payload).not.toHaveProperty('codeBundleSha256') + }) + + it('sends those same fields to an API that does support the endpoint', async () => { + // The other half of the fallback: without it, a passing strip test could + // mean the fields are never produced at all. + planResolves() + vi.mocked(getGitRepoRoot).mockReturnValue(repoRoot) + declareProjectIn(repoRoot) + const ctx = createCommandContext({ force: true }) + + await Deploy.prototype.run.call(ctx as any) + + const [payload] = vi.mocked(api.projects.deploy).mock.calls[0] + expect(payload.resources[0]).toHaveProperty('sourceFile', 'src/alerts.ts') + }) + + it('sends a payload the deploy route accepts when a preview fails and nothing was uploaded', async () => { + // A --preview run against a current API whose preview endpoint is busy: no + // plan, no uploads, so the snapshots have no storage key — which the + // dry-run deploy route requires, like every write route. + vi.mocked(api.projects.preview).mockRejectedValue(new Error('the project is busy')) + vi.mocked(getGitRepoRoot).mockReturnValue(repoRoot) + declareProjectIn(repoRoot) + const ctx = createCommandContext({ preview: true }) + + await Deploy.prototype.run.call(ctx as any) + + expect(storeBundle).not.toHaveBeenCalled() + const [payload, options] = vi.mocked(api.projects.deploy).mock.calls[0] + expect(options).toMatchObject({ dryRun: true }) + // The fields the route cannot accept in this state are gone. + const [resource] = payload.resources + expect(resource).not.toHaveProperty('sourceFile') + expect(resource.payload).not.toHaveProperty('codeBundleSha256') + }) + + it('falls back to the dry-run delete guard, uploads first, and asks with what it found', async () => { + vi.mocked(api.projects.preview).mockRejectedValue(new ProjectPreviewNotSupportedError()) + vi.mocked(api.projects.deploy).mockResolvedValue({ + data: { + project: {} as any, + diff: [{ type: 'check', logicalId: 'gone', physicalId: 7, action: 'DELETE' }], + }, + }) + const ctx = createCommandContext() + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_2') + + // Without a plan the deletions are only visible in a dry-run deploy, which + // validates the storage keys — so the uploads have to precede it. + expect(api.projects.deploy).toHaveBeenCalledOnce() + expect(vi.mocked(api.projects.deploy).mock.calls[0][1]).toMatchObject({ dryRun: true }) + expect(storeBundle).toHaveBeenCalled() + + const output = JSON.parse(ctx.logged[ctx.logged.length - 1]) + expect(output.changes).toContain('Permanently delete Check: gone, losing its run history') + // No plan, so nothing to pin: the envelope carries no structured preview. + expect(output.preview).toBeUndefined() + expect(output.confirmCommand).not.toContain('--plan-token') + }) + + it('plans again instead of failing an unattended deploy whose plan went stale', async () => { + // Nobody reviewed this plan, so there is nothing to protect: a write that + // lands while the code bundle uploads must not fail the pipeline. + const fresh = 'v1.BBBBBBBBBBBBBBBBBBBBBB' + vi.mocked(api.projects.preview) + .mockResolvedValueOnce({ planToken: PLAN_TOKEN, diff: [CHANGED] }) + .mockResolvedValueOnce({ planToken: fresh, diff: [CHANGED] }) + vi.mocked(api.projects.deploy) + .mockRejectedValueOnce(new ProjectPlanStaleError('The project changed since the preview.', [])) + .mockResolvedValueOnce({ data: { project: {} as any, diff: [] } }) + const ctx = createCommandContext({ force: true }) + + await Deploy.prototype.run.call(ctx as any) + + expect(api.projects.preview).toHaveBeenCalledTimes(2) + expect(api.projects.deploy).toHaveBeenCalledTimes(2) + // The retry carries the token of the plan it just computed; re-sending the + // refused one would be refused again. + expect(vi.mocked(api.projects.deploy).mock.calls[0][1]).toMatchObject({ planToken: PLAN_TOKEN }) + expect(vi.mocked(api.projects.deploy).mock.calls[1][1]).toMatchObject({ planToken: fresh }) + expect(ctx.style.longError).not.toHaveBeenCalled() + }) + + it('refuses a pinned deploy whose plan went stale rather than re-planning', async () => { + planResolves() + vi.mocked(api.projects.deploy).mockRejectedValue( + new ProjectPlanStaleError('The project changed since the preview.', []), + ) + const ctx = createCommandContext({ 'force': true, 'plan-token': PLAN_TOKEN }) + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_1') + + expect(api.projects.preview).toHaveBeenCalledTimes(1) + expect(api.projects.deploy).toHaveBeenCalledTimes(1) + }) + + it('asks for property values only where they are reported', async () => { + planResolves() + + // Nothing reports them: --force prints the deploy's own result, and the + // terminal output lists resources rather than properties. + await Deploy.prototype.run.call(createCommandContext({ force: true }) as any) + expect(vi.mocked(api.projects.preview).mock.calls[0][1]).toMatchObject({ detail: 'changes' }) + + // --output and --preview print the rendered construct diff of every + // updated resource, which needs each one's deployed state. + vi.mocked(api.projects.preview).mockClear() + await Deploy.prototype.run.call(createCommandContext({ force: true, output: true }) as any) + expect(vi.mocked(api.projects.preview).mock.calls[0][1]).toMatchObject({ detail: 'full' }) + + vi.mocked(api.projects.preview).mockClear() + await Deploy.prototype.run.call(createCommandContext({ preview: true }) as any) + expect(vi.mocked(api.projects.preview).mock.calls[0][1]).toMatchObject({ detail: 'full' }) + + // --dry-run prints the envelope, which carries the changed properties. + vi.mocked(api.projects.preview).mockClear() + await expect(Deploy.prototype.run.call(createCommandContext({ 'dry-run': true }) as any)) + .rejects.toThrow('EXIT_0') + expect(vi.mocked(api.projects.preview).mock.calls[0][1]).toMatchObject({ detail: 'full' }) + }) + + it('prints the construct diff under an updated resource of the preview', async () => { + planResolves([{ ...CHANGED, redactions: [] }]) + + const context = createCommandContext({ preview: true }) + await Deploy.prototype.run.call(context as any) + const output = context.logged.join('\n') + expect(output).toContain('Update:') + expect(output).toContain('--- deployed') + expect(output).toContain('- address: \'old@example.com\'') + expect(output).toContain('+ address: \'ops@example.com\'') + // The variable is named after the logical id on both sides, so the + // address it would otherwise be named after is not a second change. + expect(output).toContain(' export const opsAlert = new EmailAlertChannel(\'ops\', {') + expect(output).not.toContain('-export const') + }) + + it('does not advise --prune-relations to a run that passed it', async () => { + // The relation is already listed as one this deploy deletes; telling the + // user to pass the flag they just passed would be absurd. + planResolves([ + { + type: 'check', + logicalId: 'chk', + physicalId: 1, + action: 'UNCHANGED', + changes: [{ path: '/alertChannels/7', origin: 'unmanaged', before: { ref: 'ops' } }], + }, + { + type: 'alert-channel-subscription', + logicalId: 'unmanaged:7', + physicalId: 7, + action: 'DELETE', + origin: 'unmanaged', + foldedInto: { type: 'check', logicalId: 'chk' }, + }, + ]) + const ctx = createCommandContext({ 'preview': true, 'prune-relations': true }) + + await Deploy.prototype.run.call(ctx as any) + + const printed = ctx.logged.join('\n') + expect(printed).toContain('Prune (relations not managed by this project):') + expect(printed).not.toContain('pass --prune-relations to delete them') + }) + + it('reports an unmanaged relation without advising anything twice when it will not prune', async () => { + planResolves([{ + type: 'check', + logicalId: 'chk', + physicalId: 1, + action: 'UNCHANGED', + changes: [{ path: '/alertChannels/7', origin: 'unmanaged', before: { ref: 'ops' } }], + }]) + const ctx = createCommandContext({ preview: true }) + + await Deploy.prototype.run.call(ctx as any) + + const printed = ctx.logged.join('\n') + expect(printed).toContain('pass --prune-relations to delete them') + expect(printed).not.toContain('Update:') + }) + + it('names every relation a pruning deploy would delete', async () => { + // The one prompt that gates this deploy has to say what it deletes, and a + // pruned relation is reported on its own entry, folded into its parent. + planResolves([ + { + type: 'check', + logicalId: 'chk', + physicalId: 1, + action: 'UNCHANGED', + changes: [{ path: '/alertChannels/7', origin: 'unmanaged', before: { ref: 'ops' } }], + }, + { + type: 'alert-channel-subscription', + logicalId: 'unmanaged:7', + physicalId: 7, + action: 'DELETE', + origin: 'unmanaged', + foldedInto: { type: 'check', logicalId: 'chk' }, + }, + ]) + const ctx = createCommandContext({ 'prune-relations': true }) + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_2') + + const output = JSON.parse(ctx.logged[ctx.logged.length - 1]) + expect(output.changes).toContain( + 'Delete the alert-channel-subscription on Check: chk, which this project does not manage', + ) + expect(vi.mocked(api.projects.preview).mock.calls[0][1]).toMatchObject({ pruneRelations: true }) + }) + + it('does not call a resource an update when only its unmanaged relations changed', async () => { + // Checkly reports those on the owning resource whether or not the deploy + // would delete them; without --prune-relations it deletes nothing. + planResolves([{ + type: 'check', + logicalId: 'chk', + physicalId: 1, + action: 'UNCHANGED', + changes: [{ path: '/alertChannels/7', origin: 'unmanaged', before: { ref: 'ops' } }], + }]) + const ctx = createCommandContext() + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_2') + + const output = JSON.parse(ctx.logged[ctx.logged.length - 1]) + expect(output.changes).not.toContain('Update Check: chk') + // It is still in the plan the envelope carries, so an agent can see it. + expect(output.preview.diff).toHaveLength(1) + }) + + it('prints the current plan and fails when the deploy refuses a stale one', async () => { + planResolves() + const fresh: DiffEntry[] = [{ + type: 'alert-channel', + logicalId: 'ops', + physicalId: 42, + action: 'UPDATE', + changes: [{ path: '/config/address', origin: 'remote', before: 'ops@example.com', after: 'someone@else.com' }], + }] + vi.mocked(api.projects.deploy).mockRejectedValue( + new ProjectPlanStaleError('The project changed since the preview.', fresh), + ) + const ctx = createCommandContext({ 'force': true, 'plan-token': PLAN_TOKEN }) + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_1') + + expect(ctx.style.longError).toHaveBeenCalledWith( + expect.stringContaining('changed while this deploy was being confirmed'), + // Re-running is the way out: the refused token describes a state the + // account has left behind, so it must not be sent again. + expect.stringContaining('Re-run `checkly deploy`'), + ) }) }) @@ -217,7 +743,15 @@ describe('deploy confirmCommand', () => { }) it('generates a command oclif can parse back', async () => { - for (const argv of [[], ['--preserve-resources'], ['--no-schedule-on-deploy'], ['--verbose']]) { + const argvs = [ + [], + ['--preserve-resources'], + ['--no-schedule-on-deploy'], + ['--verbose'], + ['--prune-relations'], + ['--plan-token', PLAN_TOKEN], + ] + for (const argv of argvs) { const confirmCommand = await confirmCommandFor(argv) await expect( Parser.parse(flagArgv(confirmCommand), { flags: Deploy.flags, strict: true }), diff --git a/packages/cli/src/commands/__tests__/deploy-source-file.spec.ts b/packages/cli/src/commands/__tests__/deploy-source-file.spec.ts index 6d27b44dd..3da0d2cd2 100644 --- a/packages/cli/src/commands/__tests__/deploy-source-file.spec.ts +++ b/packages/cli/src/commands/__tests__/deploy-source-file.spec.ts @@ -8,7 +8,12 @@ vi.mock('../../helpers/cli-mode', () => ({ vi.mock('../../rest/api', () => ({ runtimes: { getAll: vi.fn().mockResolvedValue([]) }, - projects: { deploy: vi.fn().mockResolvedValue({ data: { diff: [] } }) }, + projects: { + // A deploy previews first and sends what it previewed, so both calls see + // the same synthesized resources. + preview: vi.fn().mockResolvedValue({ planToken: 'v1.AAAAAAAAAAAAAAAAAAAAAA', diff: [] }), + deploy: vi.fn().mockResolvedValue({ data: { diff: [] } }), + }, validateAuthentication: vi.fn().mockResolvedValue({ name: 'Test Account' }), })) @@ -69,7 +74,12 @@ function createCommandContext () { parse: vi.fn().mockResolvedValue({ flags: { 'force': true, - 'preview': true, + 'preview': false, + 'dry-run': false, + 'plan-token': undefined, + 'prune-relations': false, + 'preserve-resources': false, + 'cancel-in-progress-deployment': false, 'output': false, 'verbose': false, 'config': undefined, @@ -96,6 +106,7 @@ function createCommandContext () { longInfo: vi.fn(), shortError: vi.fn(), }, + confirmOrAbort: AuthCommand.prototype.confirmOrAbort, validateProject: (AuthCommand.prototype as any).validateProject, formatPreview: (Deploy.prototype as any).formatPreview, constructor: Deploy, diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 65a723e3c..218be9732 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -3,6 +3,7 @@ import * as fs from 'fs/promises' import * as api from '../rest/api.js' import { Flags } from '@oclif/core' import { AuthCommand } from './authCommand.js' +import { detectCliMode } from '../helpers/cli-mode.js' import { parseProject } from '../services/project-parser.js' import { loadChecklyConfig, resolveDependencyCacheVersion } from '../services/checkly-config-loader.js' import { @@ -15,9 +16,27 @@ import { import chalk from 'chalk' import { splitConfigFilePath, getGitInformation, getGitRepoRoot } from '../services/util.js' import commonMessages from '../messages/common-messages.js' -import { forceFlag } from '../helpers/flags.js' -import { ProjectDeployResponse, ProjectDeployCancelledError } from '../rest/projects.js' +import { dryRunFlag, forceFlag } from '../helpers/flags.js' +import { physicalIdsFromPlan } from '../services/deploy-diff/import-shape.js' +import { renderResourceDiff } from '../services/deploy-diff/render.js' +import { + DiffEntry, + ProjectDeployResponse, + ProjectDeployCancelledError, + ProjectPlanStaleError, + ProjectPreviewNotSupportedError, + ProjectPreviewResponse, + ProjectSync, + ResourceSync, +} from '../rest/projects.js' import { ConflictError } from '../rest/errors.js' +import { stripUnsupportedDeployFields } from '../services/deploy-diff/legacy-payload.js' +import { + isPrunedRelation, + onlyUnmanagedChanges, + planChangeLines, + reducePlanForAgent, +} from '../services/deploy-diff/plan-summary.js' import { uploadSnapshots } from '../services/snapshot-service.js' import { BrowserCheckBundle } from '../constructs/browser-check-bundle.js' import { Runtime } from '../runtimes/index.js' @@ -28,9 +47,14 @@ enum ResourceDeployStatus { UPDATE = 'UPDATE', CREATE = 'CREATE', DELETE = 'DELETE', - // Returned by newer backends for resources removed from code that are kept in - // the account (now managed from the Checkly web app) instead of deleted. + // Reported for a resource removed from code that is kept in the account + // (managed from the Checkly web app from then on) instead of deleted. + DETACH = 'DETACH', + // What the same case was called before the deploy diff landed. Still + // accepted so a newer CLI keeps rendering an older API's answer. DETACHED = 'DETACHED', + // A resource the deploy leaves alone because code and account agree. + UNCHANGED = 'UNCHANGED', } const PRETTY_RESOURCE_TYPES: Record = { @@ -86,6 +110,16 @@ export default class Deploy extends AuthCommand { default: false, }), 'force': forceFlag(), + 'dry-run': dryRunFlag(), + 'plan-token': Flags.string({ + description: 'Deploy only if the plan still matches this token from an earlier run. ' + + 'Aborts if anything changed in your Checkly account since then.', + }), + 'prune-relations': Flags.boolean({ + description: 'Delete the alert channel subscriptions and private location assignments on this project\'s ' + + 'checks and groups that the project does not manage.', + default: false, + }), '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.', default: false, @@ -117,6 +151,9 @@ export default class Deploy extends AuthCommand { const { force, preview, + 'dry-run': dryRun, + 'plan-token': requestedPlanToken, + 'prune-relations': pruneRelations, 'cancel-in-progress-deployment': cancelInProgress, 'schedule-on-deploy': scheduleOnDeploy, 'preserve-resources': preserveResources, @@ -136,28 +173,10 @@ export default class Deploy extends AuthCommand { } = await loadChecklyConfig(configDirectory, configFilenames) const account = this.account - if (!preview) { - await this.confirmOrAbort({ - command: 'deploy', - description: 'Deploy project to Checkly', - changes: [ - `Deploy project "${checklyConfig.projectName}" to account "${account.name}"`, - scheduleOnDeploy - ? 'Schedule checks after deploy' - : 'Checks will NOT be scheduled after deploy', - preserveResources - ? 'Keep any resources removed from code (and their run history) in your Checkly account, where you can manage them from the Checkly web app' - : 'Delete any resources removed from code, losing their run history. Pass --preserve-resources to keep them in your Checkly account instead', - ], - flags, - flagMetadata: metadata.flags, - classification: { - readOnly: Deploy.readOnly, - destructive: Deploy.destructive, - idempotent: Deploy.idempotent, - }, - }, { force }) - } + // The confirmation happens further down, once the project has been parsed + // and Checkly has said what the deploy would change, so that one prompt + // can show the actual plan instead of asking about a deploy nobody has + // seen yet. this.style.actionStart('Parsing your project') @@ -215,47 +234,59 @@ export default class Deploy extends AuthCommand { const archive = await bundler.finalize() bundler.updateMarker(archive.archiveFile) - // The remote code bundle is only consumed by Playwright check suites (via - // bundler.marker). If nothing registered files to bundle (e.g. a project of - // only uptime monitors), there is nothing to upload — skip the store() to - // avoid an unnecessary code-bundle upload. - if (!bundler.isEmpty) { - this.style.actionStart('Uploading Playwright tests') - try { - const storedArchive = await archive.store() - bundler.updateMarker(storedArchive.key) - this.style.actionSuccess() - } catch (err) { - this.style.actionFailure() - throw err + const browserBundles: BrowserCheckBundle[] = Object.values(projectBundle.data.check) + .map(({ bundle }) => bundle) + .filter((bundle): bundle is BrowserCheckBundle => bundle instanceof BrowserCheckBundle) + + // Uploading is what produces the storage keys a deploy needs, and it is + // deferred until the plan has been accepted: a preview describes the code + // bundle and every snapshot by content hash, so finding out what a deploy + // would change costs no uploads. Idempotent because the fallback path for + // an API without the preview endpoint has to upload earlier — its diff + // comes from a dry-run deploy, which requires the keys. + let uploaded = false + const uploadArtifacts = async () => { + if (uploaded) { + return } - } - - const bundledChecksByType = { - browser: [] as string[], - } - - for (const [logicalId, { bundle }] of Object.entries(projectBundle.data.check)) { - if (bundle instanceof BrowserCheckBundle) { - bundledChecksByType.browser.push(logicalId) + uploaded = true + + // The remote code bundle is only consumed by Playwright check suites (via + // bundler.marker). If nothing registered files to bundle (e.g. a project of + // only uptime monitors), there is nothing to upload — skip the store() to + // avoid an unnecessary code-bundle upload. + if (!bundler.isEmpty) { + this.style.actionStart('Uploading Playwright tests') + try { + const storedArchive = await archive.store() + bundler.updateMarker(storedArchive.key) + this.style.actionSuccess() + } catch (err) { + this.style.actionFailure() + throw err + } } - } - if (!preview && bundledChecksByType.browser.length) { - this.style.actionStart('Uploading Playwright snapshots') - try { - for (const logicalId of bundledChecksByType.browser) { - const bundle = projectBundle.data.check[logicalId].bundle as BrowserCheckBundle - bundle.snapshots = await uploadSnapshots(bundle.rawSnapshots) + if (browserBundles.length) { + this.style.actionStart('Uploading Playwright snapshots') + try { + for (const bundle of browserBundles) { + bundle.snapshots = await uploadSnapshots(bundle.rawSnapshots) + } + this.style.actionSuccess() + } catch (err) { + this.style.actionFailure() + throw err } - this.style.actionSuccess() - } catch (err) { - this.style.actionFailure() - throw err } } - const projectPayload = projectBundle.synthesize({ repoRoot }) + // Synthesized on demand rather than once: the snapshot entries are copied + // into the payload as values, so the payload sent after the upload has to + // be built after it to carry the keys. + const synthesize = (): ProjectSync => ({ ...projectBundle.synthesize({ repoRoot }), repoInfo }) + + const projectPayload = synthesize() if (!projectPayload.resources.length) { if (preview) { this.log('\nNo checks were detected. More information on how to set up a Checkly CLI project is available at https://checklyhq.com/docs/cli/.\n') @@ -272,92 +303,266 @@ 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 }> = [] + const summaryOptions = { prettyTypes: PRETTY_RESOURCE_TYPES, foldedTypes: NON_REPORTED_TYPES } + const classification = { + readOnly: Deploy.readOnly, + destructive: Deploy.destructive, + idempotent: Deploy.idempotent, + } + // What the deploy does whatever it finds, worded as the confirmation + // prompt words it. The plan's own lines follow these. + const optionLines = [ + `Deploy project "${checklyConfig.projectName}" to account "${account.name}"`, + scheduleOnDeploy + ? 'Schedule checks after deploy' + : 'Checks will NOT be scheduled after deploy', + preserveResources + ? 'Keep any resources removed from code (and their run history) in your Checkly account, where you can manage them from the Checkly web app' + : 'Delete any resources removed from code, losing their run history. Pass --preserve-resources to keep them in your Checkly account instead', + ...pruneRelations + ? ['Delete the alert channel subscriptions and private location assignments on this project\'s checks ' + + 'and groups that the project does not manage'] + : [], + ] + + // Ask Checkly what this payload would change. Writes nothing, and needs no + // upload: the payload describes the code bundle and every snapshot by + // content hash. + // `full` buys each changed resource's current state, which is what the + // rendered construct diff (`--preview`, `--output`) and the machine-readable + // envelope (`--dry-run`, the `confirmation_required` an agent or CI run + // prints) show. A plain interactive deploy lists resources, not + // properties, so it does not pay for state it would not print. + const detail = dryRun || preview || output || (!force && detectCliMode() !== 'interactive') ? 'full' : 'changes' + + let plan: ProjectPreviewResponse | undefined + // Set when the API has no preview endpoint, which also means it rejects the + // payload fields that arrived with it. + let previewNotSupported = false + this.style.actionStart('Checking what would change') + try { + plan = await api.projects.preview(projectPayload, { + detail, + preserveResources, + pruneRelations, + onStatus: message => this.style.actionStatus(message), + }) + this.style.actionSuccess() + } catch (err: any) { + this.style.actionFailure() + previewNotSupported = err instanceof ProjectPreviewNotSupportedError + const previewSupported = !previewNotSupported + + // --prune-relations deletes data, and without a plan nothing can say + // what: an API that predates the preview endpoint would not prune at all, + // and an API that would prune cannot be asked what it is about to delete. + // Both are refused rather than silently downgraded. + if (pruneRelations) { + this.style.longError( + previewSupported + ? 'Could not check which relations --prune-relations would delete, so nothing was deployed.' + : 'This Checkly API cannot prune relations yet.', + previewSupported ? 'Try again in a moment.' : 'Re-run without --prune-relations.', + ) + this.exit(1) + } + + // A deploy pinned to a plan cannot proceed without knowing the plan. + if (requestedPlanToken !== undefined) { + this.style.longError( + 'Could not check the plan this deploy is pinned to.', + previewSupported + ? err.message + : 'This Checkly API does not support deploy previews; re-run without --plan-token.', + ) + this.exit(1) + } + + // Deploying without a reviewed plan beats not deploying at all: one + // resource Checkly cannot read, or an API that is a version behind, must + // not make a project undeployable. The run falls back to the coarser + // dry-run diff and its delete guard, and sends no plan token. + this.style.longWarning( + previewSupported + // Say which failure it was: the user is about to get a coarser answer + // than they asked for and deserves to know why. + ? `Could not check what this deploy would change: ${err.message}` + // A 404 from this path means the endpoint is not there; whether that + // is an API predating it or something else in the way, the CLI cannot + // tell, so it says what it observed. + : 'This Checkly API answered 404 for the deploy preview endpoint.', + 'Falling back to a summary of created, updated and deleted resources.', + ) + } + + if (plan !== undefined && requestedPlanToken !== undefined && requestedPlanToken !== plan.planToken) { + this.style.longError( + 'Your Checkly account no longer matches the plan this deploy is pinned to, so nothing was deployed.', + 'Re-run `checkly deploy --preview` to see the current plan.', + ) + this.exit(1) + } + + // The payload goes out in the form the deploy route accepted before the + // preview endpoint existed in exactly the two cases where the full one + // cannot be sent: an API that does not have the endpoint rejects the fields + // that arrived with it, and a run that skipped the uploads describes + // snapshots it has no storage key for, which every write route requires. + // + // Not for every missing plan: a preview that failed transiently against a + // current API leaves the fields perfectly acceptable, and stripping them + // would blank the stored content hashes — making the NEXT deploy report + // every Playwright suite and every snapshot-bearing check as changed. + const deployPayload = (): ProjectSync => + previewNotSupported || !uploaded ? stripUnsupportedDeployFields(synthesize()) : synthesize() + + // Without a plan, deletions are only visible in a dry-run deploy — which + // validates the storage keys, so the uploads have to happen first. + let fallbackDiff: ProjectDeployResponse | undefined + if (plan === undefined && (preview || dryRun || (!preserveResources && !force))) { + // A run that only reports needs no uploads: the dry run validates the + // payload it is given, and a code bundle it has not been handed a key for + // is described by its path on disk, as it was before the preview endpoint + // existed. + if (!preview && !dryRun) { + await uploadArtifacts() + } this.style.actionStart('Verifying deployed state') try { - const { data: dryRunData } = await api.projects.deploy( - { ...projectPayload, repoInfo }, - { dryRun: true, scheduleOnDeploy, preserveResources }, - ) - deletions = this.collectDeletions(dryRunData) + const { data } = await api.projects.deploy(deployPayload(), { + dryRun: true, + scheduleOnDeploy, + preserveResources, + }) + fallbackDiff = data this.style.actionSuccess() } catch (err: any) { this.style.actionFailure() this.style.longError(`Your project could not be deployed.`, err) this.exit(1) } + } - if (deletions.length) { - this.log(chalk.bold.red('The 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 keep them (and their run history) in your Checkly account 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, - flagMetadata: metadata.flags, - classification: { - readOnly: Deploy.readOnly, - destructive: Deploy.destructive, - idempotent: Deploy.idempotent, - }, - }, { force }) + if (preview && !dryRun) { + this.log(this.formatPreview( + { diff: plan?.diff ?? fallbackDiff?.diff ?? [] }, + project, + verbose, + pruneRelations, + plan !== undefined ? { plan: plan.diff, local: projectPayload.resources } : undefined, + )) + if (plan !== undefined) { + this.log(`Plan token: ${plan.planToken}`) + this.log(chalk.grey( + `Deploy this exact plan with \`checkly deploy --plan-token ${plan.planToken}\`.\n`, + )) } + return } + // With a plan, every touched resource has a line; without one, only the + // deletions the dry run found are known. + const planLines = plan !== undefined + ? planChangeLines(plan.diff, summaryOptions) + : this.collectDeletions(fallbackDiff?.diff ?? []) + .map(({ resourceType, logicalId }) => + `Permanently delete ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}, ` + + 'losing its run history') + + // The one confirmation of the command: the plan is known by now, so the + // prompt, the agent envelope and --dry-run all describe the deploy that is + // about to run rather than a deploy nobody has seen. + await this.confirmOrAbort({ + command: 'deploy', + description: 'Deploy project to Checkly', + changes: [...optionLines, ...planLines], + // The token rides along in the echoed command, so the confirming run + // deploys the plan that was shown here and refuses a different one. + flags: plan !== undefined ? { ...flags, 'plan-token': plan.planToken } : flags, + flagMetadata: metadata.flags, + classification, + ...plan !== undefined + ? { preview: { planToken: plan.planToken, diff: reducePlanForAgent(plan.diff) } } + : {}, + }, { force, dryRun }) + + await uploadArtifacts() + + const runDeploy = () => api.projects.deploy( + deployPayload(), + { + scheduleOnDeploy, + preserveResources, + pruneRelations, + planToken: plan?.planToken, + cancelInProgress, + onProgress: progress => this.style.actionStatus(`${progress}% complete`), + onStatus: message => this.style.actionStatus(message), + }, + ) + try { - if (!preview) { - this.style.actionStart('Deploying project') - } - const { data } = await api.projects.deploy( - { ...projectPayload, repoInfo }, - { - dryRun: preview, - scheduleOnDeploy, - preserveResources, - cancelInProgress, - onProgress: preview ? undefined : progress => this.style.actionStatus(`${progress}% complete`), - onStatus: preview ? undefined : message => this.style.actionStatus(message), - }, - ) - if (!preview) { - this.style.actionSuccess() + this.style.actionStart('Deploying project') + let data: ProjectDeployResponse + try { + ({ data } = await runDeploy()) + } catch (err) { + // A run that showed nobody a plan has nothing to protect: rather than + // failing a pipeline because someone touched the account while the code + // bundle was uploading, it plans again and deploys that. A pinned run, + // or one a person confirmed, is refused instead — see the catch below. + if (!(err instanceof ProjectPlanStaleError) || !force || requestedPlanToken !== undefined) { + throw err + } + this.style.actionStatus('Your Checkly account changed; checking again and deploying the current plan') + plan = await api.projects.preview(deployPayload(), { detail, preserveResources, pruneRelations }) + ;({ data } = await runDeploy()) } - if (preview || output) { - this.log(this.formatPreview(data, project, verbose)) - } - if (!preview) { - await setTimeout(500) - this.log(`Successfully deployed project "${project.name}" to account "${account.name}".`) - - // Print the ping URL for heartbeat checks. - const heartbeatLogicalIds = project.getHeartbeatLogicalIds() - const heartbeatCheckIds = data.diff.filter(check => heartbeatLogicalIds.includes(check.logicalId)) - .map(check => check?.physicalId) - - heartbeatCheckIds.forEach(async id => { - const { data: { pingUrl, name } } = await api.heartbeatCheck.get(id as string) - this.log(`Ping URL of heartbeat check ${chalk.green(name)} is ${chalk.italic.underline.blue(pingUrl)}.`) - }) + this.style.actionSuccess() + if (output) { + // The deploy response names every resource with its id; the plan the + // deploy was confirmed against is where each one's deployed state is. + this.log(this.formatPreview( + data, + project, + verbose, + pruneRelations, + plan !== undefined ? { plan: plan.diff, local: projectPayload.resources } : undefined, + )) } + await setTimeout(500) + this.log(`Successfully deployed project "${project.name}" to account "${account.name}".`) + + // Print the ping URL for heartbeat checks. + const heartbeatLogicalIds = project.getHeartbeatLogicalIds() + const heartbeatCheckIds = data.diff.filter(check => heartbeatLogicalIds.includes(check.logicalId)) + .map(check => check?.physicalId) + + heartbeatCheckIds.forEach(async id => { + const { data: { pingUrl, name } } = await api.heartbeatCheck.get(id as string) + this.log(`Ping URL of heartbeat check ${chalk.green(name)} is ${chalk.italic.underline.blue(pingUrl)}.`) + }) } catch (err: any) { - if (!preview) { - this.style.actionFailure() - } - if (err instanceof ProjectDeployCancelledError) { + this.style.actionFailure() + if (err instanceof ProjectPlanStaleError) { + // Nothing was written. The way out is another run, which previews + // afresh: this run's token describes a state Checkly has left behind, + // so sending it again would be refused again. + if (err.diff.length) { + this.log(this.formatPreview({ diff: err.diff }, project, verbose, pruneRelations)) + this.style.longError( + 'Your Checkly account changed while this deploy was being confirmed, so nothing was deployed.', + 'The plan above is the current one. Re-run `checkly deploy` to review and deploy it.', + ) + } else { + // A refusal with no plan attached: the account moved for a reason the + // error itself explains, and there is nothing to print above. + this.style.longError( + `${err.message} Nothing was deployed.`, + 'Re-run `checkly deploy` to see the current plan and deploy it.', + ) + } + } else if (err instanceof ProjectDeployCancelledError) { this.style.longError('Your deployment was cancelled.', err.message) } else if (err instanceof ConflictError) { // deploy() waits-and-retries behind an in-progress deployment, so a 409 @@ -374,10 +579,13 @@ export default class Deploy extends AuthCommand { } } - private collectDeletions (previewData: ProjectDeployResponse): Array<{ resourceType: string, logicalId: string }> { - return (previewData?.diff ?? []) + private collectDeletions (diff: DiffEntry[]): Array<{ resourceType: string, logicalId: string }> { + return diff .filter(change => change.action === ResourceDeployStatus.DELETE + // A resource the project no longer declares, not a relation it never + // managed: pruning those is reported under its own heading. + && change.origin !== 'unmanaged' && !NON_REPORTED_TYPES.some(t => t === change.type), ) .map(({ type, logicalId }) => ({ resourceType: type, logicalId })) @@ -387,9 +595,17 @@ export default class Deploy extends AuthCommand { } private formatPreview ( - previewData: ProjectDeployResponse, + previewData: { diff: DiffEntry[] }, project: Project, verbose = false, + /** Whether this deploy deletes the relations it does not manage. */ + pruneRelations = false, + /** + * The preview plan with each changed resource's deployed state, and the + * local payload it was computed for: with these, every updated resource + * prints the diff of its construct as deployed against as in code. + */ + rendering?: { plan: DiffEntry[], local: ResourceSync[] }, ): 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: [] @@ -398,28 +614,59 @@ export default class Deploy extends AuthCommand { const creating = [] const deleting: Array<{ resourceType: string, logicalId: string }> = [] const detaching: Array<{ resourceType: string, logicalId: string }> = [] + const pruning: Array<{ resourceType: string, logicalId: string }> = [] + const unmanaged: Array<{ resourceType: string, logicalId: string }> = [] + let unchanged = 0 for (const change of previewData?.diff ?? []) { - const { type, logicalId, physicalId, action } = change - if ([ - AlertChannelSubscription.__checklyType, - PrivateLocationCheckAssignment.__checklyType, - PrivateLocationGroupAssignment.__checklyType, - ].some(t => t === type)) { - // Don't report changes to alert channel subscriptions or private location assignments. - // Users don't create these directly, so it's more intuitive to consider it as part of the check. + const { type, logicalId, physicalId, action, changes } = change + if (NON_REPORTED_TYPES.some(t => t === type)) { + // A relation the project manages is reported as part of the check or + // group it belongs to, since users do not declare these directly. One + // the project does NOT manage is only ever reported when --prune-relations + // would delete it, and that is worth its own line. + if (isPrunedRelation(change)) { + pruning.push({ resourceType: type, logicalId }) + } + continue + } + // Relations the project does not manage are reported on their owning + // check or group whether or not they would be deleted. Without + // --prune-relations the deploy leaves them — and the resource — alone, so + // listing it as an update would name a write that never happens. + // Never an update: what the deploy deletes is the relation, not the + // check or group it hangs off. With --prune-relations the relation's own + // entry is already listed under Prune, so the resource needs no line of + // its own — and advising the flag the user just passed would be absurd. + if (onlyUnmanagedChanges(change)) { + if (!pruneRelations) { + unmanaged.push({ resourceType: type, logicalId }) + } continue } const construct = project.data[type as keyof ProjectData][logicalId] if (action === ResourceDeployStatus.UPDATE) { updating.push({ resourceType: type, logicalId, physicalId, construct }) + } else if (action === ResourceDeployStatus.UNCHANGED) { + // A resource whose own properties agree with the account can still have + // changed alert channels or private locations, which are reported on it + // rather than as resources of their own; only an entry with nothing at + // all to report counts as unchanged. + if ((changes?.length ?? 0) > 0) { + updating.push({ resourceType: type, logicalId, physicalId, construct }) + } else { + unchanged++ + } } else if (action === ResourceDeployStatus.CREATE) { creating.push({ resourceType: type, logicalId, physicalId, construct }) } 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. + } else if ( + action === ResourceDeployStatus.DETACH + || action === ResourceDeployStatus.DETACHED + ) { + // Removed from code but kept in the account, so the construct is not in + // the project any more. detaching.push({ resourceType: type, logicalId }) } } @@ -465,8 +712,15 @@ export default class Deploy extends AuthCommand { const sortedDetaching = detaching .sort(compareEntries) + const sortedPruning = pruning + .sort(compareEntries) + + const sortedUnmanaged = unmanaged + .sort(compareEntries) + if (!sortedCreating.length && !sortedDeleting.length && !sortedDetaching.length - && !sortedUpdating.length && !skipping.length) { + && !sortedUpdating.length && !sortedPruning.length && !sortedUnmanaged.length + && !unchanged && !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' } @@ -499,9 +753,17 @@ export default class Deploy extends AuthCommand { } output.push('') } + if (sortedPruning.length) { + output.push(chalk.bold.red('Prune (relations not managed by this project):')) + for (const { resourceType, logicalId } of sortedPruning) { + output.push(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`) + } + output.push('') + } if (sortedUpdating.length) { - output.push(chalk.bold.magenta('Update and Unchanged:')) - for (const { logicalId, physicalId, construct } of sortedUpdating) { + output.push(chalk.bold.magenta('Update:')) + const ids = rendering !== undefined ? physicalIdsFromPlan(rendering.plan, rendering.local) : undefined + for (const { resourceType, logicalId, physicalId, construct } of sortedUpdating) { output.push(` ${construct.constructor.name}: ${logicalId}`) if (verbose && (construct as any).name) { output.push(` name: ${(construct as any).name}`) @@ -509,9 +771,43 @@ export default class Deploy extends AuthCommand { if (verbose && physicalId) { output.push(` id: ${physicalId}`) } + if (rendering === undefined || ids === undefined) { + continue + } + // The entry to render is the plan's, whether this listing is the plan + // itself or the deploy that carried it out. + const planned = rendering.plan.find(entry => entry.type === resourceType && entry.logicalId === logicalId) + if (planned === undefined || planned.before === undefined) { + continue + } + const lines = renderResourceDiff({ + entry: planned, + local: rendering.local.find(resource => resource.type === resourceType && resource.logicalId === logicalId), + localResources: rendering.local, + diff: rendering.plan, + project, + ids, + pruneRelations, + }) + for (const line of lines) { + output.push(` ${line}`) + } } output.push('') } + if (sortedUnmanaged.length) { + output.push(chalk.bold.yellow( + 'Has alert channels or private locations this project does not manage (pass --prune-relations to delete them):', + )) + for (const { resourceType, logicalId } of sortedUnmanaged) { + output.push(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`) + } + output.push('') + } + if (unchanged) { + output.push(chalk.bold.grey(`Unchanged: ${unchanged}`)) + output.push('') + } if (skipping.length) { output.push(chalk.bold.grey('Skip (testOnly):')) for (const { logicalId, construct } of skipping) { diff --git a/packages/cli/src/constructs/__tests__/deploy-payload-hashes.spec.ts b/packages/cli/src/constructs/__tests__/deploy-payload-hashes.spec.ts new file mode 100644 index 000000000..7400c1d44 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/deploy-payload-hashes.spec.ts @@ -0,0 +1,218 @@ +import { createHash } from 'node:crypto' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../rest/api', () => ({ + checklyStorage: { upload: vi.fn(), uploadCodeBundle: vi.fn() }, +})) + +import { checklyStorage } from '../../rest/api.js' +import { Bundler } from '../../services/check-parser/bundler.js' +import { detectSnapshots, uploadSnapshots } from '../../services/snapshot-service.js' +import { BrowserCheck } from '../browser-check.js' +import { BrowserCheckBundle } from '../browser-check-bundle.js' +import { PlaywrightCheck } from '../playwright-check.js' +import { PlaywrightCheckBundle } from '../playwright-check-bundle.js' +import { Project } from '../project.js' +import { Session } from '../session.js' + +/** + * The content hashes the deploy payload carries for everything it uploads: one + * per visual snapshot and one for the Playwright code bundle. Both are + * computable before anything is uploaded, which is what lets `checkly deploy` + * ask Checkly what a deploy would change without uploading first — so both + * have to be in the synthesized payload at that point, when the storage keys + * are not. + */ + +const sha256 = (content: string | Buffer) => createHash('sha256').update(content).digest('hex') + +describe('snapshot content hashes', () => { + let basePath: string + + beforeEach(async () => { + basePath = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'hashes-'))) + Session.reset() + Session.basePath = basePath + Session.project = new Project('project-id', { name: 'Test Project' }) + }) + + afterEach(async () => { + Session.reset() + await fs.rm(basePath, { recursive: true, force: true }) + }) + + async function writeSnapshot (name: string, content: string): Promise { + const dir = path.join(basePath, 'tests', 'home.spec.ts-snapshots') + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, name), content) + return content + } + + it('hashes each detected snapshot file', async () => { + const first = await writeSnapshot('home.png', 'first-image') + const second = await writeSnapshot('about.png', 'second-image') + + const snapshots = await detectSnapshots(basePath, path.join(basePath, 'tests', 'home.spec.ts')) + + expect(snapshots).toHaveLength(2) + expect(snapshots).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'tests/home.spec.ts-snapshots/home.png', sha256: sha256(first) }), + expect.objectContaining({ path: 'tests/home.spec.ts-snapshots/about.png', sha256: sha256(second) }), + ])) + }) + + it('reports a snapshot by hash before the upload and by hash and key after it', async () => { + const content = await writeSnapshot('home.png', 'an-image') + const rawSnapshots = await detectSnapshots(basePath, path.join(basePath, 'tests', 'home.spec.ts')) + const check = new BrowserCheck('browser-check', { + name: 'Browser check', + code: { content: 'console.log("x")' }, + }) + const bundle = new BrowserCheckBundle(check, { script: 'console.log("x")', rawSnapshots }) + + // What a preview sees: no storage key exists yet, and the hash is what + // describes the file. + expect(bundle.synthesize().snapshots).toEqual([ + { path: 'tests/home.spec.ts-snapshots/home.png', sha256: sha256(content) }, + ]) + + vi.mocked(checklyStorage.upload).mockResolvedValue({ data: { key: 'checks/home.png' } } as never) + bundle.snapshots = await uploadSnapshots(bundle.rawSnapshots) + + // What the deploy sends: the key the upload produced, and the same hash. + expect(bundle.synthesize().snapshots).toEqual([ + { path: 'tests/home.spec.ts-snapshots/home.png', key: 'checks/home.png', sha256: sha256(content) }, + ]) + }) + + it('leaves an inline-script check without snapshots', () => { + const check = new BrowserCheck('browser-check', { + name: 'Browser check', + code: { content: 'console.log("x")' }, + }) + const bundle = new BrowserCheckBundle(check, { script: 'console.log("x")' }) + + expect(bundle.synthesize().snapshots).toBeUndefined() + }) +}) + +describe('code bundle content hash', () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'bundle-'))) + Session.reset() + Session.project = new Project('project-id', { name: 'Test Project' }) + }) + + afterEach(async () => { + Session.reset() + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + function playwrightBundle (bundler: Bundler) { + // The construct resolves its config path against the file that declares + // it, as it does when a check file is loaded. + Session.checkFileAbsolutePath = path.join(tempDir, 'suite.check.ts') + const check = new PlaywrightCheck('suite', { + name: 'Suite', + playwrightConfigPath: 'playwright.config.ts', + logicalId: 'suite', + }) + return new PlaywrightCheckBundle(check, { + codeBundlePath: bundler.marker, + codeBundleSha256: bundler.codeBundleSha256, + testCommand: 'npx playwright test', + }) + } + + it('carries the archive\'s hash once the bundle has been finalized', async () => { + const sourceFile = path.join(tempDir, 'spec.ts') + await fs.writeFile(sourceFile, 'export const x = 1') + + const bundler = await Bundler.create({ cacheHash: 'cache-hash', stripPrefix: tempDir }) + bundler.registerFiles({ filePath: sourceFile, physical: true }) + const bundle = playwrightBundle(bundler) + + // The archive does not exist yet, so there is no hash to claim: the key is + // simply absent from the payload rather than present and wrong. + expect(JSON.parse(JSON.stringify(bundle.synthesize()))).not.toHaveProperty('codeBundleSha256') + + const archive = await bundler.finalize() + // What `checkly deploy` does between finalizing and previewing: the + // payload points at the archive on disk, since nothing is uploaded yet. + bundler.updateMarker(archive.archiveFile) + + const payload = JSON.parse(JSON.stringify(bundle.synthesize())) + expect(payload.codeBundleSha256).toBe(sha256(await fs.readFile(archive.archiveFile))) + expect(payload.codeBundleSha256).toBe(archive.sha256) + expect(payload.codeBundlePath).toBe(archive.archiveFile) + }) + + it('hashes the same content to the same value whatever the timestamps are', async () => { + // The hash has to describe the content alone: a file's mtime (a CI pipeline + // clones fresh every run, so every mtime is the checkout time) and the + // clock a generated entry would otherwise be stamped with must not reach + // it, or every deploy reports every Playwright suite as changed. + // Only the clock is faked, so an entry archiver would timestamp itself gets + // a different one per iteration; the archive's own streams keep real timers. + vi.useFakeTimers({ toFake: ['Date'] }) + const hashes: string[] = [] + for (const [attempt, mtime] of [['first', new Date('2026-01-01T00:00:00Z')], + ['second', new Date('2026-06-15T12:34:56Z')]] as const) { + vi.setSystemTime(mtime) + const dir = await fs.mkdtemp(path.join(tempDir, `${attempt}-`)) + const sourceFile = path.join(dir, 'spec.ts') + await fs.writeFile(sourceFile, 'export const x = 1') + await fs.utimes(sourceFile, mtime, mtime) + const bundler = await Bundler.create({ cacheHash: 'cache-hash', stripPrefix: dir }) + bundler.registerFiles( + { filePath: sourceFile, physical: true }, + { filePath: path.join(dir, 'package.json'), physical: false, content: '{"name":"generated"}' }, + // A workspace bundle is a symlink farm, and those entries carry a + // timestamp of their own. + { filePath: path.join(dir, 'node_modules', 'pkg'), physical: true, symlinkTarget: '../packages/pkg' }, + ) + hashes.push((await bundler.finalize()).sha256) + } + vi.useRealTimers() + + expect(hashes[0]).toBe(hashes[1]) + }) + + it('sends the archive\'s hash with the upload, so the stored object can be matched to it', async () => { + const sourceFile = path.join(tempDir, 'spec.ts') + await fs.writeFile(sourceFile, 'export const x = 1') + const bundler = await Bundler.create({ cacheHash: 'cache-hash', stripPrefix: tempDir }) + bundler.registerFiles({ filePath: sourceFile, physical: true }) + const archive = await bundler.finalize() + vi.mocked(checklyStorage.uploadCodeBundle).mockResolvedValue({ data: { key: 'bundles/x.tar.gz' } } as never) + + await archive.store() + + expect(checklyStorage.uploadCodeBundle).toHaveBeenCalledWith( + expect.anything(), + expect.any(Number), + archive.sha256, + ) + }) + + it('changes when the bundled content changes', async () => { + const hashes: string[] = [] + for (const content of ['export const x = 1', 'export const x = 2']) { + const dir = await fs.mkdtemp(path.join(tempDir, 'case-')) + const sourceFile = path.join(dir, 'spec.ts') + await fs.writeFile(sourceFile, content) + const bundler = await Bundler.create({ cacheHash: 'cache-hash', stripPrefix: dir }) + bundler.registerFiles({ filePath: sourceFile, physical: true }) + const archive = await bundler.finalize() + hashes.push(archive.sha256) + } + + expect(hashes[0]).not.toBe(hashes[1]) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/render-construct.spec.ts b/packages/cli/src/constructs/__tests__/render-construct.spec.ts new file mode 100644 index 000000000..59b279957 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/render-construct.spec.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest' + +import { ConstructCodegen } from '../construct-codegen.js' +import { Codegen, Context, ConstructRenderError, renderConstruct } from '../internal/codegen/index.js' +import { Program, expr, ident, lineComment } from '../../sourcegen/index.js' + +/** + * A program and a codegen per render, which is what a caller comparing two + * versions of a resource has to do: a context hands out `my-check-2` the + * second time it is asked for a path, and that path reaches the construct. + */ +const side = () => { + const program = new Program({ + rootDirectory: '.', + constructFileSuffix: '.check', + specFileSuffix: '.spec', + language: 'typescript', + }) + return { program, codegen: new ConstructCodegen(program), context: new Context() } +} + +const apiCheck = (name: string) => ({ + type: 'check' as const, + logicalId: 'my-check', + payload: { + id: 'my-check', + checkType: 'API', + name, + activated: true, + locations: ['eu-west-1'], + request: { + url: 'https://api.example.com/health', + method: 'GET', + followRedirects: true, + skipSSL: false, + assertions: [], + }, + }, +}) + +describe('renderConstruct()', () => { + it('renders a construct with no file or import scaffolding', () => { + const { codegen, context } = side() + + const source = renderConstruct(codegen, 'my-check', apiCheck('Health'), { context }) + + expect(source).toContain('new ApiCheck(') + expect(source).toContain(`name: 'Health'`) + // The imports and the generated-file header belong to a file on disk, not + // to the construct, and an import path invented in memory is noise. + expect(source).not.toContain('import') + expect(source.startsWith('\n')).toBe(false) + }) + + it('renders the same resource identically twice', () => { + const first = renderConstruct(side().codegen, 'my-check', apiCheck('Health')) + const second = renderConstruct(side().codegen, 'my-check', apiCheck('Health')) + + expect(second).toEqual(first) + }) + + it('differs only where the resource differs', () => { + const before = renderConstruct(side().codegen, 'my-check', apiCheck('Health')) + const after = renderConstruct(side().codegen, 'my-check', apiCheck('Liveness')) + + expect(before).not.toEqual(after) + expect(before.replace(`'Health'`, `'Liveness'`)).toEqual(after) + }) + + it('resolves a reference to the variable the context names', () => { + const { program, codegen, context } = side() + // A referenced group is registered against a support file, so it is not + // mistaken for the construct being rendered. + context.registerCheckGroup(42, 'Website Group', program.generatedSupportFile('groups')) + + const source = renderConstruct(codegen, 'my-check', { + ...apiCheck('Health'), + payload: { ...apiCheck('Health').payload, groupId: 42 }, + }, { context }) + + expect(source).toContain('group: websiteGroup') + }) + + it('falls back to fromId for a reference the context does not know', () => { + const { codegen, context } = side() + + const source = renderConstruct(codegen, 'my-check', { + ...apiCheck('Health'), + payload: { ...apiCheck('Health').payload, groupId: 42 }, + }, { context }) + + expect(source).toContain('CheckGroupV2.fromId(42)') + }) + + it('throws ConstructRenderError when the codegen writes no construct file', () => { + const { program, codegen, context } = side() + // Subscriptions are folded into their parent and generate nothing of + // their own, so there is no construct to return. + expect(() => renderConstruct(codegen, 'sub', { + type: 'alert-channel-subscription' as const, + logicalId: 'sub', + payload: { alertChannelId: 1, checkId: 'my-check' }, + }, { context })).toThrow(ConstructRenderError) + expect(program.generatedConstructFiles).toHaveLength(0) + }) + + it('leaves a check script out, since the codegen writes it to its own file', () => { + // The contract a diffing caller depends on: a script change is invisible + // here, so equal renders mean "nothing to show", not "nothing changed". + const browserCheck = (script: string) => ({ + type: 'check' as const, + logicalId: 'login', + payload: { id: 'login', checkType: 'BROWSER', name: 'Login', script }, + }) + + const before = renderConstruct(side().codegen, 'login', browserCheck('await page.goto("/a")')) + const after = renderConstruct(side().codegen, 'login', browserCheck('await page.goto("/b")')) + + expect(before).toEqual(after) + expect(before).toContain('entrypoint') + expect(before).not.toContain('page.goto') + }) + + it('suppresses the generated-file header as well as the imports', () => { + // The default program has no construct headers, so the header half of the + // scaffolding switch needs a program that asks for one. + const program = new Program({ + rootDirectory: '.', + constructFileSuffix: '.check', + specFileSuffix: '.spec', + language: 'typescript', + constructHeaders: [lineComment('Generated by checkly import')], + }) + + const source = renderConstruct(new ConstructCodegen(program), 'my-check', apiCheck('Health')) + + expect(source).not.toContain('Generated by checkly import') + expect(source.startsWith('new ApiCheck(')).toBe(true) + }) + + it('throws ConstructRenderError when the codegen writes more than one construct file', () => { + const { program } = side() + class TwoFileCodegen extends Codegen<{ name: string }> { + describe = () => 'two files' + gencode (logicalId: string) { + for (const suffix of ['a', 'b']) { + this.program + .generatedConstructFile(`resources/${logicalId}-${suffix}`) + .section(expr(ident('Thing'), builder => builder.new(builder => builder.string(logicalId)))) + } + } + } + + expect(() => renderConstruct(new TwoFileCodegen(program), 'thing', { name: 'Thing' })) + .toThrow(/produced 2 construct files/) + }) + + it('propagates the codegen error for an unsupported resource type', () => { + const { codegen } = side() + + expect(() => renderConstruct(codegen, 'suite', { + type: 'check' as const, + logicalId: 'suite', + payload: { id: 'suite', checkType: 'PLAYWRIGHT', name: 'Suite' }, + })).toThrow(/unsupported check type 'PLAYWRIGHT'/) + }) +}) + +describe('Program.generatedConstructFiles', () => { + it('lists construct files and leaves support and static files out', () => { + const { program } = side() + + const construct = program.generatedConstructFile('resources/api-checks/health') + program.generatedSupportFile('resources/api-checks/setup-script') + program.staticSpecFile('resources/browser-checks/login', 'test()') + + expect(program.generatedConstructFiles).toEqual([construct]) + }) + + it('does not list the same construct file twice', () => { + const { program } = side() + + program.generatedConstructFile('resources/api-checks/health') + program.generatedConstructFile('resources/api-checks/health') + + expect(program.generatedConstructFiles).toHaveLength(1) + }) +}) diff --git a/packages/cli/src/constructs/browser-check-bundle.ts b/packages/cli/src/constructs/browser-check-bundle.ts index c27ae6d7b..8114652a0 100644 --- a/packages/cli/src/constructs/browser-check-bundle.ts +++ b/packages/cli/src/constructs/browser-check-bundle.ts @@ -1,4 +1,4 @@ -import { Snapshot } from '../services/snapshot-service.js' +import { RawSnapshot, Snapshot } from '../services/snapshot-service.js' import { BrowserCheck } from './browser-check.js' import { Bundle } from './construct.js' import { SharedFileRef } from './session.js' @@ -7,7 +7,7 @@ export interface BrowserCheckBundleProps { script: string scriptPath?: string dependencies?: SharedFileRef[] - rawSnapshots?: { absolutePath: string, path: string }[] + rawSnapshots?: RawSnapshot[] } export class BrowserCheckBundle implements Bundle { @@ -17,7 +17,7 @@ export class BrowserCheckBundle implements Bundle { dependencies?: SharedFileRef[] // For snapshots, we first store `rawSnapshots` with the path to the file. // The `snapshots` field is set later (with a `key`) after these are uploaded to storage. - rawSnapshots?: { absolutePath: string, path: string }[] + rawSnapshots?: RawSnapshot[] snapshots?: Snapshot[] constructor (browserCheck: BrowserCheck, props: BrowserCheckBundleProps) { @@ -34,7 +34,11 @@ export class BrowserCheckBundle implements Bundle { script: this.script, scriptPath: this.scriptPath, dependencies: this.dependencies, - snapshots: this.snapshots, + // Until the upload has run there is no storage key, but the content hash + // is already known, and that is what a deploy preview compares. Checkly + // requires the key on every route that writes or runs a check, so a + // key-less entry can only ever reach the preview. + snapshots: this.snapshots ?? this.rawSnapshots?.map(({ path, sha256 }) => ({ path, sha256 })), } } } diff --git a/packages/cli/src/constructs/browser-check.ts b/packages/cli/src/constructs/browser-check.ts index 9f5ac6b6a..980b41e54 100644 --- a/packages/cli/src/constructs/browser-check.ts +++ b/packages/cli/src/constructs/browser-check.ts @@ -186,7 +186,7 @@ export class BrowserCheck extends RepairableRuntimeCheck { script: parsed.entrypoint.content, scriptPath: Session.relativePosixPath(parsed.entrypoint.filePath), dependencies: deps, - snapshots: detectSnapshots(Session.basePath!, parsed.entrypoint.filePath), + snapshots: await detectSnapshots(Session.basePath!, parsed.entrypoint.filePath), } } diff --git a/packages/cli/src/constructs/construct-codegen.ts b/packages/cli/src/constructs/construct-codegen.ts index 13a65e16f..28ac7c696 100644 --- a/packages/cli/src/constructs/construct-codegen.ts +++ b/packages/cli/src/constructs/construct-codegen.ts @@ -30,7 +30,7 @@ export type ResourceType = | 'status-page-component' | 'status-page-automation-rule' -interface Resource { +export interface Resource { type: ResourceType logicalId: string payload: any diff --git a/packages/cli/src/constructs/internal/codegen/context.ts b/packages/cli/src/constructs/internal/codegen/context.ts index 16f773169..c34f9c9d0 100644 --- a/packages/cli/src/constructs/internal/codegen/context.ts +++ b/packages/cli/src/constructs/internal/codegen/context.ts @@ -119,7 +119,32 @@ function formatVariable (base: string, name: string): string { return prefix + suffix } +/** + * What a masked value prints as: never an empty string, which could pass for + * a value. Only a string the preview itself wrote (`ContextOptions.maskedValues`) + * is ever printed back for a secret; a value that merely looks masked is not. + */ +export const MASKED_VALUE = '********' + +export interface ContextOptions { + /** + * Set by the deploy preview's renderer, never by an import: the exact + * strings the preview wrote over masked values. A `secret: true` variable + * then prints such a value as a string literal beside `secret: true`, + * instead of a generated `secret()` reference, so the two sides of a + * preview can differ where the secret moved. Any other value prints as the + * plain mask, never as itself. + */ + maskedValues?: ReadonlySet +} + export class Context { + readonly maskedValues: ReadonlySet | undefined + + constructor (options: ContextOptions = {}) { + this.maskedValues = options.maskedValues + } + #alertChannelVariablesByPhysicalId = new Map() #alertChannelFriendVariablesByPhysicalId = new Map() diff --git a/packages/cli/src/constructs/internal/codegen/index.ts b/packages/cli/src/constructs/internal/codegen/index.ts index 4200d6499..1717f6745 100644 --- a/packages/cli/src/constructs/internal/codegen/index.ts +++ b/packages/cli/src/constructs/internal/codegen/index.ts @@ -1,4 +1,6 @@ export { Codegen } from './codegen.js' -export { Context, MissingContextVariableMappingError } from './context.js' +export { Context, MASKED_VALUE, type ContextOptions, MissingContextVariableMappingError } from './context.js' +export { ConstructRenderError, renderConstruct } from './render.js' +export type { RenderConstructOptions } from './render.js' export { ImportSafetyViolation } from './safety.js' export { validateScript } from './snippet.js' diff --git a/packages/cli/src/constructs/internal/codegen/render.ts b/packages/cli/src/constructs/internal/codegen/render.ts new file mode 100644 index 000000000..8a00ae401 --- /dev/null +++ b/packages/cli/src/constructs/internal/codegen/render.ts @@ -0,0 +1,108 @@ +import { Output } from '../../../sourcegen/index.js' +import { Codegen } from './codegen.js' +import { Context } from './context.js' + +/** + * A construct could not be rendered on its own. Callers that render for a + * reader — the diff `checkly deploy --preview` prints — treat this as "show + * the coarser listing instead", never as a failure of the command. + */ +export class ConstructRenderError extends Error { + constructor (message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'ConstructRenderError' + } +} + +export interface RenderConstructOptions { + /** + * A context to generate against, normally one the caller has already + * registered variables into so that references render as the names the + * project gives them rather than as `fromId(...)` calls. + * + * Registering a variable needs a file to import it from; use + * `Program.generatedSupportFile()` for those, so the placeholder does not + * look like the construct file this function is looking for. + * + * **One context and one program per render, never shared.** A context + * accumulates state: `Context.filePath()` hands out `my-check-2` the second + * time it is asked for a path, and that path is rendered into browser and + * API checks as their entrypoint, so a context reused across resources makes + * a resource's rendered code depend on which resources preceded it. The + * `register*` methods also overwrite silently, so a second render can + * repoint a locator the caller set up for the first. + */ + context?: Context +} + +/** + * Renders one construct to a string, without writing anything to disk and + * without the file scaffolding around it: no generated-file header and no + * import list, just the construct as it would read in a check file. + * + * **What a construct does not contain.** A codegen writes script and snippet + * bodies to files of their own — a browser or multi-step check's script to a + * spec file, an API check's setup and teardown scripts to support files — and + * leaves only an `entrypoint` path behind in the construct. Those files are + * not part of what this function returns, so two resources differing only in a + * check's script render to the same string. A caller diffing two sides has to + * treat "the renders are equal" as "nothing I can show here", not as "nothing + * changed", and report such a change another way. + * + * The codegen generates into the program it was constructed with, so a caller + * comparing two versions of a resource builds a program and a codegen per + * side. A program is single-use either way: a render that throws part-way has + * already registered its construct file, and a retry against the same program + * would find that file unchanged and report it as having generated nothing. + * + * @param logicalId Names the construct where the codegen uses it, and labels + * the errors raised here. `ConstructCodegen` is not one of those codegens: it + * forwards `resource.logicalId` to the per-type codegen, so with a construct + * envelope this argument only labels errors, and the name in the rendered code + * comes from the envelope. Two sides that must compare equal therefore have to + * agree on `resource.logicalId`, not just on this argument. + * + * @throws ConstructRenderError if the codegen produced no construct file, or + * more than one, since neither leaves anything unambiguous to return. + * + * Errors from the codegens themselves are deliberately left as they are, the + * way `commands/import/plan.ts` takes them: a resource type they do not cover + * (a Playwright check suite) throws a plain `Error`, and a script they cannot + * parse throws `UnsupportedScriptError`. **So a caller rendering for a reader + * catches `Error`, not only `ConstructRenderError`**, and treats any of them + * as "show the coarser listing for this resource". + */ +export function renderConstruct ( + codegen: Codegen, + logicalId: string, + resource: T, + options: RenderConstructOptions = {}, +): string { + const { program } = codegen + const context = options.context ?? new Context() + + // Both phases are watched, not just `gencode`: several codegens create and + // register their construct file in `prepare` and then generate into the file + // the registration returns. + const existing = new Set(program.generatedConstructFiles) + codegen.prepare(logicalId, resource, context) + codegen.gencode(logicalId, resource, context) + const added = program.generatedConstructFiles.filter(file => !existing.has(file)) + + if (added.length !== 1) { + throw new ConstructRenderError( + `Rendering '${logicalId}' produced ${added.length} construct files; expected exactly one.`, + ) + } + + const output = new Output() + try { + added[0].render(output, { scaffolding: false }) + } catch (cause) { + throw new ConstructRenderError(`Failed to render '${logicalId}': ${cause}`, { cause }) + } + + // `GeneratedFile.render` separates sections with a blank line, which leaves + // one at the top once the headers and imports above them are gone. + return output.finalize().replace(/^\n+/, '') +} diff --git a/packages/cli/src/constructs/key-value-pair-codegen.ts b/packages/cli/src/constructs/key-value-pair-codegen.ts index 590af81bc..13760ffc0 100644 --- a/packages/cli/src/constructs/key-value-pair-codegen.ts +++ b/packages/cli/src/constructs/key-value-pair-codegen.ts @@ -1,5 +1,5 @@ import { decl, expr, GeneratedFile, ident, object, Program, Value } from '../sourcegen/index.js' -import { Context } from './internal/codegen/index.js' +import { Context, MASKED_VALUE } from './internal/codegen/index.js' import KeyValuePair from './key-value-pair.js' export function valueForKeyValuePair ( @@ -13,13 +13,20 @@ export function valueForKeyValuePair ( if (kv.secret !== true) { builder.string('value', kv.value) + } else if (context.maskedValues !== undefined) { + // A preview prints a secret only as a value it masked itself; anything + // else, whatever it looks like, prints as the plain mask. + const value: unknown = kv.value + builder.string('value', typeof value === 'string' && context.maskedValues.has(value) ? value : MASKED_VALUE) } if (kv.locked === true) { builder.boolean('locked', kv.locked) } - if (kv.secret === true) { + if (kv.secret === true && context.maskedValues !== undefined) { + builder.boolean('secret', true) + } else if (kv.secret === true) { const secretVariable = ident(kv.key, { format: 'SCREAMING_SNAKE_CASE', }) diff --git a/packages/cli/src/constructs/playwright-check-bundle.ts b/packages/cli/src/constructs/playwright-check-bundle.ts index fc0c12459..d7cd2504a 100644 --- a/packages/cli/src/constructs/playwright-check-bundle.ts +++ b/packages/cli/src/constructs/playwright-check-bundle.ts @@ -1,11 +1,12 @@ import { Bundle } from './construct.js' -import { BundlePathMarker, CacheHashMarker } from '../services/check-parser/bundler.js' +import { BundlePathMarker, CacheHashMarker, CodeBundleChecksumMarker } from '../services/check-parser/bundler.js' import { PlaywrightCheck } from './playwright-check.js' import { Ref } from './ref.js' export interface PlaywrightCheckBundleProps { groupId?: Ref codeBundlePath: BundlePathMarker + codeBundleSha256?: CodeBundleChecksumMarker browsers?: string[] cacheHash?: CacheHashMarker playwrightVersion?: string @@ -18,6 +19,7 @@ export class PlaywrightCheckBundle implements Bundle { playwrightCheck: PlaywrightCheck groupId?: Ref codeBundlePath: BundlePathMarker + codeBundleSha256?: CodeBundleChecksumMarker browsers?: string[] cacheHash?: CacheHashMarker playwrightVersion?: string @@ -29,6 +31,7 @@ export class PlaywrightCheckBundle implements Bundle { this.playwrightCheck = playwrightCheck this.groupId = props.groupId this.codeBundlePath = props.codeBundlePath + this.codeBundleSha256 = props.codeBundleSha256 this.browsers = props.browsers this.cacheHash = props.cacheHash this.playwrightVersion = props.playwrightVersion @@ -42,6 +45,7 @@ export class PlaywrightCheckBundle implements Bundle { ...this.playwrightCheck.synthesize(), groupId: this.groupId, codeBundlePath: this.codeBundlePath, + codeBundleSha256: this.codeBundleSha256, browsers: this.browsers, cacheHash: this.cacheHash, playwrightVersion: this.playwrightVersion, diff --git a/packages/cli/src/constructs/playwright-check.ts b/packages/cli/src/constructs/playwright-check.ts index f2c44016e..71d87902b 100644 --- a/packages/cli/src/constructs/playwright-check.ts +++ b/packages/cli/src/constructs/playwright-check.ts @@ -506,6 +506,7 @@ export class PlaywrightCheck extends RuntimeCheck { return new PlaywrightCheckBundle(this, { groupId, codeBundlePath: bundler.marker, + codeBundleSha256: bundler.codeBundleSha256, browsers, cacheHash: bundler.cacheHash, playwrightVersion, diff --git a/packages/cli/src/helpers/__tests__/command-preview.spec.ts b/packages/cli/src/helpers/__tests__/command-preview.spec.ts index 32a1dffb4..1b3c5d710 100644 --- a/packages/cli/src/helpers/__tests__/command-preview.spec.ts +++ b/packages/cli/src/helpers/__tests__/command-preview.spec.ts @@ -142,4 +142,17 @@ describe('buildConfirmCommand', () => { const result = buildConfirmCommand('deploy', { 'schedule-on-deploy': true }) expect(result).toBe('checkly deploy --schedule-on-deploy --force') }) + + it('escapes what would otherwise end the quoting', () => { + // The result is meant to be run in a shell, and a value can come from a + // path the user typed or a token the API returned. + const result = buildConfirmCommand('deploy', { + 'config': 'my "project"/checkly.config.ts', + 'plan-token': 'v1.$(id)`id`\\', + }) + expect(result).toBe( + 'checkly deploy --config="my \\"project\\"/checkly.config.ts" ' + + '--plan-token="v1.\\$(id)\\`id\\`\\\\" --force', + ) + }) }) diff --git a/packages/cli/src/helpers/command-preview.ts b/packages/cli/src/helpers/command-preview.ts index 64f8368d1..659ea8771 100644 --- a/packages/cli/src/helpers/command-preview.ts +++ b/packages/cli/src/helpers/command-preview.ts @@ -1,3 +1,5 @@ +import type { DiffEntry } from '../rest/projects.js' + export type CommandClassification = { readOnly: boolean destructive: boolean @@ -10,6 +12,19 @@ export type CommandClassification = { */ export type FlagMetadata = Record +/** + * A deploy plan in machine-readable form, next to the human-readable `changes` + * lines that describe the same thing. Only `deploy` sets it. + * + * `planToken` is the plan's fingerprint: the `confirmCommand` passes it back so + * the confirming run applies the plan that was shown here and refuses if + * Checkly moved in between. + */ +export type CommandPlanPreview = { + planToken: string + diff: DiffEntry[] +} + export type CommandPreview = { command: string description: string @@ -18,6 +33,7 @@ export type CommandPreview = { flagMetadata?: FlagMetadata args?: Record classification: CommandClassification + preview?: CommandPlanPreview } export type AgentPreviewResponse = { @@ -27,10 +43,23 @@ export type AgentPreviewResponse = { classification: CommandClassification changes: string[] confirmCommand: string + /** Present for commands that compute a structured plan; `deploy` does. */ + preview?: CommandPlanPreview } const OMITTED_FLAGS: ReadonlySet = new Set(['output', 'force', 'dry-run']) +/** + * A flag value as it can appear inside the double quotes of the command this + * returns. The command is meant to be run in a shell, and a value can come from + * anywhere — a path the user typed, or a token the API returned — so a quote or + * a backslash in one must not end the quoting and let the rest be read as shell + * syntax. + */ +function quote (value: unknown): string { + return String(value).replace(/([\\"$`])/g, '\\$1') +} + export function buildConfirmCommand ( command: string, flags: Record, @@ -54,12 +83,12 @@ export function buildConfirmCommand ( if (Array.isArray(value)) { for (const item of value) { - parts.push(`--${key}="${item}"`) + parts.push(`--${key}="${quote(item)}"`) } } else if (typeof value === 'boolean') { parts.push(value ? `--${key}` : `--no-${key}`) } else { - parts.push(`--${key}="${value}"`) + parts.push(`--${key}="${quote(value)}"`) } } @@ -78,6 +107,7 @@ export function formatPreviewForAgent ( classification: preview.classification, changes: preview.changes, confirmCommand: buildConfirmCommand(preview.command, preview.flags, preview.args, preview.flagMetadata), + ...preview.preview ? { preview: preview.preview } : {}, } } diff --git a/packages/cli/src/rest/__tests__/projects-preview.spec.ts b/packages/cli/src/rest/__tests__/projects-preview.spec.ts new file mode 100644 index 000000000..f5484cf8e --- /dev/null +++ b/packages/cli/src/rest/__tests__/projects-preview.spec.ts @@ -0,0 +1,380 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { Readable } from 'node:stream' +import type { AxiosInstance } from 'axios' +import Projects, { + ProjectPlanStaleError, + ProjectPlanSupersededError, + ProjectPreviewNotSupportedError, + ProjectPreviewUnavailableError, + type ProjectSync, +} from '../projects.js' +import { ConflictError, NotFoundError, RequestTimeoutError, ValidationError } from '../errors.js' +import { stripUnsupportedDeployFields } from '../../services/deploy-diff/legacy-payload.js' + +/** + * The preview call and the plan token: what the CLI sends, how it survives a + * busy project, how it behaves against an API that has neither, and what it + * does when a deploy refuses the plan it was pinned to. + */ + +function makeAxiosMock (): AxiosInstance { + return { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + } as unknown as AxiosInstance +} + +const sync: ProjectSync = { + project: { name: 'My Project', logicalId: 'my-project' }, + resources: [], + repoInfo: null, +} + +const plan = { planToken: 'v1.AAAAAAAAAAAAAAAAAAAAAA', diff: [] } + +const sse = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` +const sseStream = (...frames: string[]) => ({ data: Readable.from(frames) }) + +const conflict = (retryAfter?: string) => + new ConflictError({ + statusCode: 409, + error: 'Conflict', + message: 'Could not preview this project: another operation is holding it. Please retry.', + ...retryAfter !== undefined ? { retryAfter } : {}, + }) + +describe('Projects.preview', () => { + let api: AxiosInstance + let projects: Projects + + beforeEach(() => { + vi.useFakeTimers() + api = makeAxiosMock() + projects = new Projects(api) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('asks for the changes detail level by default and sends no flags it was not given', async () => { + vi.mocked(api.post).mockResolvedValue({ data: plan }) + + expect(await projects.preview(sync)).toEqual(plan) + + expect(api.post).toHaveBeenCalledWith( + '/v1/projects/preview?detail=changes', + sync, + expect.objectContaining({ transformRequest: expect.any(Function) }), + ) + }) + + it('sends the detail level and both flags the caller asked for', async () => { + vi.mocked(api.post).mockResolvedValue({ data: plan }) + + await projects.preview(sync, { detail: 'full', preserveResources: true, pruneRelations: true }) + + expect(api.post).toHaveBeenCalledWith( + '/v1/projects/preview?detail=full&preserveResources=true&pruneRelations=true', + sync, + expect.anything(), + ) + }) + + it('reports an API without the endpoint as unsupported rather than as a missing project', async () => { + vi.mocked(api.post).mockRejectedValue( + new NotFoundError({ statusCode: 404, error: 'Not Found', message: 'Not Found' }), + ) + + await expect(projects.preview(sync)).rejects.toThrow(ProjectPreviewNotSupportedError) + }) + + it('surfaces a rejected payload as it is', async () => { + vi.mocked(api.post).mockRejectedValue( + new ValidationError({ statusCode: 400, error: 'Bad Request', message: 'resource "c" is malformed' }), + ) + + await expect(projects.preview(sync)).rejects.toThrow(ValidationError) + }) + + it('retries a busy project, waiting as long as the API asked', async () => { + vi.mocked(api.post) + .mockRejectedValueOnce(conflict('5')) + .mockResolvedValueOnce({ data: plan }) + const onStatus = vi.fn() + + const pending = projects.preview(sync, { onStatus }) + // Still waiting: the retry is not due yet. + await vi.advanceTimersByTimeAsync(4_000) + expect(api.post).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1_000) + expect(await pending).toEqual(plan) + expect(api.post).toHaveBeenCalledTimes(2) + expect(onStatus).toHaveBeenCalledWith(expect.stringContaining('holding the project')) + }) + + it('gives up after three attempts and says what is going on', async () => { + vi.mocked(api.post).mockRejectedValue(conflict('1')) + + const pending = projects.preview(sync) + const assertion = expect(pending).rejects.toThrow(ProjectPreviewUnavailableError) + await vi.advanceTimersByTimeAsync(10_000) + await assertion + + expect(api.post).toHaveBeenCalledTimes(3) + }) + + it('caps how long it honours a retry-after', async () => { + vi.mocked(api.post) + .mockRejectedValueOnce(conflict('600')) + .mockResolvedValueOnce({ data: plan }) + + const pending = projects.preview(sync) + await vi.advanceTimersByTimeAsync(10_000) + + expect(await pending).toEqual(plan) + }) +}) + +describe('Projects.deploy with a plan token', () => { + let api: AxiosInstance + let projects: Projects + + beforeEach(() => { + api = makeAxiosMock() + projects = new Projects(api) + }) + + it('sends the token and the pruning flag, and omits both when unset', async () => { + const applied = { project: sync.project, diff: [] } + vi.mocked(api.post).mockResolvedValue({ data: { id: 'd1', logicalId: 'my-project', status: 'PENDING' } }) + vi.mocked(api.get).mockResolvedValue( + sseStream(sse('complete', { id: 'd1', status: 'SUCCEEDED', progress: 100, result: applied, error: null })), + ) + + await projects.deploy(sync, { planToken: 'v1.token+slash/value', pruneRelations: true }) + + expect(api.post).toHaveBeenCalledWith( + '/v1/projects/deploy?dryRun=false&scheduleOnDeploy=true&pruneRelations=true' + + '&planToken=v1.token%2Bslash%2Fvalue', + sync, + expect.anything(), + ) + + vi.mocked(api.post).mockClear() + vi.mocked(api.post).mockResolvedValue({ data: { id: 'd2', logicalId: 'my-project', status: 'PENDING' } }) + vi.mocked(api.get).mockResolvedValue( + sseStream(sse('complete', { id: 'd1', status: 'SUCCEEDED', progress: 100, result: applied, error: null })), + ) + + await projects.deploy(sync) + + expect(api.post).toHaveBeenCalledWith( + '/v1/projects/deploy?dryRun=false&scheduleOnDeploy=true', + sync, + expect.anything(), + ) + }) + + it('does not re-send a token after waiting out another deployment', async () => { + // Whatever the predecessor wrote, finishing is what moved the state the + // token describes: re-POSTing the payload would buy a certain refusal. + const conflict = new ConflictError({ + statusCode: 409, + error: 'Conflict', + message: 'A deployment is already in progress.', + deploymentId: 'dep-running', + }) + vi.mocked(api.post).mockRejectedValue(conflict) + // The predecessor is gone by the time we look, so the wait ends at once. + vi.mocked(api.get).mockRejectedValue( + new NotFoundError({ statusCode: 404, error: 'Not Found', message: 'Not Found' }), + ) + + await expect(projects.deploy(sync, { planToken: plan.planToken })) + .rejects.toThrow(ProjectPlanSupersededError) + + // One POST, not two: the second would have carried the stale token. + expect(api.post).toHaveBeenCalledTimes(1) + }) + + it('surfaces the conflict when the wait gives up with the predecessor still running', async () => { + // Nothing finished, so nothing invalidated the plan: the caller needs to + // hear that a deployment is still in progress, not that its plan is stale. + const conflict = new ConflictError({ + statusCode: 409, + error: 'Conflict', + message: 'A deployment is already in progress.', + deploymentId: 'dep-running', + }) + vi.mocked(api.post).mockRejectedValue(conflict) + // The completion endpoint keeps timing out: the predecessor runs on. + vi.mocked(api.get).mockRejectedValue( + new RequestTimeoutError({ statusCode: 408, error: 'Request Timeout', message: 'still running' }), + ) + // Past the deadline on the first look, so the wait gives up at once. + const now = vi.spyOn(Date, 'now') + now.mockReturnValueOnce(0).mockReturnValue(31 * 60_000) + + const error = await projects.deploy(sync, { planToken: plan.planToken }).catch(err => err) + + expect(error).toBeInstanceOf(ConflictError) + expect(error).not.toBeInstanceOf(ProjectPlanSupersededError) + now.mockRestore() + }) + + it('deploys anyway when it cancelled the predecessor itself', async () => { + // The caller asked to deploy instead of the running deployment; a + // cancelled one may have written nothing at all, so the deploy goes ahead + // and Checkly's own check decides whether the plan still holds. + const applied = { project: sync.project, diff: [] } + vi.mocked(api.post) + .mockRejectedValueOnce(new ConflictError({ + statusCode: 409, + error: 'Conflict', + message: 'A deployment is already in progress.', + deploymentId: 'dep-running', + })) + // The cancel call, then the retried deploy. + .mockResolvedValueOnce({ data: {} }) + .mockResolvedValueOnce({ data: { id: 'd2', logicalId: 'my-project', status: 'PENDING' } }) + vi.mocked(api.get) + .mockRejectedValueOnce(new NotFoundError({ statusCode: 404, error: 'Not Found', message: 'Not Found' })) + .mockResolvedValueOnce( + sseStream(sse('complete', { id: 'd2', status: 'SUCCEEDED', progress: 100, result: applied, error: null })), + ) + + await projects.deploy(sync, { planToken: plan.planToken, cancelInProgress: true }) + + const deployCalls = vi.mocked(api.post).mock.calls.filter(([url]) => url.includes('/projects/deploy')) + expect(deployCalls).toHaveLength(2) + expect(deployCalls[1][0]).toContain(`planToken=${plan.planToken}`) + }) + + it('still retries after a predecessor when no plan is pinned', async () => { + const applied = { project: sync.project, diff: [] } + const conflict = new ConflictError({ + statusCode: 409, + error: 'Conflict', + message: 'A deployment is already in progress.', + deploymentId: 'dep-running', + }) + vi.mocked(api.post) + .mockRejectedValueOnce(conflict) + .mockResolvedValueOnce({ data: { id: 'd2', logicalId: 'my-project', status: 'PENDING' } }) + vi.mocked(api.get) + .mockRejectedValueOnce(new NotFoundError({ statusCode: 404, error: 'Not Found', message: 'Not Found' })) + .mockResolvedValueOnce( + sseStream(sse('complete', { id: 'd2', status: 'SUCCEEDED', progress: 100, result: applied, error: null })), + ) + + await projects.deploy(sync) + + expect(api.post).toHaveBeenCalledTimes(2) + }) + + it('reports a refused plan with the plan as it looks now, and never retries it', async () => { + const fresh = [{ logicalId: 'c1', type: 'check', action: 'UPDATE', changes: [{ path: '/name', origin: 'remote' }] }] + vi.mocked(api.post).mockResolvedValue({ data: { id: 'd1', logicalId: 'my-project', status: 'PENDING' } }) + vi.mocked(api.get).mockResolvedValue( + sseStream(sse('complete', { + id: 'd1', + status: 'FAILED', + progress: 100, + error: { code: 'PLAN_STALE', message: 'The project changed since the preview.' }, + result: { project: null, diff: fresh }, + })), + ) + + const error = await projects.deploy(sync, { planToken: plan.planToken }).catch(err => err) + + expect(error).toBeInstanceOf(ProjectPlanStaleError) + expect(error.diff).toEqual(fresh) + // One POST: a refused plan is never resent, since the deploy's own commit + // would have invalidated the token anyway. + expect(api.post).toHaveBeenCalledTimes(1) + }) +}) + +describe('the payload an API without the preview endpoint accepts', () => { + const payload: ProjectSync = { + project: { name: 'My Project', logicalId: 'my-project' }, + repoInfo: null, + resources: [ + { + logicalId: 'browser', + type: 'check', + member: true, + sourceFile: '__checks__/browser.check.ts', + payload: { + name: 'Browser', + checkType: 'BROWSER', + snapshots: [ + { path: 'a.png', key: 'checks/a.png', sha256: 'a'.repeat(64) }, + { path: 'b.png', sha256: 'b'.repeat(64) }, + ], + }, + }, + { + logicalId: 'suite', + type: 'check', + member: true, + sourceFile: 'suite.check.ts', + payload: { name: 'Suite', checkType: 'PLAYWRIGHT', codeBundlePath: 'k', codeBundleSha256: 'c'.repeat(64) }, + }, + { logicalId: 'referenced', type: 'alert-channel', member: false, payload: null }, + ], + } + + it('strips the three fields such an API rejects, and nothing else', () => { + const stripped = stripUnsupportedDeployFields(payload) + + const [browser, suite, referenced] = stripped.resources + expect(browser).not.toHaveProperty('sourceFile') + expect(suite).not.toHaveProperty('sourceFile') + expect(suite.payload).not.toHaveProperty('codeBundleSha256') + // The bundle key itself is what the deploy needs, and it stays. + expect(suite.payload.codeBundlePath).toBe('k') + // An uploaded snapshot keeps its key and loses its hash; one that was never + // uploaded describes a file this API cannot be told about at all. + expect(browser.payload.snapshots).toEqual([{ path: 'a.png', key: 'checks/a.png' }]) + // A referenced resource has no payload to strip anything from. + expect(referenced).toEqual({ logicalId: 'referenced', type: 'alert-channel', member: false, payload: null }) + // The original is untouched, so the caller can still deploy the full + // payload to an API that does support the endpoint. + expect(payload.resources[0].sourceFile).toBe('__checks__/browser.check.ts') + }) + + it('keeps an empty snapshots array, which says the check has none', () => { + // Dropping the key would turn "this check has no snapshots" into "nothing + // about snapshots", and an older API treats an absent optional key as + // "leave what is stored". + const emptied: ProjectSync = { + ...payload, + resources: [{ + logicalId: 'browser', + type: 'check', + member: true, + payload: { name: 'Browser', checkType: 'BROWSER', snapshots: [] }, + }], + } + + expect(stripUnsupportedDeployFields(emptied).resources[0].payload.snapshots).toEqual([]) + }) + + it('drops the snapshots array when nothing in it has been uploaded', () => { + const notUploaded: ProjectSync = { + ...payload, + resources: [{ + logicalId: 'browser', + type: 'check', + member: true, + payload: { name: 'Browser', checkType: 'BROWSER', snapshots: [{ path: 'a.png', sha256: 'a'.repeat(64) }] }, + }], + } + + expect(stripUnsupportedDeployFields(notUploaded).resources[0].payload).not.toHaveProperty('snapshots') + }) +}) diff --git a/packages/cli/src/rest/checkly-storage.ts b/packages/cli/src/rest/checkly-storage.ts index a45d13c08..189610771 100644 --- a/packages/cli/src/rest/checkly-storage.ts +++ b/packages/cli/src/rest/checkly-storage.ts @@ -15,11 +15,22 @@ class ChecklyStorage { ) } - uploadCodeBundle (stream: Readable, size: number) { + /** + * @param sha256 Lowercase hex SHA-256 of the archive. Stored as object + * metadata, so an uploaded bundle can be matched to the hash the deploy + * payload reports for it. + */ + uploadCodeBundle (stream: Readable, size: number, sha256?: string) { return this.api.post<{ key: string }>( '/next/checkly-storage/upload-code-bundle', stream, - { headers: { 'Content-Type': 'application/octet-stream', 'content-length': size } }, + { + headers: { + 'Content-Type': 'application/octet-stream', + 'content-length': size, + ...sha256 ? { 'x-bundle-checksum-sha256': sha256 } : {}, + }, + }, ) } diff --git a/packages/cli/src/rest/errors.ts b/packages/cli/src/rest/errors.ts index ecdf22027..5f10b8fce 100644 --- a/packages/cli/src/rest/errors.ts +++ b/packages/cli/src/rest/errors.ts @@ -131,6 +131,13 @@ export interface ErrorData { * deployment that blocked this one. Lets the client attach to or cancel it. */ deploymentId?: string + /** + * The response's `retry-after` header, verbatim, when it carried one. Set + * from the response rather than the body, so a caller that retries a + * transient failure itself (rather than leaving it to the retry + * interceptor) can honour what the server asked for. + */ + retryAfter?: string } function isErrorData (value: any): value is ErrorData { @@ -332,13 +339,18 @@ export function handleErrorResponse (err: Error): never { throw new MissingResponseError({ cause: err }) } - const { status: statusCode, data } = err.response + const { status: statusCode, data, headers } = err.response const errorData = parseErrorData(data, { statusCode, }) if (errorData !== undefined) { + const retryAfter = headers?.['retry-after'] + if (typeof retryAfter === 'string') { + errorData.retryAfter = retryAfter + } + if (statusCode === 400) { throw new ValidationError(errorData, { cause: err }) } diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 9665dc04d..131334dbe 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -4,6 +4,7 @@ import type { GitInformation } from '../services/util.js' import { compressJSONPayload } from './util.js' import { SharedFile } from '../constructs/index.js' import { ConflictError, ForbiddenError, handleErrorResponse, NotFoundError, RequestTimeoutError } from './errors.js' +import { parseRetryAfter } from './retry.js' export interface Project { name: string @@ -20,6 +21,104 @@ export interface Change { action: string } +/** The API's stand-in for a sensitive value in a reported change. */ +export interface DiffMaskedMarker { + $masked: 'same' | 'changed' +} + +/** + * One property of one resource that a deploy would change, or has changed. + * + * `origin` says which side moved since the last deploy: `code` for a local + * edit, `remote` for one made outside the CLI (the web app, the API), `both` + * when the property moved on both sides — in which case `remote` carries the + * movement the deploy is about to overwrite. A value too large to inline is + * reported as a `{ $hash }` object rather than in the clear; under + * `detail: 'full'` the deployed text is in the entry's `before`, at the path + * the import format gives it (the same one for a script or a request body). + * A secret change (`secret: true`) carries its values with every sensitive + * position replaced by a `DiffMaskedMarker`, `changed` on the element whose + * secret moved on that side; never a value or a hash. A list holding an + * unmoved secret carries `same` markers without the flag. + * `cause` names the reason for a change with no user-facing property behind + * it, such as a new code bundle. + */ +export interface DiffChange { + path: string + /** + * `unmanaged` marks an alert channel or private location attached to this + * project's check or group from outside the project: reported on the resource + * it belongs to, and only deleted with `--prune-relations`. + */ + origin: 'code' | 'remote' | 'both' | 'unmanaged' + before?: unknown + after?: unknown + remote?: { before?: unknown, after?: unknown } + cause?: string + /** Set when a sensitive value moved or a sensitive list element could not be matched; see `DiffMaskedMarker`. */ + secret?: true +} + +/** + * One rule of the redaction table the API applies to an entry's `before`, as + * a JSON Pointer pattern (`*` for every list position) and the flag test on + * the holding element that decides it. The API reports the resource type's + * whole table, whatever the deployed row held, so the local side blanks by + * the same rules — a credential the code adds included. + */ +export interface DiffRedaction { + path: string + /** What the rule blanks to: a `value` becomes the empty string, an `object` becomes null. */ + kind: 'value' | 'object' + when?: 'locked' | 'lockedOrSecret' +} + +/** + * A resource in a deploy plan or an applied deploy. `action` is CREATE, + * UPDATE, DELETE, DETACH (removed from code but kept in the account) or + * UNCHANGED. + */ +export interface DiffEntry extends Change { + origin?: 'code' | 'remote' | 'unmanaged' + /** Absent under `detail: 'summary'`. */ + changes?: DiffChange[] + /** + * The resource as currently deployed, in the import format: the payload the + * import plan returns for it, references as physical ids, a check's or + * group's subscription and assignment rows on it, credential values blanked. + * Only under `detail: 'full'`, and only for a retained resource with a + * change to show. + */ + before?: Record + /** With `before`: the type's redaction rule table, applied to it. */ + redactions?: DiffRedaction[] + /** + * Set on a relation (an alert channel subscription, a private location + * assignment) whose change is reported as part of the check or group it + * belongs to, so a renderer can fold it into that resource instead of + * listing it separately. + */ + foldedInto?: { type: string, logicalId: string } + /** The file Checkly has recorded for the resource, or null if none. */ + sourceFile?: string | null +} + +/** How much of each change a preview reports. */ +export type ProjectPreviewDetail = 'summary' | 'changes' | 'full' + +export interface ProjectPreviewResponse { + /** The project as currently deployed; absent before its first deploy. */ + project?: DeployedProject | null + /** + * Opaque fingerprint of the Checkly-side state this plan was computed + * against. Passing it to a deploy makes that deploy refuse, rather than + * apply a different plan than the one that was reviewed, if anything moved + * in between. + */ + planToken: string + diff: DiffEntry[] +} + export interface ResourceSync { logicalId: string physicalId?: string | number @@ -111,7 +210,7 @@ export interface DeployedProject extends Project { export interface ProjectDeployResponse { project: DeployedProject - diff: Array + diff: Array } export type ProjectDeploymentStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED' @@ -255,6 +354,66 @@ export class NoImportableResourcesFoundError extends Error { } } +/** + * The plan a deploy was pinned to no longer describes Checkly's state: a + * change landed between the preview and the deploy, so the deploy applied + * nothing. `diff` is the plan as it looks now. + */ +export class ProjectPlanStaleError extends Error { + readonly diff: DiffEntry[] + + constructor (message: string, diff: DiffEntry[], options?: ErrorOptions) { + super(message, options) + this.name = 'ProjectPlanStaleError' + this.diff = diff + } +} + +/** + * A deployment that was already running finished while this one waited for it. + * Whatever it wrote, it moved the state the plan was computed against, so the + * plan is stale before the deploy is even attempted — reported here rather + * than after re-sending the whole payload for a refusal that is certain. + * + * A subtype of {@link ProjectPlanStaleError}: callers recover from both the + * same way, by planning again. It carries no diff, because no new plan has + * been computed yet. + */ +export class ProjectPlanSupersededError extends ProjectPlanStaleError { + constructor (options?: ErrorOptions) { + super( + 'Another deployment of this project finished while this one was waiting for it, ' + + 'so the plan this deploy was pinned to no longer describes your Checkly account.', + [], + options, + ) + this.name = 'ProjectPlanSupersededError' + } +} + +/** + * The preview could not be computed because another operation is holding the + * project. Transient: the operation holding it finishes. + */ +export class ProjectPreviewUnavailableError extends Error { + constructor (message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'ProjectPreviewUnavailableError' + } +} + +/** + * The account's Checkly API does not have the preview endpoint yet (a + * self-hosted or not-yet-updated backend). Callers fall back to the older, + * coarser deploy preview. + */ +export class ProjectPreviewNotSupportedError extends Error { + constructor (options?: ErrorOptions) { + super('This Checkly API does not support deploy previews.', options) + this.name = 'ProjectPreviewNotSupportedError' + } +} + export class ImportPlanNotFoundError extends Error { constructor (options?: ErrorOptions) { super(`Import plan does not exist.`, options) @@ -274,6 +433,14 @@ export class InvalidImportPlanStateError extends Error { // large predecessor deploy or delete can legitimately run for many minutes. const DEPLOY_CONFLICT_WAIT_DEADLINE_MS = 30 * 60_000 +// A preview conflicts only while something else holds the project, which is +// measured in seconds, so it is retried a few times rather than waited out +// like a deploy's predecessor. The cap keeps a server asking for an +// unreasonable wait from stalling the command instead of failing it. +const PREVIEW_CONFLICT_ATTEMPTS = 3 +const PREVIEW_RETRY_AFTER_DEFAULT_MS = 2_000 +const PREVIEW_RETRY_AFTER_MAX_MS = 10_000 + class Projects { api: AxiosInstance constructor (api: AxiosInstance) { @@ -397,6 +564,71 @@ class Projects { } } + /** + * What deploying this payload would change, per resource and per property, + * without writing anything. + * + * The returned `planToken` pins the plan: pass it to {@link deploy} and the + * deploy refuses if Checkly's state moved in between. A code-side edit + * between the two is not such a move — the token describes Checkly's state, + * not the payload — so previewing, editing and deploying still works. + * + * @throws {ProjectPreviewNotSupportedError} If the API has no preview endpoint. + * @throws {ProjectPreviewUnavailableError} If the project stayed busy. + */ + async preview ( + resources: ProjectSync, + { + detail = 'changes', + preserveResources = false, + pruneRelations = false, + onStatus, + }: { + detail?: ProjectPreviewDetail + preserveResources?: boolean + pruneRelations?: boolean + /** Human-readable status updates (e.g. while retrying behind another operation). */ + onStatus?: (message: string) => void + } = {}, + ): Promise { + const query = new URLSearchParams({ detail }) + // Only sent when opted in, like deploy's: false is the default and the + // endpoint's contract is easier to keep stable if the CLI omits defaults. + if (preserveResources) { + query.set('preserveResources', 'true') + } + if (pruneRelations) { + query.set('pruneRelations', 'true') + } + + for (let attempt = 1; ; attempt++) { + try { + const { data } = await this.api.post( + `/v1/projects/preview?${query.toString()}`, + resources, + { transformRequest: compressJSONPayload }, + ) + return data + } catch (err) { + if (err instanceof NotFoundError) { + throw new ProjectPreviewNotSupportedError({ cause: err }) + } + if (!(err instanceof ConflictError)) { + throw err + } + if (attempt >= PREVIEW_CONFLICT_ATTEMPTS) { + throw new ProjectPreviewUnavailableError(err.data.message, { cause: err }) + } + const retryAfterMs = Math.min( + parseRetryAfter(err.data.retryAfter) ?? PREVIEW_RETRY_AFTER_DEFAULT_MS, + PREVIEW_RETRY_AFTER_MAX_MS, + ) + onStatus?.('Another operation is holding the project, retrying...') + await new Promise(resolve => setTimeout(resolve, retryAfterMs)) + } + } + } + /** * Deploy a project. The deployment runs asynchronously on the backend: this * submits it, then follows its progress stream to completion, so large projects @@ -411,6 +643,8 @@ class Projects { dryRun = false, scheduleOnDeploy = true, preserveResources = false, + pruneRelations = false, + planToken, cancelInProgress = false, onProgress, onStatus, @@ -422,6 +656,17 @@ class Projects { * instead of deleting them. */ preserveResources?: boolean + /** + * Delete the alert channel subscriptions and private location assignments + * on this project's checks and groups that the project does not manage. + */ + pruneRelations?: boolean + /** + * The `planToken` of the preview this deploy was reviewed against. The + * deploy is refused with {@link ProjectPlanStaleError}, having written + * nothing, if Checkly's state moved since that preview. + */ + planToken?: string /** * On a 409 (another deployment is already in progress), cancel that * deployment instead of waiting for it to finish, then retry. @@ -443,7 +688,14 @@ class Projects { const deadlineAt = Date.now() + DEPLOY_CONFLICT_WAIT_DEADLINE_MS for (;;) { try { - return await this.submitDeployment(resources, { dryRun, scheduleOnDeploy, preserveResources, onProgress }) + return await this.submitDeployment(resources, { + dryRun, + scheduleOnDeploy, + preserveResources, + pruneRelations, + planToken, + onProgress, + }) } catch (err) { if ( dryRun @@ -453,11 +705,21 @@ class Projects { ) { throw err } - await this.resolveInProgressDeployment(logicalId, err.data.deploymentId, { + const finished = await this.resolveInProgressDeployment(logicalId, err.data.deploymentId, { cancel: cancelInProgress, onStatus, deadlineAt, }) + // A predecessor that ran to completion is what invalidates a plan + // token, so re-POSTing with this one would spend the whole payload on a + // refusal nobody can act on. Two cases are not that: one we cancelled + // ourselves (the caller asked to deploy instead of it, and it may well + // have written nothing), and one still running when the wait gave up — + // nothing has changed, and the re-POST surfaces the conflict the caller + // needs to hear about. + if (planToken !== undefined && !cancelInProgress && finished) { + throw new ProjectPlanSupersededError({ cause: err }) + } // loop → re-POST, now that the predecessor has reached a final state } } @@ -465,19 +727,26 @@ class Projects { private async submitDeployment ( resources: ProjectSync, - { dryRun, scheduleOnDeploy, preserveResources, onProgress }: { + { dryRun, scheduleOnDeploy, preserveResources, pruneRelations, planToken, onProgress }: { dryRun: boolean scheduleOnDeploy: boolean preserveResources: boolean + pruneRelations: boolean + planToken?: string 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. + // pruneRelations and planToken are omitted for the same reason: an older + // API knows neither. const preserveParam = preserveResources ? '&preserveResources=true' : '' + const pruneParam = pruneRelations ? '&pruneRelations=true' : '' + const tokenParam = planToken ? `&planToken=${encodeURIComponent(planToken)}` : '' const { data } = await this.api.post( - `/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}${preserveParam}`, + `/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}` + + `${preserveParam}${pruneParam}${tokenParam}`, resources, { transformRequest: compressJSONPayload }, ) @@ -497,6 +766,13 @@ class Projects { ) } + // A refused plan is not a failed deploy: nothing was written, and the + // deployment carries the plan as it looks now so the caller can show what + // moved instead of a bare error. + if (completed.error?.code === 'PLAN_STALE') { + throw new ProjectPlanStaleError(completed.error.message, completed.result?.diff ?? []) + } + if (completed.status !== 'SUCCEEDED' || completed.result === null) { throw new ProjectDeployFailedError(completed.error?.message ?? 'The deployment did not complete successfully.') } @@ -509,14 +785,18 @@ class Projects { * optionally cancel it, then wait until it reaches a final state (or is gone) * before returning — so the caller re-POSTs only when the slot is actually * free, never re-uploading the payload while the predecessor is still running. - * Returns early if the overall `deadlineAt` passes (the caller then re-POSTs - * once and surfaces the conflict). + * + * @returns Whether the predecessor was observed to reach a final state. + * `false` means the wait hit its deadline with the predecessor still running, + * which is a different situation for the caller: nothing has changed, so a + * plan computed before the wait still stands, and re-POSTing surfaces the + * conflict the caller needs to hear about. */ private async resolveInProgressDeployment ( logicalId: string, deploymentId: string, { cancel, onStatus, deadlineAt }: { cancel: boolean, onStatus?: (message: string) => void, deadlineAt: number }, - ): Promise { + ): Promise { if (cancel) { onStatus?.('Cancelling an in-progress deployment...') try { @@ -526,7 +806,7 @@ class Projects { if (!(err instanceof NotFoundError)) { throw err } - return + return true } } else { onStatus?.('Waiting for an in-progress deployment to finish...') @@ -538,17 +818,17 @@ class Projects { for (;;) { try { await this.awaitDeploymentCompletion(logicalId, deploymentId) - return // reached a final state → slot free + return true // reached a final state → slot free } catch (err) { if (err instanceof NotFoundError) { - return // gone → slot free + return true // gone → slot free } // 408 = still running after the long-poll window. Keep waiting unless the // overall deadline has passed, in which case return and let the caller // re-POST once and surface the conflict. if (err instanceof RequestTimeoutError) { if (Date.now() >= deadlineAt) { - return + return false } continue } diff --git a/packages/cli/src/services/__tests__/test-session-runners.spec.ts b/packages/cli/src/services/__tests__/test-session-runners.spec.ts index 423ffecea..bf80876f6 100644 --- a/packages/cli/src/services/__tests__/test-session-runners.spec.ts +++ b/packages/cli/src/services/__tests__/test-session-runners.spec.ts @@ -81,6 +81,57 @@ describe('test-session runners', () => { }) }) + it('leaves the deploy-only content hashes out of a check run job', async () => { + // A check's payload is synthesized the same way for a deploy and for a + // test session, but only a deploy compares content hashes — and only the + // deploy schemas accept them, so sending them here would break `checkly + // test` against an API that has not shipped the matching change yet. + vi.mocked(testSessions.run).mockResolvedValue({ + data: { testSessionId: 'ts-hashes', sequenceIds: { 'browser-check': 'seq-1' } }, + } as any) + + const check = { logicalId: 'browser-check', groupId: { ref: 'group' }, getSourceFile: () => 'home.check.ts' } + const bundle = { + synthesize: vi.fn(() => ({ + checkType: 'BROWSER', + name: 'Browser Check', + codeBundleSha256: 'a'.repeat(64), + snapshots: [{ path: 'home.png', key: 'checks/home.png', sha256: 'b'.repeat(64) }], + })), + } + const groupBundle = { + synthesize: vi.fn(() => ({ name: 'Group', codeBundleSha256: 'c'.repeat(64) })), + } + const projectBundle = { + project: { name: 'Project', logicalId: 'project' }, + data: { 'check-group': { group: { bundle: groupBundle } } }, + } + + const runner = new TestRunner( + 'account-id', + projectBundle as any, + [{ construct: check, bundle }] as any, + [], + RUN_LOCATION, + 60, + false, + true, + null, + null, + false, + '.', + null, + ) + + await runner.scheduleChecks('suite-id') + const [job] = vi.mocked(testSessions.run).mock.calls[0][0].checkRunJobs + + expect(job).not.toHaveProperty('codeBundleSha256') + expect(job.group).not.toHaveProperty('codeBundleSha256') + // The storage key stays: that is what the run needs to fetch the file. + expect(job.snapshots).toEqual([{ path: 'home.png', key: 'checks/home.png' }]) + }) + it('maps triggered Agentic checks back to the test-session sequence IDs', async () => { const agenticCheck = { id: 'agentic-check-id', diff --git a/packages/cli/src/services/check-parser/bundler.ts b/packages/cli/src/services/check-parser/bundler.ts index 03e730b9e..e7fc59ed7 100644 --- a/packages/cli/src/services/check-parser/bundler.ts +++ b/packages/cli/src/services/check-parser/bundler.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { AxiosResponse } from 'axios' -import type { Archiver } from 'archiver' +import type { Archiver, EntryData } from 'archiver' import Debug from 'debug' import * as uuid from 'uuid' @@ -47,6 +47,7 @@ import { PackageManager } from './package-files/package-manager.js' import { File } from './parser.js' import { Registries, REGISTRIES_ARCHIVE_PATH, serializeRegistries, validateRegistries } from '../runner/registries.js' import { Workspace } from './package-files/workspace.js' +import { sha256OfFile } from '../content-hash.js' import { pathToPosix } from '../util.js' const debug = Debug('checkly:cli:services:check-parser:bundler') @@ -139,6 +140,13 @@ function dropSymlinksWithChildren (entries: Array<[string, File]>): File[] { .map(([, file]) => file) } +/** + * Timestamp stamped on every archive entry, in place of the file's own mtime or + * the clock. Fixed so the archive's content hash describes the content and + * nothing else; see {@link BundleArchive.add}. + */ +const ARCHIVE_ENTRY_DATE = new Date(0) + export interface CreateBundleArchiveOptions { tempDir?: string stripPrefix?: string @@ -248,6 +256,15 @@ export class BundleArchive { const entry = { mode: 0o755, // Default mode for files in the archive name, + // Every entry's timestamp is pinned, because the archive's SHA-256 is + // what tells Checkly whether the code bundle changed and it has to + // describe the content alone. Left alone, a tar entry carries the + // file's mtime — or, for an entry the bundler generates in memory (a + // faux workspace manifest, a pruned lockfile, the runner's registries + // file), the current time. Either makes two archives of identical + // content differ: a CI pipeline that clones fresh per run would report + // every Playwright suite as changed on every deploy. + date: ARCHIVE_ENTRY_DATE, } if (!file.physical) { @@ -256,7 +273,18 @@ export class BundleArchive { } if (file.symlinkTarget !== undefined) { - this.#archive.symlink(name, file.symlinkTarget, entry.mode) + // Appended rather than added through archiver's symlink(), which takes + // no date and would stamp the entry with the current time — the same + // reproducibility problem as any other entry, and one that hits every + // pnpm or npm workspace bundle, since those are symlink farms. + // `linkname` is the field archiver's tar sink reads for a symlink + // entry — the same one its own symlink() fills in; the published types + // do not list it. + this.#archive.append(Buffer.alloc(0), { + ...entry, + type: 'symlink', + linkname: file.symlinkTarget, + } as EntryData) continue } @@ -279,6 +307,10 @@ export class BundleArchive { return await FinalizedBundleArchive.create({ archiveFile: this.#archiveFile, + // Hashed here, where the archive is complete on disk and has not been + // uploaded yet: the deploy sends the hash, and a preview of that deploy + // describes the bundle by hash alone. + sha256: await sha256OfFile(this.#archiveFile), containsEmbeddedPackages: this.#containsEmbeddedPackages, }) } @@ -368,25 +400,30 @@ function parseMaxBytes (message: string): number | undefined { export interface CreateFinalizedBundleArchiveOptions { archiveFile: string + sha256: string containsEmbeddedPackages?: boolean } interface FinalizedBundleArchiveOptions { archiveFile: string + sha256: string containsEmbeddedPackages?: boolean } export class FinalizedBundleArchive { #archiveFile: string + #sha256: string #containsEmbeddedPackages: boolean private constructor (options: FinalizedBundleArchiveOptions) { const { archiveFile, + sha256, containsEmbeddedPackages, } = options this.#archiveFile = archiveFile + this.#sha256 = sha256 this.#containsEmbeddedPackages = containsEmbeddedPackages ?? false } @@ -399,6 +436,11 @@ export class FinalizedBundleArchive { return this.#archiveFile } + /** Lowercase hex SHA-256 of the archive's bytes. */ + get sha256 (): string { + return this.#sha256 + } + async store (): Promise { const { size } = await fs.stat(this.#archiveFile) @@ -407,7 +449,7 @@ export class FinalizedBundleArchive { data: { key, }, - } = await this.#uploadCodeBundle(this.#archiveFile, size) + } = await this.#uploadCodeBundle(this.#archiveFile, size, this.#sha256) return await RemoteBundleArchive.create({ key, @@ -426,13 +468,13 @@ export class FinalizedBundleArchive { } } - async #uploadCodeBundle (filePath: string, size: number): Promise { + async #uploadCodeBundle (filePath: string, size: number, sha256: string): Promise { const stream = createReadStream(filePath) stream.on('error', err => { throw new Error(`Failed to read Playwright project file: ${err.message}`) }) try { - return await checklyStorage.uploadCodeBundle(stream, size) + return await checklyStorage.uploadCodeBundle(stream, size, sha256) } finally { // A failed upload leaves the stream unconsumed and its file handle // open; on Windows the open handle blocks deleting the archive's @@ -620,6 +662,7 @@ export class Bundler { #id: string #marker: BundlePathMarker #cacheHashMarker: CacheHashMarker + #codeBundleChecksumMarker: CodeBundleChecksumMarker #tempDir?: string #stripPrefix?: string #workspaceContext?: WorkspaceBundleContext @@ -645,6 +688,7 @@ export class Bundler { this.#id = uuid.v4() this.#marker = new BundlePathMarker(`bundle:${this.#id}`) this.#cacheHashMarker = new CacheHashMarker(cacheHash) + this.#codeBundleChecksumMarker = new CodeBundleChecksumMarker() this.#stripPrefix = stripPrefix this.#tempDir = tempDir this.#workspaceContext = workspaceContext @@ -740,6 +784,14 @@ export class Bundler { return this.#cacheHashMarker } + /** + * The code bundle's content hash, filled in by finalize(). See + * {@link CodeBundleChecksumMarker} for why it is a holder. + */ + get codeBundleSha256 (): CodeBundleChecksumMarker { + return this.#codeBundleChecksumMarker + } + /** * Whether any files have been registered for bundling. Only Playwright check * suites register files (see playwright-check.ts), so an empty bundler means the @@ -1349,12 +1401,18 @@ export class Bundler { }) const files = dropSymlinksWithChildren( - Array.from(this.#files.entries()).sort(([a], [b]) => a.localeCompare(b)), + // Ordered by code point, not `localeCompare`: the order decides the + // archive's bytes and therefore its hash, and a locale-sensitive + // comparison would make that hash depend on the machine that built it. + Array.from(this.#files.entries()).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), ) await archive.add(...files) - return await archive.finalize() + const finalized = await archive.finalize() + this.#codeBundleChecksumMarker.updateValue(finalized.sha256) + + return finalized } } @@ -1413,3 +1471,28 @@ export class CacheHashMarker { return this.#value } } + +/** + * Mutable holder for the code bundle's SHA-256, serialized as a plain string. + * Checks copy it into their payloads during bundle(), but the archive it + * describes only exists once finalize() has run — the same ordering problem + * {@link BundlePathMarker} solves for the archive path. + * + * Starts empty and serializes to `undefined` until finalize() fills it in, so + * a payload synthesized without a finalized archive simply omits the key + * instead of claiming a hash of nothing. + * + * Deliberately a standalone class rather than a subclass of + * {@link BundlePathMarker}, for the reason given on {@link CacheHashMarker}. + */ +export class CodeBundleChecksumMarker { + #value?: string + + updateValue (newValue: string) { + this.#value = newValue + } + + toJSON (): string | undefined { + return this.#value + } +} diff --git a/packages/cli/src/services/content-hash.ts b/packages/cli/src/services/content-hash.ts new file mode 100644 index 000000000..6c94e220d --- /dev/null +++ b/packages/cli/src/services/content-hash.ts @@ -0,0 +1,22 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { pipeline } from 'node:stream/promises' + +/** + * Lowercase hex SHA-256 of a file's contents. + * + * The deploy payload carries one of these per uploaded artifact — every + * Playwright visual snapshot and the code bundle archive — so Checkly can tell + * an unchanged upload from a new one by content rather than by storage key, + * which changes on every upload. A hash is also computable before anything is + * uploaded, which is what lets `checkly deploy` ask what a deploy would change + * without uploading first. + * + * Streamed rather than read into a buffer: a bundle archive or a full-page + * screenshot can be tens of megabytes, and several are hashed per deploy. + */ +export async function sha256OfFile (filePath: string): Promise { + const hash = createHash('sha256') + await pipeline(createReadStream(filePath), hash) + return hash.digest('hex') +} diff --git a/packages/cli/src/services/deploy-diff/__tests__/import-shape.spec.ts b/packages/cli/src/services/deploy-diff/__tests__/import-shape.spec.ts new file mode 100644 index 000000000..4a7aa2e6f --- /dev/null +++ b/packages/cli/src/services/deploy-diff/__tests__/import-shape.spec.ts @@ -0,0 +1,701 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { ApiCheck } from '../../../constructs/api-check.js' +import { CheckGroupV2 } from '../../../constructs/check-group-v2.js' +import { ConstructCodegen, type Resource } from '../../../constructs/construct-codegen.js' +import { Dashboard } from '../../../constructs/dashboard.js' +import { EmailAlertChannel } from '../../../constructs/email-alert-channel.js' +import { HeartbeatMonitor } from '../../../constructs/heartbeat-monitor.js' +import { Context, renderConstruct } from '../../../constructs/internal/codegen/index.js' +import { MaintenanceWindow } from '../../../constructs/maintenance-window.js' +import { PrivateLocation } from '../../../constructs/private-location.js' +import { Project } from '../../../constructs/project.js' +import { Session } from '../../../constructs/session.js' +import { StatusPage } from '../../../constructs/status-page.js' +import { StatusPageService } from '../../../constructs/status-page-service.js' +import { TcpMonitor } from '../../../constructs/tcp-monitor.js' +import { UrlMonitor } from '../../../constructs/url-monitor.js' +import { WebhookAlertChannel } from '../../../constructs/webhook-alert-channel.js' +import type { DiffEntry, ResourceSync } from '../../../rest/projects.js' +import { Program } from '../../../sourcegen/index.js' +import { + blankRedacted, + carriesMarker, + fillUnchangedFromBefore, + markChanged, + MASK, + idKey, + physicalIdsFromPlan, + registerProject, + relationResourcesForAfter, + relationResourcesFromBefore, + toImportResource, + UnshapeableError, +} from '../import-shape.js' + +/** + * The local side of a preview shaped like an import resource, and the + * deployed side used as the import resource it already is (`import-shape.ts`). + */ + +const ids = new Map([ + [idKey('check', 'api'), 'check-uuid'], + [idKey('check-group', 'grp'), 42], + [idKey('alert-channel', 'email'), 7], + [idKey('private-location', 'pl'), 'pl-uuid'], + [idKey('status-page-service', 'svc'), 'svc-uuid'], + [idKey('status-page', 'page'), 'page-uuid'], + [idKey('status-page-component', 'parent'), 'parent-uuid'], + [idKey('status-page-component', 'child'), 'child-uuid'], + [idKey('status-page-automation-rule', 'rule'), 'rule-uuid'], + [idKey('alert-channel-subscription', 'sub'), 1_000_000_000_001], + [idKey('alert-channel-subscription', 'other-parent'), 1_000_000_000_002], + [idKey('private-location-group-assignment', 'assign'), 'assign-uuid'], + [idKey('maintenance-window', 'mw'), 'mw-uuid'], +]) + +const program = () => + new Program({ rootDirectory: '.', constructFileSuffix: '.check', specFileSuffix: '.spec', language: 'typescript' }) + +describe('physicalIdsFromPlan', () => { + it('takes ids from the plan, then from the local payload, and invents stable ones for the rest', () => { + const diff: DiffEntry[] = [ + { type: 'check', logicalId: 'api', physicalId: 'from-plan', action: 'UPDATE' }, + { type: 'check-group', logicalId: 'grp', action: 'CREATE' }, + ] + const local: ResourceSync[] = [ + { type: 'check', logicalId: 'api', physicalId: 'from-local', member: true, payload: {} }, + { type: 'check-group', logicalId: 'grp', member: true, payload: {} }, + { type: 'alert-channel', logicalId: 'ref', physicalId: 9, member: false, payload: null }, + { type: 'private-location', logicalId: 'new-pl', member: true, payload: {} }, + ] + const first = physicalIdsFromPlan(diff, local) + expect(first.get(idKey('check', 'api'))).toBe('from-plan') + expect(first.get(idKey('alert-channel', 'ref'))).toBe(9) + const group = first.get(idKey('check-group', 'grp')) + expect(typeof group).toBe('number') + expect(group as number).toBeGreaterThan(1_000_000_000_000) + expect(first.get(idKey('private-location', 'new-pl'))).toBe('synthetic:new-pl') + // Derived from identity, not counted: the same input numbers the same way. + expect(physicalIdsFromPlan([...diff].reverse(), [...local].reverse()).get(idKey('check-group', 'grp'))).toBe(group) + }) +}) + +describe('fillUnchangedFromBefore', () => { + const before = { + id: 'check-uuid', + name: 'API', + activated: true, + muted: false, + degradedResponseTime: 5000, + frequency: 10, + request: { url: 'https://example.com', method: 'GET', headers: [], assertions: [{ source: 'STATUS_CODE' }] }, + agenticCheckData: { skills: [] }, + alertChannelSubscriptions: [{ id: 1 }], + privateLocationAssignments: [], + } + + it('fills what the payload leaves out and the plan does not report, at the top and inside objects', () => { + const local: Record = { id: 'check-uuid', name: 'API', request: { url: 'https://example.com/v2' } } + fillUnchangedFromBefore(local, before, [{ path: '/request/url', origin: 'code' }]) + expect(local).toEqual({ + id: 'check-uuid', + name: 'API', + activated: true, + muted: false, + degradedResponseTime: 5000, + frequency: 10, + request: { url: 'https://example.com/v2', method: 'GET', headers: [], assertions: [{ source: 'STATUS_CODE' }] }, + agenticCheckData: { skills: [] }, + }) + }) + + it('leaves a key alone when a change is reported at it, under it, or above it', () => { + const local: Record = { id: 'check-uuid', request: { url: 'https://example.com' } } + fillUnchangedFromBefore(local, before, [ + { path: '/activated', origin: 'code' }, + { path: '/request', origin: 'code' }, + { path: '/agentRuntime', origin: 'code' }, + ]) + expect(local).not.toHaveProperty('activated') + expect(local.request).toEqual({ url: 'https://example.com' }) + // Reported under the deploy payload's own spelling. + expect(local).not.toHaveProperty('agenticCheckData') + const nested: Record = { id: 'check-uuid', request: {} } + fillUnchangedFromBefore(nested, before, [{ path: '/request/headers/0', origin: 'code', secret: true }]) + expect(nested.request).toEqual({ url: 'https://example.com', method: 'GET', assertions: [{ source: 'STATUS_CODE' }] }) + }) + + it('keeps an explicit null, never fills list elements, and copies rather than shares', () => { + const local: Record = { id: 'check-uuid', muted: null, request: { assertions: [] } } + fillUnchangedFromBefore(local, before, []) + expect(local.muted).toBeNull() + expect((local.request as { assertions: unknown[] }).assertions).toEqual([]) + ;(local.agenticCheckData as { skills: unknown[] }).skills.push('x') + expect(before.agenticCheckData.skills).toEqual([]) + }) + + it('never copies the id or the relation rows', () => { + const local: Record = { name: 'API' } + fillUnchangedFromBefore(local, before, []) + expect(local).not.toHaveProperty('id') + expect(local).not.toHaveProperty('alertChannelSubscriptions') + expect(local).not.toHaveProperty('privateLocationAssignments') + }) +}) + +describe('toImportResource', () => { + it('sets the id, substitutes every kind of reference and drops the deploy-only keys', () => { + const shaped = toImportResource( + 'check', + 'api', + { + name: 'API', + groupId: { ref: 'grp' }, + privateLocations: ['eu-west-1'], + sourceFile: 'src/api.check.ts', + codeBundleSha256: 'abc', + triggerIncident: { serviceId: { ref: 'svc' }, severity: 'MAJOR' }, + nested: { alertChannelId: { ref: 'email' }, privateLocationId: { ref: 'pl' } }, + }, + ids, + ) + expect(shaped).toEqual({ + type: 'check', + logicalId: 'api', + payload: { + id: 'check-uuid', + name: 'API', + groupId: 42, + triggerIncident: { serviceId: 'svc-uuid', severity: 'MAJOR' }, + nested: { alertChannelId: 7, privateLocationId: 'pl-uuid' }, + }, + }) + }) + + it('turns a card\'s service references into the `{ id }` objects the card codegen reads', () => { + const shaped = toImportResource( + 'status-page', + 'page', + { name: 'Page', cards: [{ name: 'Card', services: [{ ref: 'svc' }] }] }, + ids, + ) + expect(shaped.payload).toEqual({ + id: 'page-uuid', + name: 'Page', + cards: [{ name: 'Card', services: [{ id: 'svc-uuid' }] }], + }) + }) + + it('fills in an absent services list, which the card codegen iterates unguarded', () => { + const shaped = toImportResource('status-page', 'page', { name: 'Page', cards: [{ name: 'Card', services: undefined }] }, ids) + expect(shaped.payload).toEqual({ id: 'page-uuid', name: 'Page', cards: [{ name: 'Card', services: [] }] }) + const absent = toImportResource('status-page', 'page', { name: 'Page', cards: [{ name: 'Card' }] }, ids) + expect(absent.payload).toEqual({ id: 'page-uuid', name: 'Page', cards: [{ name: 'Card', services: [] }] }) + }) + + it('drops the group version marker with the other deploy-only keys', () => { + expect(toImportResource('check-group', 'grp', { name: 'G', v: 2 }, ids).payload).toEqual({ id: 42, name: 'G' }) + }) + + it('takes a self-serializing value as what it serializes to', () => { + const marker = { toJSON: () => 'checks/abc.tar.gz' } + const shaped = toImportResource('check', 'api', { codeBundlePath: marker }, ids) + expect(shaped.payload).toEqual({ id: 'check-uuid', codeBundlePath: 'checks/abc.tar.gz' }) + }) + + it('groups a check\'s intent constraints the way the store keeps them', () => { + const shaped = toImportResource( + 'check', + 'api', + { + intent: { + goal: 'stay up', + constraints: [ + { type: 'MUST_PRESERVE', statement: 'p1' }, + { type: 'REQUIRED_OUTCOME', statement: 'r1' }, + { type: 'MUST_PRESERVE', statement: 'p2' }, + { type: 'REQUIRED_OUTCOME', statement: 'r2' }, + { type: 'SOMEDAY', statement: 's1' }, + ], + }, + }, + ids, + ) + expect(shaped.payload.intent).toEqual({ + goal: 'stay up', + constraints: [ + { type: 'REQUIRED_OUTCOME', statement: 'r1' }, + { type: 'REQUIRED_OUTCOME', statement: 'r2' }, + { type: 'MUST_PRESERVE', statement: 'p1' }, + { type: 'MUST_PRESERVE', statement: 'p2' }, + { type: 'SOMEDAY', statement: 's1' }, + ], + }) + expect(toImportResource('check', 'api', { intent: null }, ids).payload.intent).toBeNull() + }) + + it('substitutes the status page and parent references of a component and a rule', () => { + const component = toImportResource( + 'status-page-component', + 'child', + { name: 'Child', statusPageId: { ref: 'page' }, parentId: { ref: 'parent' } }, + ids, + ) + expect(component.payload).toMatchObject({ statusPageId: 'page-uuid', parentId: 'parent-uuid' }) + const rule = toImportResource( + 'status-page-automation-rule', + 'rule', + { statusPageId: { ref: 'page' }, components: [{ componentId: { ref: 'parent' }, targetImpact: 'MAJOR' }] }, + ids, + ) + expect(rule.payload).toMatchObject({ + statusPageId: 'page-uuid', + components: [{ componentId: 'parent-uuid', targetImpact: 'MAJOR' }], + }) + }) + + it('renames the agentic runtime to what the codegen reads', () => { + const shaped = toImportResource('check', 'api', { checkType: 'AGENTIC', agentRuntime: { skills: ['a/b'] } }, ids) + expect(shaped.payload).toEqual({ id: 'check-uuid', checkType: 'AGENTIC', agenticCheckData: { skills: ['a/b'] } }) + }) + + it('refuses a resource the plan has no id for: ids are minted in one place', () => { + expect(() => toImportResource('check-group', 'brand-new', { name: 'New' }, ids)).toThrow(UnshapeableError) + }) + + it('refuses a reference to a resource the plan does not know, an invalid date, and a non-object', () => { + expect(() => toImportResource('check', 'api', { groupId: { ref: 'missing' } }, ids)).toThrow( + '\'groupId\' refers to check-group \'missing\', which this plan does not know', + ) + expect(() => toImportResource('maintenance-window', 'mw', { startsAt: new Date('nope') }, ids)).toThrow( + '\'startsAt\' holds an invalid date', + ) + expect(() => toImportResource('check', 'api', null, ids)).toThrow(UnshapeableError) + }) + + it('leaves everything else exactly as it is: no defaults, no nulls invented, no sorting', () => { + const payload = { name: 'x', tags: ['b', 'a'], locations: [], muted: null, request: { headers: [] } } + const shaped = toImportResource('check', 'api', payload, ids) + expect(shaped.payload).toEqual({ id: 'check-uuid', ...payload }) + }) +}) + +describe('relations', () => { + const before = { + id: 'check-uuid', + alertChannelSubscriptions: [ + { id: 1, alertChannelId: 7, checkId: 'check-uuid', activated: true }, + { id: 2, alertChannelId: 99, checkId: 'check-uuid', activated: true }, + { id: 3, alertChannelId: 55, checkId: 'check-uuid', activated: true }, + ], + privateLocationAssignments: [{ id: 'a1', privateLocationId: 'pl-uuid', checkId: 'check-uuid' }], + } + const entry: DiffEntry = { type: 'check', logicalId: 'api', physicalId: 'check-uuid', action: 'UPDATE', before } + const local: ResourceSync[] = [ + { + type: 'alert-channel-subscription', + logicalId: 'sub', + member: true, + payload: { alertChannelId: { ref: 'email' }, checkId: { ref: 'api' }, activated: true }, + }, + { + type: 'alert-channel-subscription', + logicalId: 'other-parent', + member: true, + payload: { alertChannelId: { ref: 'email' }, groupId: { ref: 'grp' }, activated: true }, + }, + ] + + it('reads the deployed side\'s relations off the parent\'s before, in target order', () => { + expect(relationResourcesFromBefore('check', before).map(resource => [resource.type, resource.payload])).toEqual([ + // By target, not by row: channel 55 sorts before 7 and 99 as a string. + ['alert-channel-subscription', before.alertChannelSubscriptions[2]], + ['alert-channel-subscription', before.alertChannelSubscriptions[0]], + ['alert-channel-subscription', before.alertChannelSubscriptions[1]], + ['private-location-check-assignment', before.privateLocationAssignments[0]], + ]) + expect(relationResourcesFromBefore('alert-channel', before)).toEqual([]) + }) + + it('renders the local side with its own relations plus the deployed ones the deploy keeps', () => { + const diff: DiffEntry[] = [ + entry, + // The code removed this subscription: it goes. + { + type: 'alert-channel-subscription', + logicalId: 'gone', + physicalId: 3, + action: 'DELETE', + foldedInto: { type: 'check', logicalId: 'api' }, + }, + ] + const result = relationResourcesForAfter({ ids, local, entry, diff, pruneRelations: false }) + expect(result.map(resource => resource.payload)).toEqual([ + // The local construct, shaped; it also covers the deployed row for channel 7. + { id: expect.any(Number), alertChannelId: 7, checkId: 'check-uuid', activated: true }, + // Unmanaged: no construct, not removed, so the deploy keeps it. + { id: 2, alertChannelId: 99, checkId: 'check-uuid', activated: true }, + { id: 'a1', privateLocationId: 'pl-uuid', checkId: 'check-uuid' }, + ]) + // The same order the deployed side gets, whichever side a row came from: + // by target, so a local construct and a deployed row interleave alike. + const targets = (resources: Resource[]) => + resources.map(resource => { + const row = resource.payload as { alertChannelId?: unknown, privateLocationId?: unknown } + return row.alertChannelId !== undefined ? `alert-channel:${row.alertChannelId}` : `private-location:${row.privateLocationId}` + }) + expect(targets(result)).toEqual([...targets(result)].sort()) + expect(targets(relationResourcesFromBefore('check', before))).toEqual([...targets(relationResourcesFromBefore('check', before))].sort()) + }) + + it('drops the unmanaged relations --prune-relations will delete', () => { + const diff: DiffEntry[] = [ + entry, + { + type: 'alert-channel-subscription', + logicalId: 'unmanaged/check/api/2', + physicalId: 2, + action: 'DELETE', + origin: 'unmanaged', + foldedInto: { type: 'check', logicalId: 'api' }, + }, + ] + const kept = relationResourcesForAfter({ ids, local, entry, diff, pruneRelations: false }) + expect(kept.map(resource => (resource.payload as { id: unknown }).id)).toContain(2) + const pruned = relationResourcesForAfter({ ids, local, entry, diff, pruneRelations: true }) + expect(pruned.map(resource => (resource.payload as { id: unknown }).id)).not.toContain(2) + }) + + it('treats a detached relation like a deleted one, and de-duplicates an assignment a construct covers', () => { + const groupBefore = { + id: 42, + alertChannelSubscriptions: [{ id: 5, alertChannelId: 7, groupId: 42, activated: true }], + privateLocationAssignments: [ + { id: 'g1', privateLocationId: 'pl-uuid', groupId: 42 }, + { id: 'g2', privateLocationId: 'other-pl', groupId: 42 }, + ], + } + const groupEntry: DiffEntry = { type: 'check-group', logicalId: 'grp', physicalId: 42, action: 'UPDATE', before: groupBefore } + const groupLocal: ResourceSync[] = [ + { + type: 'private-location-group-assignment', + logicalId: 'assign', + member: true, + payload: { privateLocationId: { ref: 'pl' }, groupId: { ref: 'grp' } }, + }, + ] + const diff: DiffEntry[] = [ + groupEntry, + // Detached under --preserve-resources: gone from the local side all the same. + { + type: 'alert-channel-subscription', + logicalId: 'sub-grp', + physicalId: 5, + action: 'DETACH', + foldedInto: { type: 'check-group', logicalId: 'grp' }, + }, + ] + const result = relationResourcesForAfter({ ids, local: groupLocal, entry: groupEntry, diff, pruneRelations: false }) + expect(result.map(resource => [resource.type, (resource.payload as { id: unknown }).id])).toEqual([ + // The other assignment is unmanaged and stays; the detached subscription does not. + ['private-location-group-assignment', 'g2'], + // The construct covers the deployed row for pl-uuid, so that row is not repeated. + ['private-location-group-assignment', expect.any(String)], + ]) + }) + + it('does not let a removed subscription\'s id mask an assignment with the same id', () => { + const shared = { + id: 'check-uuid', + alertChannelSubscriptions: [{ id: 5, alertChannelId: 7, checkId: 'check-uuid', activated: true }], + privateLocationAssignments: [{ id: 5, privateLocationId: 'pl-uuid', checkId: 'check-uuid' }], + } + const sharedEntry: DiffEntry = { type: 'check', logicalId: 'api', physicalId: 'check-uuid', action: 'UPDATE', before: shared } + const diff: DiffEntry[] = [ + sharedEntry, + { type: 'alert-channel-subscription', logicalId: 'gone', physicalId: 5, action: 'DELETE', foldedInto: { type: 'check', logicalId: 'api' } }, + ] + const result = relationResourcesForAfter({ ids, local: [], entry: sharedEntry, diff, pruneRelations: false }) + expect(result.map(resource => resource.type)).toEqual(['private-location-check-assignment']) + }) + + it('refuses a plan that removes a relation without naming its row', () => { + const diff: DiffEntry[] = [ + entry, + { type: 'alert-channel-subscription', logicalId: 'gone', action: 'DELETE', foldedInto: { type: 'check', logicalId: 'api' } }, + ] + expect(() => relationResourcesForAfter({ ids, local, entry, diff, pruneRelations: false })) + .toThrow(UnshapeableError) + }) + + it('throws when one of its own relations cannot be shaped', () => { + const broken: ResourceSync[] = [ + { type: 'alert-channel-subscription', logicalId: 'sub', member: true, payload: { alertChannelId: { ref: 'nope' }, checkId: { ref: 'api' } } }, + ] + expect(() => relationResourcesForAfter({ ids, local: broken, entry, diff: [entry], pruneRelations: false })) + .toThrow(UnshapeableError) + }) + + it('has nothing to say for a type that carries no relations', () => { + const dashboard: DiffEntry = { type: 'dashboard', logicalId: 'd', physicalId: 'd1', action: 'UPDATE' } + expect(relationResourcesForAfter({ ids, local, entry: dashboard, diff: [], pruneRelations: false })).toEqual([]) + }) +}) + +describe('blankRedacted', () => { + const payload = { + environmentVariables: [ + { key: 'PUBLIC', value: 'v', locked: false, secret: false }, + { key: 'TOKEN', value: 't', locked: true, secret: false }, + { key: 'SIGN', value: 's', locked: false, secret: true }, + ], + request: { basicAuth: { username: 'u', password: 'p' }, headers: [{ key: 'A', value: 'a', locked: true }] }, + playwrightConfig: { use: { extraHTTPHeaders: { Authorization: 'x' }, baseURL: 'https://e.com' } }, + } + + it('blanks by the element\'s own flags for a conditional rule, and in place for a scalar one', () => { + const blanked = blankRedacted(payload, [ + { path: '/environmentVariables/*/value', kind: 'value', when: 'lockedOrSecret' }, + { path: '/request/basicAuth/password', kind: 'value' }, + { path: '/playwrightConfig/use/extraHTTPHeaders', kind: 'object' }, + ]) + expect(blanked).toEqual({ + environmentVariables: [ + { key: 'PUBLIC', value: 'v', locked: false, secret: false }, + { key: 'TOKEN', value: MASK, locked: true, secret: false }, + { key: 'SIGN', value: MASK, locked: false, secret: true }, + ], + request: { basicAuth: { username: 'u', password: MASK }, headers: [{ key: 'A', value: 'a', locked: true }] }, + // An object is blanked to null, as the API blanks it. + playwrightConfig: { use: { extraHTTPHeaders: null, baseURL: 'https://e.com' } }, + }) + // Never in place. + expect(payload.request.basicAuth.password).toBe('p') + }) + + it('blanks a locked header and leaves an unlocked one under the `locked` condition', () => { + const blanked = blankRedacted( + { request: { headers: [{ key: 'A', value: 'a', locked: true }, { key: 'B', value: 'b', locked: false }] } }, + [{ path: '/request/headers/*/value', kind: 'value', when: 'locked' }], + ) + expect(blanked.request.headers).toEqual([{ key: 'A', value: MASK, locked: true }, { key: 'B', value: 'b', locked: false }]) + }) + + it('blanks regardless under a condition this CLI does not know, rather than printing a credential', () => { + const blanked = blankRedacted( + { environmentVariables: [{ key: 'A', value: 'a', locked: false, secret: false }] }, + [{ path: '/environmentVariables/*/value', kind: 'value', when: 'someday' as 'locked' }], + ) + expect(blanked.environmentVariables[0].value).toBe(MASK) + }) + + it('blanks to the placeholder the rule names, not to what the local value looks like', () => { + const blanked = blankRedacted( + { playwrightConfig: { use: { launchOptions: null, httpCredentials: 'odd' } } }, + [ + { path: '/playwrightConfig/use/launchOptions', kind: 'object' }, + { path: '/playwrightConfig/use/httpCredentials', kind: 'object' }, + ], + ) + expect(blanked.playwrightConfig.use).toEqual({ launchOptions: null, httpCredentials: null }) + }) + + it('does not let a condition name reach through to the prototype', () => { + const blanked = blankRedacted( + { environmentVariables: [{ key: 'A', value: 'a', locked: false, secret: false }] }, + [{ path: '/environmentVariables/*/value', kind: 'value', when: 'constructor' as 'locked' }], + ) + expect(blanked.environmentVariables[0].value).toBe(MASK) + }) + + it('ignores a rule that matches nothing locally', () => { + expect(blankRedacted(payload, [ + { path: '/config/apiKey', kind: 'value' }, + { path: '/request/grpcConfig/metadata/*/value', kind: 'value' }, + ])).toEqual(payload) + }) + + it('refuses to render without a rule table rather than print a credential', () => { + expect(() => blankRedacted(payload, undefined)).toThrow(UnshapeableError) + expect(blankRedacted(payload, [])).toBe(payload) + }) + + it('blanks every position of a map, and the positions themselves under a trailing wildcard', () => { + const blanked = blankRedacted( + { headers: { Authorization: 'x', Accept: 'json' }, tokens: ['a', 'b'] }, + [{ path: '/headers/*', kind: 'value' }, { path: '/tokens/*', kind: 'value' }], + ) + expect(blanked).toEqual({ headers: { Authorization: MASK, Accept: MASK }, tokens: [MASK, MASK] }) + // A trailing wildcard honours the condition like any other terminal. + const conditional = blankRedacted( + { vars: [{ key: 'A', locked: false }, { key: 'B', locked: true }] }, + [{ path: '/vars/*', kind: 'object', when: 'locked' }], + ) + expect(conditional).toEqual({ vars: [{ key: 'A', locked: false }, null] }) + }) + + it('masks the deployed side the same way, so a blank never passes for a value', () => { + const masked = blankRedacted( + { environmentVariables: [{ key: 'TOKEN', value: '', locked: true, secret: false }], request: { basicAuth: { username: 'u', password: '' } } }, + [ + { path: '/environmentVariables/*/value', kind: 'value', when: 'lockedOrSecret' }, + { path: '/request/basicAuth/password', kind: 'value' }, + ], + ) + expect(masked.environmentVariables[0].value).toBe(MASK) + expect(masked.request.basicAuth.password).toBe(MASK) + }) + + it('refuses a rule whose path is not a pointer', () => { + expect(() => blankRedacted(payload, [{ path: 'environmentVariables', kind: 'value' }])).toThrow(UnshapeableError) + }) +}) + +describe('registerProject and the render round trip', () => { + let project: Project + + beforeEach(() => { + Session.reset() + Session.project = new Project('proj', { name: 'Project' }) + project = Session.project + }) + + const localResources = (): ResourceSync[] => + Object.values(project.data).flatMap(record => + Object.values(record).map((construct: any) => ({ + type: construct.type, + logicalId: construct.logicalId, + physicalId: construct.physicalId, + member: construct.member, + payload: construct.synthesize(), + })), + ) + + const render = (resource: Resource, planIds: ReadonlyMap) => { + const prog = program() + const context = new Context() + registerProject(context, prog, project, planIds) + return renderConstruct(new ConstructCodegen(prog), resource.logicalId, resource, { context }) + } + + it('renders a reference as the construct\'s variable, and the same way on both sides', () => { + const group = new CheckGroupV2('grp', { name: 'Website Group' }) + const email = new EmailAlertChannel('email', { address: 'ops@example.com' }) + new ApiCheck('api', { name: 'API', group, alertChannels: [email], request: { url: 'https://example.com', method: 'GET' } }) + const local = localResources() + const planIds = physicalIdsFromPlan([], local) + const check = local.find(resource => resource.logicalId === 'api') as ResourceSync + const shaped = toImportResource('check', 'api', check.payload, planIds) + const first = render(shaped, planIds) + expect(first).toContain('group: websiteGroup') + expect(first).not.toContain('fromId') + expect(render(shaped, planIds)).toBe(first) + }) + + it('leaves a reference construct to the codegen, which renders it as the fromId it is', () => { + const referenced = CheckGroupV2.fromId(4242) + new ApiCheck('api', { name: 'API', group: referenced, request: { url: 'https://example.com', method: 'GET' } }) + const local = localResources() + const planIds = physicalIdsFromPlan([], local) + const check = local.find(resource => resource.logicalId === 'api') as ResourceSync + const rendered = render(toImportResource('check', 'api', check.payload, planIds), planIds) + expect(rendered).toContain('fromId(4242)') + }) + + it('tells two constructs with the same name apart', () => { + const one = new CheckGroupV2('grp-one', { name: 'Group' }) + const two = new CheckGroupV2('grp-two', { name: 'Group' }) + new ApiCheck('api', { name: 'API', group: two, request: { url: 'https://example.com', method: 'GET' } }) + const local = localResources() + const planIds = physicalIdsFromPlan([], local) + const check = local.find(resource => resource.logicalId === 'api') as ResourceSync + const rendered = render(toImportResource('check', 'api', check.payload, planIds), planIds) + expect(rendered).toContain('group: group2') + expect(one).not.toBe(two) + }) + + it('round-trips every construct type with codegen coverage through the codegen', () => { + const group = new CheckGroupV2('grp', { name: 'Group' }) + const email = new EmailAlertChannel('email', { address: 'ops@example.com' }) + new WebhookAlertChannel('hook', { name: 'Hook', url: 'https://hook.example.com', method: 'POST' }) + new PrivateLocation('pl', { name: 'PL', slugName: 'pl' }) + new MaintenanceWindow('mw', { + name: 'MW', + tags: ['prod'], + startsAt: new Date('2026-01-01T00:00:00.000Z'), + endsAt: new Date('2026-01-02T00:00:00.000Z'), + }) + new Dashboard('dash', { header: 'Ops', customUrl: 'ops-dashboard' }) + const service = new StatusPageService('svc', { name: 'Service' }) + new StatusPage('page', { name: 'Page', url: 'status-example', cards: [{ name: 'Card', services: [service] }] }) + new ApiCheck('api', { name: 'API', group, alertChannels: [email], request: { url: 'https://example.com', method: 'GET' } }) + new HeartbeatMonitor('hb', { name: 'HB', period: 1, periodUnit: 'hours', grace: 10, graceUnit: 'minutes' }) + new UrlMonitor('url', { name: 'URL', request: { url: 'https://example.com' } }) + new TcpMonitor('tcp', { name: 'TCP', request: { hostname: 'example.com', port: 443 } }) + + const local = localResources().filter(resource => resource.payload !== null) + const planIds = physicalIdsFromPlan([], local) + const failures: string[] = [] + for (const resource of local) { + if (resource.type === 'alert-channel-subscription' || resource.type.startsWith('private-location-')) { + continue + } + try { + const shaped = toImportResource(resource.type as Resource['type'], resource.logicalId, resource.payload, planIds) + const rendered = render(shaped, planIds) + expect(rendered.length).toBeGreaterThan(0) + } catch (cause) { + failures.push(`${resource.type} ${resource.logicalId}: ${cause}`) + } + } + expect(failures).toEqual([]) + expect(local.length).toBeGreaterThanOrEqual(11) + }) +}) + +describe('markChanged', () => { + const CHANGED = { $masked: 'changed' } + const SAME = { $masked: 'same' } + const list = () => ({ + environmentVariables: [ + { key: 'REGION', value: 'eu', locked: false }, + { key: 'TOKEN', value: MASK, locked: true }, + { key: 'OTHER', value: MASK, locked: true }, + ], + }) + + it('marks the element the report names, by key, whatever its position on this side', () => { + const payload = list() + // The deployed row lists its secrets last; the report is in author order. + const reported = [{ key: 'TOKEN', value: CHANGED, locked: true }, { key: 'OTHER', value: SAME, locked: true }, { key: 'REGION', value: 'eu' }] + expect(markChanged(payload, '/environmentVariables', reported, 'X')).toBe(true) + expect(payload.environmentVariables.map(v => v.value)).toEqual(['eu', 'X', MASK]) + }) + + it('falls back to the position only for an equal key at that index in lists of one length', () => { + const dup = () => ({ vars: [{ key: 'T', value: MASK }, { key: 'T', value: MASK }] }) + const same = dup() + expect(markChanged(same, '/vars', [{ key: 'T', value: SAME }, { key: 'T', value: CHANGED }], 'X')).toBe(true) + expect(same.vars.map(v => v.value)).toEqual([MASK, 'X']) + const shifted = dup() + expect(markChanged(shifted, '/vars', [{ key: 'N', value: 'n' }, { key: 'T', value: CHANGED }, { key: 'T', value: SAME }], 'X')).toBe(false) + expect(shifted.vars.map(v => v.value)).toEqual([MASK, MASK]) + }) + + it('marks a scalar at its own path, and never a position the rules blank to null', () => { + const payload = { request: { basicAuth: { username: 'u', password: MASK } }, playwrightConfig: { use: { httpCredentials: null } } } + expect(markChanged(payload, '/request/basicAuth/password', CHANGED, 'X')).toBe(true) + expect(payload.request.basicAuth.password).toBe('X') + expect(markChanged(payload, '/playwrightConfig/use/httpCredentials', CHANGED, 'X')).toBe(false) + expect(payload.playwrightConfig.use.httpCredentials).toBeNull() + }) + + it('places nothing without markers, and recognises one anywhere in a reported value', () => { + const payload = list() + expect(markChanged(payload, '/environmentVariables', undefined, 'X')).toBe(false) + expect(markChanged(payload, '/environmentVariables', [{ key: 'TOKEN', value: 'plain' }], 'X')).toBe(false) + expect(carriesMarker([{ key: 'TOKEN', value: SAME }])).toBe(true) + expect(carriesMarker([{ key: 'TOKEN', value: 'x' }])).toBe(false) + }) +}) diff --git a/packages/cli/src/services/deploy-diff/__tests__/plan-summary.spec.ts b/packages/cli/src/services/deploy-diff/__tests__/plan-summary.spec.ts new file mode 100644 index 000000000..bc50ba49a --- /dev/null +++ b/packages/cli/src/services/deploy-diff/__tests__/plan-summary.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { reducePlanForAgent } from '../plan-summary.js' + +/** + * The plan as the agent envelope carries it: every entry and every changed + * property, without the state the terminal rendering needs. + */ +describe('reducePlanForAgent', () => { + it('drops the deployed state and the redaction table that applies to it', () => { + const [reduced] = reducePlanForAgent([ + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/name', origin: 'code', before: 'a', after: 'b' }], + before: { id: 'x' }, + redactions: [{ path: '/request/basicAuth/password', kind: 'value' }], + }, + ]) + expect(reduced).toEqual({ + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/name', origin: 'code', before: 'a', after: 'b' }], + }) + }) +}) diff --git a/packages/cli/src/services/deploy-diff/__tests__/render.spec.ts b/packages/cli/src/services/deploy-diff/__tests__/render.spec.ts new file mode 100644 index 000000000..37cb86743 --- /dev/null +++ b/packages/cli/src/services/deploy-diff/__tests__/render.spec.ts @@ -0,0 +1,839 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { ApiCheck } from '../../../constructs/api-check.js' +import { RetryStrategyBuilder } from '../../../constructs/retry-strategy.js' +import { CheckGroupV2 } from '../../../constructs/check-group-v2.js' +import { EmailAlertChannel } from '../../../constructs/email-alert-channel.js' +import { Project } from '../../../constructs/project.js' +import { Session } from '../../../constructs/session.js' +import type { DiffEntry, ResourceSync } from '../../../rest/projects.js' +import { physicalIdsFromPlan } from '../import-shape.js' +import { renderResourceDiff } from '../render.js' + +/** + * The lines printed under an updated resource (`render.ts`): which of the + * branches applies, and what each prints. + */ + +let project: Project + +beforeEach(() => { + Session.reset() + Session.project = new Project('proj', { name: 'Project' }) + project = Session.project +}) + +/** A project with one group, one channel and one check subscribed to it, as the deploy payload sends them. */ +function scenario (checkOverrides: Record = {}) { + const group = new CheckGroupV2('grp', { name: 'Website Group' }) + const email = new EmailAlertChannel('email', { address: 'ops@example.com' }) + const check = new ApiCheck('api', { + name: 'API', + group, + alertChannels: [email], + request: { url: 'https://example.com/health', method: 'GET' }, + ...checkOverrides, + }) + // What the deploy sends: the constructs' own payloads plus the subscription + // the check declares. + const local: ResourceSync[] = [ + { type: 'check-group', logicalId: 'grp', member: true, payload: group.synthesize() }, + { type: 'alert-channel', logicalId: 'email', member: true, payload: email.synthesize() }, + { type: 'check', logicalId: 'api', member: true, payload: check.synthesize() }, + { + type: 'alert-channel-subscription', + logicalId: 'sub', + member: true, + payload: { alertChannelId: { ref: 'email' }, checkId: { ref: 'api' }, activated: true }, + }, + ] + return { group, email, check, local } +} + +/** The check as Checkly has it: the import format, the request at the deployed URL, its subscription row. */ +function deployed (overrides: Record = {}): Record { + return { + id: 'check-uuid', + checkType: 'API', + name: 'API', + activated: true, + muted: false, + groupId: 42, + locations: [], + tags: [], + request: { url: 'https://example.com/health', method: 'GET', headers: [], queryParameters: [], assertions: [] }, + alertChannelSubscriptions: [{ id: 1, alertChannelId: 7, checkId: 'check-uuid', activated: true }], + privateLocationAssignments: [], + ...overrides, + } +} + +const plan = (entry: DiffEntry, ...rest: DiffEntry[]): DiffEntry[] => [ + { type: 'check-group', logicalId: 'grp', physicalId: 42, action: 'UNCHANGED' }, + { type: 'alert-channel', logicalId: 'email', physicalId: 7, action: 'UNCHANGED' }, + { type: 'alert-channel-subscription', logicalId: 'sub', physicalId: 1, action: 'UNCHANGED', foldedInto: { type: 'check', logicalId: 'api' } }, + entry, + ...rest, +] + +function render (entry: DiffEntry, local: ResourceSync[], extra: DiffEntry[] = [], pruneRelations = false) { + const diff = plan(entry, ...extra) + return renderResourceDiff({ + entry, + local: local.find(resource => resource.logicalId === entry.logicalId && resource.type === entry.type), + localResources: local, + diff, + project, + ids: physicalIdsFromPlan(diff, local), + pruneRelations, + }) +} + +describe('renderResourceDiff', () => { + it('prints the construct diff of the deployed and the local rendering, references as variables', () => { + const { local } = scenario({ request: { url: 'https://example.com/v2/health', method: 'GET' } }) + const lines = render( + { + type: 'check', + logicalId: 'api', + physicalId: 'check-uuid', + action: 'UPDATE', + sourceFile: 'src/api.check.ts', + changes: [{ path: '/request/url', origin: 'code', before: 'https://example.com/health', after: 'https://example.com/v2/health' }], + before: deployed(), + redactions: [], + }, + local, + ) + expect(lines[0]).toBe('file: src/api.check.ts') + const text = lines.join('\n') + expect(text).toContain('- url: \'https://example.com/health\'') + expect(text).toContain('+ url: \'https://example.com/v2/health\'') + // Everything the two sides agree on cancels: the group and the channel + // render as the same variables on both, so neither is a changed line — + // and neither side had to fall back to `fromId`. + const changed = lines.filter(line => /^[-+](?![-+]{2} )/.test(line)) + expect(changed, changed.join('\n')).toHaveLength(2) + expect(text).not.toContain('fromId') + expect(text).not.toContain('secret changed') + }) + + it('prints one line for changes that carry only a cause', () => { + const { local } = scenario() + expect(render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [ + { path: '/codeBundle', origin: 'code', cause: 'code bundle' }, + { path: '/cacheHash', origin: 'code', cause: 'dependency cache' }, + ], + before: deployed(), + redactions: [], + }, + local, + )).toEqual(['changed: code bundle, dependency cache']) + }) + + it('names a CLI upgrade rather than diffing two spellings of the same thing', () => { + // A pre-4.0.9 project sent `doubleCheck: true`, which the API stored as + // a retry strategy beside it; the upgraded CLI sends the strategy itself + // and no `doubleCheck`, so the flag is the only reported change and the + // two constructs render alike. + const options = { baseBackoffSeconds: 60, maxRetries: 2, maxDurationSeconds: 600, sameRegion: true } + const retryStrategy = { type: 'LINEAR', ...options } + const { local } = scenario({ retryStrategy: RetryStrategyBuilder.linearStrategy(options) }) + expect(render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/doubleCheck', origin: 'code', before: true }], + before: deployed({ doubleCheck: true, retryStrategy }), + redactions: [], + }, + local, + )).toEqual(['payload format changed (CLI upgrade)']) + }) + + it('leaves a deployed snippet reference out of both sides', () => { + const { local } = scenario({ request: { url: 'https://example.com/v2/health', method: 'GET' } }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/request/url', origin: 'code', before: 'https://example.com/health', after: 'https://example.com/v2/health' }], + // A setup snippet attached in the web app: the codegen would resolve + // it through a file no preview registers, and a deploy clears it. + before: deployed({ setupSnippetId: 42, tearDownSnippetId: 43 }), + redactions: [], + }, + local, + ) + const text = lines.join('\n') + expect(text).not.toContain('could not render') + expect(text).not.toContain('snippet') + expect(text).toContain('+ url: \'https://example.com/v2/health\'') + // A group's codegen resolves a snippet reference the same way. + const { local: withGroup } = scenario() + const group = withGroup.find(resource => resource.type === 'check-group') as ResourceSync + group.payload = { ...group.payload, name: 'Website Group v2' } + const groupLines = render( + { + type: 'check-group', + logicalId: 'grp', + physicalId: 42, + action: 'UPDATE', + changes: [{ path: '/name', origin: 'code', before: 'Website Group', after: 'Website Group v2' }], + before: { id: 42, name: 'Website Group', setupSnippetId: 42, alertChannelSubscriptions: [], privateLocationAssignments: [] }, + redactions: [], + }, + withGroup, + ) + expect(groupLines.join('\n')).not.toContain('could not render') + expect(groupLines.join('\n')).toContain('+ name: \'Website Group v2\'') + }) + + it('shows a script change as a text diff of its own, beside the construct diff when there is one', () => { + const script = (line: string) => `const { test } = require('@playwright/test')\n${line}\n` + const browser = (overrides: Record) => ({ + checkType: 'BROWSER', + name: 'Browser', + activated: true, + muted: false, + locations: [], + tags: [], + alertChannelSubscriptions: [], + privateLocationAssignments: [], + ...overrides, + }) + const local: ResourceSync[] = [ + { type: 'check', logicalId: 'browser', member: true, payload: browser({ script: script('test("b", () => {})'), muted: true }) }, + ] + const withMuted = render( + { + type: 'check', + logicalId: 'browser', + physicalId: 'browser-uuid', + action: 'UPDATE', + changes: [ + { path: '/script', origin: 'code', before: { $hash: 'a' }, after: { $hash: 'b' } }, + { path: '/muted', origin: 'code', before: false, after: true }, + ], + before: browser({ id: 'browser-uuid', script: script('test("a", () => {})') }), + redactions: [], + }, + local, + ) + const text = withMuted.join('\n') + expect(text).toContain('+ muted: true') + expect(text).toContain('/script:') + expect(text).toContain('-test("a", () => {})') + expect(text).toContain('+test("b", () => {})') + // The script alone: the constructs agree, the text diff is all there is. + const alone = render( + { + type: 'check', + logicalId: 'browser', + physicalId: 'browser-uuid', + action: 'UPDATE', + changes: [{ path: '/script', origin: 'code', before: { $hash: 'a' }, after: { $hash: 'b' } }], + before: browser({ id: 'browser-uuid', script: script('test("a", () => {})'), muted: true }), + redactions: [], + }, + local, + ) + expect(alone[0]).toBe('/script:') + expect(alone.join('\n')).not.toContain('muted') + }) + + it('still names a cause beside the construct diff', () => { + const { local } = scenario({ request: { url: 'https://example.com/v2/health', method: 'GET' } }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [ + { path: '/request/url', origin: 'code', before: 'https://example.com/health', after: 'https://example.com/v2/health' }, + { path: '/cacheHash', origin: 'code', cause: 'dependency cache' }, + ], + before: deployed(), + redactions: [], + }, + local, + ) + expect(lines.join('\n')).toContain('+ url: \'https://example.com/v2/health\'') + expect(lines.at(-1)).toBe('/cacheHash: changed (dependency cache)') + }) + + it('takes what the payload leaves out and the plan does not report from the deployed side', () => { + const { local } = scenario({ request: { url: 'https://example.com/v2/health', method: 'GET' } }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/request/url', origin: 'code', before: 'https://example.com/health', after: 'https://example.com/v2/health' }], + // The row carries the response-time defaults the deploy filled in; the construct never said them. + before: deployed({ degradedResponseTime: 5000, maxResponseTime: 20000 }), + redactions: [], + }, + local, + ) + const changed = lines.filter(line => /^[-+](?![-+]{2} )/.test(line)) + expect(changed, changed.join('\n')).toHaveLength(2) + expect(lines.join('\n')).not.toContain('ResponseTime') + }) + + it('shows an edit to a shape-change property as the construct diff it is', () => { + const { local } = scenario({ retryStrategy: RetryStrategyBuilder.linearStrategy({ maxRetries: 3 }) }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/retryStrategy', origin: 'code', before: null, after: { type: 'LINEAR', maxRetries: 3 } }], + before: deployed(), + redactions: [], + }, + local, + ) + const text = lines.join('\n') + expect(text).not.toContain('CLI upgrade') + expect(text).toContain('+ retryStrategy') + expect(text).toContain('maxRetries: 3') + }) + + const CHANGED = { $masked: 'changed' } + const SAME = { $masked: 'same' } + const RULES = [ + { path: '/environmentVariables/*/value', kind: 'value', when: 'lockedOrSecret' }, + { path: '/request/basicAuth/password', kind: 'value' }, + ] + const deployedVars = () => + deployed({ + environmentVariables: [ + { key: 'TOKEN', value: '', locked: true, secret: false }, + { key: 'REGION', value: 'eu', locked: false, secret: false }, + ], + }) + + it('shows a rotated secret inline, masked and marked beside its key, never valued', () => { + const { local } = scenario({ + environmentVariables: [ + { key: 'TOKEN', value: 'rotated-plaintext', locked: true }, + { key: 'REGION', value: 'eu', locked: false }, + ], + }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ + path: '/environmentVariables', + origin: 'code', + secret: true, + before: [{ key: 'TOKEN', value: CHANGED, locked: true, secret: false }, { key: 'REGION', value: 'eu', locked: false }], + after: [{ key: 'TOKEN', value: CHANGED, locked: true }, { key: 'REGION', value: 'eu', locked: false }], + }], + before: deployedVars(), + redactions: RULES, + }, + local, + ) + const text = lines.join('\n') + expect(text).not.toContain('rotated-plaintext') + expect(text).not.toContain('\'\'') + expect(text).toContain('- value: \'********\',') + expect(text).toContain('+ value: \'******** (changed)\',') + expect(lines.filter(line => /^[-+](?![-+]{2} )/.test(line))).toHaveLength(2) + expect(text).not.toContain('secret changed') + expect(text).not.toContain('#') + }) + + it('never writes a mark or a mask into the plan or the deploy payload', () => { + const { local } = scenario({ + environmentVariables: [{ key: 'TOKEN', value: 'rotated-plaintext', locked: true }], + }) + const entry: DiffEntry = { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ + path: '/environmentVariables', + origin: 'code', + secret: true, + before: [{ key: 'TOKEN', value: CHANGED, locked: true }], + after: [{ key: 'TOKEN', value: CHANGED, locked: true }], + }], + before: deployed({ environmentVariables: [{ key: 'TOKEN', value: '', locked: true, secret: false }] }), + redactions: RULES, + } + const entrySnapshot = JSON.stringify(entry) + const localSnapshot = JSON.stringify(local) + render(entry, local) + expect(JSON.stringify(entry)).toBe(entrySnapshot) + expect(JSON.stringify(local)).toBe(localSnapshot) + }) + + it('shows a plain edit folded into a secret change beside the marked secret', () => { + const { local } = scenario({ + environmentVariables: [ + { key: 'TOKEN', value: 'rotated-plaintext', locked: true }, + { key: 'REGION', value: 'us', locked: false }, + ], + }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [ + { path: '/codeBundle', origin: 'code', cause: 'code bundle' }, + { + path: '/environmentVariables', + origin: 'code', + secret: true, + before: [{ key: 'TOKEN', value: CHANGED, locked: true }, { key: 'REGION', value: 'eu', locked: false }], + after: [{ key: 'TOKEN', value: CHANGED, locked: true }, { key: 'REGION', value: 'us', locked: false }], + }, + ], + before: deployedVars(), + redactions: RULES, + }, + local, + ) + const text = lines.join('\n') + expect(text).toContain('+ value: \'******** (changed)\',') + expect(text).toContain('- value: \'eu\',') + expect(text).toContain('+ value: \'us\',') + expect(text).not.toContain('rotated-plaintext') + expect(lines).toContain('/codeBundle: changed (code bundle)') + expect(text).not.toContain('secret changed') + }) + + it('marks a secret rotated in Checkly on the deployed side, by key, whatever the row\'s order', () => { + const { local } = scenario({ + environmentVariables: [ + { key: 'API_KEY', value: 'k', secret: true }, + { key: 'REGION', value: 'eu', locked: false }, + ], + }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ + path: '/environmentVariables', + origin: 'remote', + secret: true, + before: [{ key: 'REGION', value: 'eu' }, { key: 'API_KEY', value: CHANGED, secret: true }], + after: [{ key: 'REGION', value: 'eu' }, { key: 'API_KEY', value: CHANGED, secret: true }], + }], + before: deployed({ + environmentVariables: [ + { key: 'API_KEY', value: '', locked: false, secret: true }, + { key: 'REGION', value: 'eu', locked: false, secret: false }, + ], + }), + redactions: RULES, + }, + local, + ) + const text = lines.join('\n') + expect(text).toContain('- value: \'******** (changed in Checkly)\',') + expect(text).toContain('+ value: \'********\',') + // A `secret: true` variable prints its masked value beside the flag. + expect(text).toContain('secret: true') + expect(text).not.toContain('secret changed') + // The deploy overwrites it, which the inline mark alone does not say. + expect(lines.at(-1)).toBe('/environmentVariables: (changed in Checkly, overwritten by this deploy)') + }) + + it('marks a rotated scalar secret at its own path', () => { + const { local } = scenario({ + request: { url: 'https://example.com/health', method: 'GET', basicAuth: { username: 'svc', password: 'rotated' } }, + }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/request/basicAuth/password', origin: 'code', secret: true, before: CHANGED, after: CHANGED }], + before: deployed({ + request: { + url: 'https://example.com/health', + method: 'GET', + headers: [], + queryParameters: [], + assertions: [], + basicAuth: { username: 'svc', password: '' }, + }, + }), + redactions: RULES, + }, + local, + ) + const text = lines.join('\n') + expect(text).not.toContain('rotated') + expect(text).toContain('+ password: \'******** (changed)\',') + expect(text).not.toContain('secret changed') + }) + + it('names a secret change after the block when no mark could be placed', () => { + const { local } = scenario({ + environmentVariables: [{ key: 'NEW', value: 'rotated-plaintext', locked: true }], + }) + // Renamed: the report matches nothing, the list shows the swap, masked. + const renamed = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ + path: '/environmentVariables', + origin: 'code', + secret: true, + before: [{ key: 'OLD', value: SAME, locked: true }], + after: [{ key: 'NEW', value: SAME, locked: true }], + }], + before: deployed({ environmentVariables: [{ key: 'OLD', value: '', locked: true, secret: false }] }), + redactions: RULES, + }, + local, + ) + const text = renamed.join('\n') + expect(text).not.toContain('rotated-plaintext') + expect(text).toContain('- key: \'OLD\',') + expect(text).toContain('+ key: \'NEW\',') + expect(text).not.toContain('\'\'') + expect(renamed.at(-1)).toBe('secret changed: /environmentVariables') + // An API that reports no markers at all: masked both sides, named after. + const { local: same } = scenario({ environmentVariables: [{ key: 'TOKEN', value: 'plaintext-token', locked: true }] }) + const unmarked = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/environmentVariables', origin: 'code', secret: true }], + before: deployed({ environmentVariables: [{ key: 'TOKEN', value: '', locked: true, secret: false }] }), + redactions: RULES, + }, + same, + ) + expect(unmarked).toEqual(['secret changed: /environmentVariables']) + }) + + it('does not render a secret change that no reported redaction rule reaches', () => { + const { local } = scenario({ + environmentVariables: [ + { key: 'TOKEN', value: 'rotated-plaintext', locked: true }, + { key: 'REGION', value: 'us', locked: false }, + ], + }) + const entry: DiffEntry = { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [ + { path: '/request/url', origin: 'code', before: 'https://example.com/health', after: 'https://example.com/v2/health' }, + { + path: '/environmentVariables', + origin: 'code', + secret: true, + before: [{ key: 'TOKEN', value: CHANGED, locked: true }, { key: 'REGION', value: 'eu' }], + after: [{ key: 'TOKEN', value: CHANGED, locked: true }, { key: 'REGION', value: 'us' }], + }, + ], + before: deployedVars(), + redactions: [{ path: '/request/basicAuth/password', kind: 'value' }], + } + const lines = render(entry, local) + expect(lines.join('\n')).not.toContain('rotated-plaintext') + expect(lines).toEqual([ + '/request/url: "https://example.com/health" -> "https://example.com/v2/health"', + 'secret changed: /environmentVariables', + ]) + // A reorder carries markers without the flag, and is guarded the same way. + const reorder = render( + { + ...entry, + changes: [{ + path: '/environmentVariables', + origin: 'code', + before: [{ key: 'TOKEN', value: SAME, locked: true }, { key: 'REGION', value: 'eu' }], + after: [{ key: 'REGION', value: 'eu' }, { key: 'TOKEN', value: SAME, locked: true }], + }], + }, + local, + ) + expect(reorder.join('\n')).not.toContain('rotated-plaintext') + expect(reorder[0]).toContain('/environmentVariables: ') + expect(reorder[0]).toContain('"********"') + expect(reorder[0]).not.toContain('$masked') + // A rule above the path reaches it as well as one below: an object rule + // over basicAuth blanks the whole block on both sides, so the password's + // mark has nowhere to land and the line carries it, but the entry renders. + const { local: withAuth } = scenario({ + request: { url: 'https://example.com/v2/health', method: 'GET', basicAuth: { username: 'svc', password: 'rotated' } }, + }) + const above = render( + { + ...entry, + changes: [ + entry.changes![0], + { path: '/request/basicAuth/password', origin: 'code', secret: true, before: CHANGED, after: CHANGED }, + ], + before: deployed({ + request: { + url: 'https://example.com/health', + method: 'GET', + headers: [], + queryParameters: [], + assertions: [], + basicAuth: { username: 'svc', password: '' }, + }, + }), + redactions: [{ path: '/request/basicAuth', kind: 'object' }], + }, + withAuth, + ) + expect(above.join('\n')).toContain('--- deployed') + expect(above.join('\n')).not.toContain('rotated') + expect(above.at(-1)).toBe('secret changed: /request/basicAuth/password') + }) + + it('shows a variable flipped to locked as its plain value becoming a marked mask', () => { + const { local } = scenario({ + environmentVariables: [{ key: 'REGION', value: 'now-locked', locked: true }], + }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ + path: '/environmentVariables', + origin: 'code', + secret: true, + before: [{ key: 'REGION', value: CHANGED, locked: false }], + after: [{ key: 'REGION', value: CHANGED, locked: true }], + }], + before: deployed({ environmentVariables: [{ key: 'REGION', value: 'eu', locked: false, secret: false }] }), + redactions: RULES, + }, + local, + ) + const text = lines.join('\n') + expect(text).not.toContain('now-locked') + expect(text).toContain('- value: \'eu\',') + expect(text).toContain('+ value: \'******** (changed)\',') + expect(text).not.toContain('secret changed') + }) + + it('names a secret change after the block when its mark sits where nothing renders', () => { + // A basicAuth block with no username is not printed at all, so a placed + // mark never reaches the reader and the line carries the movement. + const { local } = scenario({ + request: { url: 'https://example.com/health', method: 'GET', basicAuth: { username: '', password: 'rotated' } }, + }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/request/basicAuth/password', origin: 'code', secret: true, before: CHANGED, after: CHANGED }], + before: deployed({ + request: { + url: 'https://example.com/health', + method: 'GET', + headers: [], + queryParameters: [], + assertions: [], + basicAuth: { username: '', password: '' }, + }, + }), + redactions: RULES, + }, + local, + ) + expect(lines.join('\n')).not.toContain('rotated') + expect(lines).toEqual(['secret changed: /request/basicAuth/password']) + }) + + it('names only the secret change whose mark did not reach the reader, when another did', () => { + const { local } = scenario({ + environmentVariables: [{ key: 'TOKEN', value: 'rotated-plaintext', locked: true }], + request: { url: 'https://example.com/health', method: 'GET', basicAuth: { username: '', password: 'rotated' } }, + }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [ + { + path: '/environmentVariables', + origin: 'code', + secret: true, + before: [{ key: 'TOKEN', value: CHANGED, locked: true }], + after: [{ key: 'TOKEN', value: CHANGED, locked: true }], + }, + { path: '/request/basicAuth/password', origin: 'code', secret: true, before: CHANGED, after: CHANGED }, + ], + before: deployed({ + environmentVariables: [{ key: 'TOKEN', value: '', locked: true, secret: false }], + request: { + url: 'https://example.com/health', + method: 'GET', + headers: [], + queryParameters: [], + assertions: [], + basicAuth: { username: '', password: '' }, + }, + }), + redactions: RULES, + }, + local, + ) + const text = lines.join('\n') + expect(text).toContain('+ value: \'******** (changed)\',') + expect(text).not.toContain('#') + expect(lines.filter(line => line.startsWith('secret changed'))).toEqual(['secret changed: /request/basicAuth/password']) + }) + + it('prints a secret: true header or query parameter only as its mask under the preview', () => { + // Neither is covered by the API's rule table; the codegen itself never + // prints such a value under the preview's flag. + const { local } = scenario({ + request: { + url: 'https://example.com/v2/health', + method: 'GET', + headers: [{ key: 'Authorization', value: '********-raw-header-secret', secret: true }], + queryParameters: [{ key: 'token', value: 'raw-query-secret', secret: true }], + }, + }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ + path: '/request/url', + origin: 'code', + before: 'https://example.com/health', + after: 'https://example.com/v2/health', + }], + before: deployed(), + redactions: RULES, + }, + local, + ) + const text = lines.join('\n') + // A plaintext that merely looks masked is not one the preview wrote. + expect(text).not.toContain('raw-header-secret') + expect(text).not.toContain('raw-query-secret') + expect(text).toContain('value: \'********\'') + }) + + it('shows a content change as a text diff of the two texts when the constructs render alike', () => { + const body = `{"payload":"${'a'.repeat(300)}"}` + const edited = `{"payload":"${'b'.repeat(300)}"}` + const { local } = scenario({ request: { url: 'https://example.com/health', method: 'POST', body: edited } }) + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/request/body', origin: 'code', before: { $hash: 'x' }, after: { $hash: 'y' } }], + before: deployed({ request: { url: 'https://example.com/health', method: 'POST', body, headers: [], queryParameters: [], assertions: [] } }), + redactions: [], + }, + local, + ) + const text = lines.join('\n') + // The construct itself renders the body, so this is the construct diff. + expect(text).toContain('a'.repeat(300)) + expect(text).toContain('b'.repeat(300)) + }) + + it('falls back to a listing, with the reason, when a side cannot be shaped', () => { + const { local } = scenario() + const check = local.find(resource => resource.logicalId === 'api') as ResourceSync + check.payload = { ...check.payload, groupId: { ref: 'unknown-group' } } + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/name', origin: 'remote', before: 'API', after: 'Renamed in the UI' }], + before: deployed(), + redactions: [], + }, + local, + ) + expect(lines[0]).toMatch(/^\('groupId' refers to check-group 'unknown-group'/) + expect(lines[1]).toBe('/name: "API" -> "Renamed in the UI" (changed in Checkly, overwritten by this deploy)') + }) + + it('falls back to a listing when the codegen throws, and never throws itself', () => { + const { local } = scenario() + const check = local.find(resource => resource.logicalId === 'api') as ResourceSync + check.payload = { ...check.payload, checkType: 'PLAYWRIGHT' } + const lines = render( + { + type: 'check', + logicalId: 'api', + action: 'UPDATE', + changes: [{ path: '/name', origin: 'code', before: 'API', after: 'Suite' }], + before: deployed({ checkType: 'PLAYWRIGHT' }), + redactions: [], + }, + local, + ) + expect(lines[0]).toMatch(/^\(could not render this resource: /) + expect(lines).toContain('/name: "API" -> "Suite"') + }) + + it('lists the changes when the entry carries no deployed state', () => { + const { local } = scenario() + expect(render( + { type: 'check', logicalId: 'api', action: 'UPDATE', changes: [{ path: '/name', origin: 'code', before: 'API', after: 'New' }] }, + local, + )).toEqual(['/name: "API" -> "New"']) + }) + + it('keeps an unmanaged subscription on both sides, and drops it under --prune-relations', () => { + const { local } = scenario() + const entry: DiffEntry = { + type: 'check', + logicalId: 'api', + physicalId: 'check-uuid', + action: 'UNCHANGED', + changes: [ + { path: '/name', origin: 'code', before: 'API', after: 'API' }, + { path: '/alertChannels/x', origin: 'unmanaged', before: { alertChannel: { $id: '99' }, activated: true } }, + ], + before: deployed({ + alertChannelSubscriptions: [ + { id: 1, alertChannelId: 7, checkId: 'check-uuid', activated: true }, + { id: 2, alertChannelId: 99, checkId: 'check-uuid', activated: true }, + ], + }), + redactions: [], + } + // Kept: both sides carry it, so the renderings agree and nothing about + // the channel is a changed line. + const kept = render(entry, local) + expect(kept.filter(line => line.startsWith('-') || line.startsWith('+'))).toEqual([]) + const pruned = render(entry, local, [ + { type: 'alert-channel-subscription', logicalId: 'unmanaged/check/api/2', physicalId: 2, action: 'DELETE', origin: 'unmanaged', foldedInto: { type: 'check', logicalId: 'api' } }, + ], true).join('\n') + expect(pruned).toContain('- AlertChannel.fromId(99)') + }) +}) diff --git a/packages/cli/src/services/deploy-diff/__tests__/unified-diff.spec.ts b/packages/cli/src/services/deploy-diff/__tests__/unified-diff.spec.ts new file mode 100644 index 000000000..04e40c391 --- /dev/null +++ b/packages/cli/src/services/deploy-diff/__tests__/unified-diff.spec.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest' + +import { unifiedDiff } from '../unified-diff.js' + +const lines = (count: number, from = 1) => + Array.from({ length: count }, (_, index) => `line${index + from}`).join('\n') + '\n' + +describe('unifiedDiff()', () => { + it('reports nothing for identical texts', () => { + expect(unifiedDiff('a\nb\n', 'a\nb\n')).toEqual([]) + }) + + it('reports nothing when the texts differ only by a trailing newline', () => { + // A construct rendered with and without a final newline is the same + // construct; reporting it would put a change on every resource. + expect(unifiedDiff('a\nb\n', 'a\nb')).toEqual([]) + expect(unifiedDiff('a\nb', 'a\nb\n')).toEqual([]) + }) + + it('renders an insertion with its context', () => { + expect(unifiedDiff('a\nb\nc\n', 'a\nb\nx\nc\n')).toEqual([ + '--- deployed', + '+++ local', + '@@ -1,3 +1,4 @@', + ' a', + ' b', + '+x', + ' c', + ]) + }) + + it('renders a deletion with its context', () => { + expect(unifiedDiff('a\nb\nc\n', 'a\nc\n')).toEqual([ + '--- deployed', + '+++ local', + '@@ -1,3 +1,2 @@', + ' a', + '-b', + ' c', + ]) + }) + + it('renders a replaced line as a deletion followed by an insertion', () => { + expect(unifiedDiff('a\nb\nc\n', 'a\nB\nc\n')).toEqual([ + '--- deployed', + '+++ local', + '@@ -1,3 +1,3 @@', + ' a', + '-b', + '+B', + ' c', + ]) + }) + + // Every expected header in this block was taken from `git diff --no-index` + // on the same two inputs, at the same context. + describe('hunk headers, against git diff', () => { + it('omits the count for a single-line side', () => { + expect(unifiedDiff('a\n', 'b\n')).toEqual([ + '--- deployed', + '+++ local', + '@@ -1 +1 @@', + '-a', + '+b', + ]) + }) + + it('omits it on one side only when only that side spans one line', () => { + expect(unifiedDiff('a\n', 'a\nb\n')?.[2]).toEqual('@@ -1 +1,2 @@') + expect(unifiedDiff('a\nb\nc\n', 'a\n')?.[2]).toEqual('@@ -1,3 +1 @@') + }) + + it('numbers an empty range at the lines that precede it', () => { + // At context 0 nothing pads the hunk, so the empty side's own number is + // what is printed. `diff -U0` gives each of these headers. + expect(unifiedDiff('a\nb\nc\n', 'a\nx\nb\nc\n', { context: 0 })).toEqual([ + '--- deployed', + '+++ local', + '@@ -1,0 +2 @@', + '+x', + ]) + expect(unifiedDiff('a\nb\nc\nd\n', 'a\nb\nc\nd\ne\n', { context: 0 })?.[2]) + .toEqual('@@ -4,0 +5 @@') + }) + + it('numbers an empty range before the first line as 0, the way git does', () => { + // Nothing precedes the change, so the empty side is line 0. BSD diff + // (the macOS binary) says `1,0` here; git and GNU diff say `0,0`, and + // these expectations came from `git diff --no-index -U0`. + expect(unifiedDiff('a\nb\nc\n', 'x\na\nb\nc\n', { context: 0 })?.[2]) + .toEqual('@@ -0,0 +1 @@') + expect(unifiedDiff('a\nb\nc\n', 'b\nc\n', { context: 0 })?.[2]) + .toEqual('@@ -1 +0,0 @@') + }) + }) + + it('numbers an empty side at the line it follows, like diff -u', () => { + expect(unifiedDiff('', 'a\nb\n')).toEqual([ + '--- deployed', + '+++ local', + '@@ -0,0 +1,2 @@', + '+a', + '+b', + ]) + expect(unifiedDiff('a\nb\n', '')).toEqual([ + '--- deployed', + '+++ local', + '@@ -1,2 +0,0 @@', + '-a', + '-b', + ]) + }) + + it('keeps three lines of context around a change in the middle', () => { + const before = lines(11) + const after = before.replace('line6\n', 'six\n') + + expect(unifiedDiff(before, after)).toEqual([ + '--- deployed', + '+++ local', + '@@ -3,7 +3,7 @@', + ' line3', + ' line4', + ' line5', + '-line6', + '+six', + ' line7', + ' line8', + ' line9', + ]) + }) + + it('merges two changes whose context regions touch into one hunk', () => { + const before = lines(12) + const after = before.replace('line2\n', 'two\n').replace('line5\n', 'five\n') + + expect(unifiedDiff(before, after)).toEqual([ + '--- deployed', + '+++ local', + '@@ -1,8 +1,8 @@', + ' line1', + '-line2', + '+two', + ' line3', + ' line4', + '-line5', + '+five', + ' line6', + ' line7', + ' line8', + ]) + }) + + it('keeps two distant changes in separate hunks', () => { + const before = lines(30) + const after = before.replace('line3\n', 'three\n').replace('line20\n', 'twenty\n') + + const result = unifiedDiff(before, after) + + expect(result?.filter(line => line.startsWith('@@'))).toEqual([ + '@@ -1,6 +1,6 @@', + '@@ -17,7 +17,7 @@', + ]) + // Nothing between the hunks is printed: that is the point of the split. + expect(result).not.toContain(' line12') + }) + + it('honours the context option', () => { + const before = lines(11) + const after = before.replace('line6\n', 'six\n') + + expect(unifiedDiff(before, after, { context: 1 })).toEqual([ + '--- deployed', + '+++ local', + '@@ -5,3 +5,3 @@', + ' line5', + '-line6', + '+six', + ' line7', + ]) + }) + + it('names the two sides as asked', () => { + const result = unifiedDiff('a\n', 'b\n', { beforeLabel: 'in Checkly', afterLabel: 'in code' }) + + expect(result?.slice(0, 2)).toEqual(['--- in Checkly', '+++ in code']) + }) + + it('declines a comparison larger than maxLines', () => { + // The comparison is quadratic in the lines that differ, and a caller that + // gets nothing back has a coarser listing to print instead. + expect(unifiedDiff(lines(5), lines(5, 100), { maxLines: 4 })).toBeUndefined() + expect(unifiedDiff(lines(4), lines(4, 100), { maxLines: 4 })).not.toBeUndefined() + }) + + it('declines rather than throwing when a raised maxLines would overflow the comparison', () => { + // The cost is the product of the two sides, not their length, so a caller + // that tunes maxLines must still get the documented `undefined` back and + // not a RangeError from the typed array. + let result: string[] | undefined + expect(() => { + result = unifiedDiff(lines(3000), lines(3000, 10_000), { maxLines: 5000 }) + }).not.toThrow() + expect(result).toBeUndefined() + }) + + it('still compares two sides that differ only in the middle of a large text', () => { + // The head and tail trim is what keeps a realistic comparison well inside + // the cell budget: these are 3000 lines a side, but only one differs. + const before = lines(3000) + const after = before.replace('line1500\n', 'changed\n') + + expect(unifiedDiff(before, after, { maxLines: 5000 })?.[2]).toEqual('@@ -1497,7 +1497,7 @@') + }) + + it('compares texts that share no lines at all', () => { + expect(unifiedDiff('a\nb\n', 'x\ny\n')).toEqual([ + '--- deployed', + '+++ local', + '@@ -1,2 +1,2 @@', + '-a', + '-b', + '+x', + '+y', + ]) + }) +}) diff --git a/packages/cli/src/services/deploy-diff/import-shape.ts b/packages/cli/src/services/deploy-diff/import-shape.ts new file mode 100644 index 000000000..b27bdc538 --- /dev/null +++ b/packages/cli/src/services/deploy-diff/import-shape.ts @@ -0,0 +1,749 @@ +import type { Resource, ResourceType } from '../../constructs/construct-codegen.js' +import { type Context, MASKED_VALUE } from '../../constructs/internal/codegen/index.js' +import type { Project } from '../../constructs/project.js' +import type { DiffChange, DiffEntry, DiffMaskedMarker, DiffRedaction, ResourceSync } from '../../rest/projects.js' +import type { Program } from '../../sourcegen/index.js' + +/** + * Shapes the local side of a deploy preview like an import resource — the + * payload `checkly import` receives per resource and the construct codegens + * read — so it can be rendered by the same codegen as the deployed side and + * the two diffed as text. + * + * The deployed side needs no shaping: a preview's `before` (`detail: 'full'`) + * IS the import format, straight from the API. The local side is the payload + * this CLI synthesized for the deploy, which differs from the import format in + * exactly the ways `toImportResource` bridges: it has no `id`; it references + * other resources by logical id (`{ ref }`) where the import format carries + * physical ids; its relations are separate subscription and assignment + * resources, which the codegen folds onto the parent by registering them; it + * keeps a check's intent constraints in the author's order where the stored + * form groups them; and it carries a few keys only a deploy reads. + * + * Nothing here canonicalizes nulls, defaults or ordering beyond that. The + * local payload is well-formed by construction, and the deployed side comes + * from the same function the import plan uses, which the codegen was written + * for. A payload that cannot be shaped throws `UnshapeableError`; the + * renderer catches it and lists the changes instead. + */ + +/** Physical ids keyed by `${type}:${logicalId}`. */ +export type PhysicalIds = ReadonlyMap + +export function idKey (type: string, logicalId: string): string { + return `${type}:${logicalId}` +} + +/** + * The segments of an RFC 6901 pointer, unescaped; the root pointer has none. + * + * @throws UnshapeableError for a string that is not a pointer, rather than + * matching it against nothing. + */ +export function pointerSegments (pointer: string): string[] { + if (pointer === '') { + return [] + } + if (!pointer.startsWith('/')) { + throw new UnshapeableError(`'${pointer}' is not a JSON Pointer`) + } + return pointer.slice(1).split('/').map(segment => segment.replace(/~1/g, '/').replace(/~0/g, '~')) +} + +/** A payload this module cannot turn into an import resource; the message says why. */ +export class UnshapeableError extends Error {} + +/** Types whose physical id is a number; every other type's is a string. */ +const NUMERIC_ID_TYPES: ReadonlySet = new Set(['check-group', 'alert-channel', 'alert-channel-subscription']) + +/** + * Above every real numeric id, so a synthetic one can never be mistaken for + * (or collide with) a resource that exists. + */ +const SYNTHETIC_ID_BASE = 1_000_000_000_000 + +/** + * A stable id for a resource that has none yet (it is being created), derived + * from its identity rather than counted: two shapings of the same project + * must number the same resource the same way, whatever order they run in. + * FNV-1a over the key, offset above every real id for numeric types; string + * ids reuse the logical id, which no UUID can equal. + */ +function syntheticId (type: string, logicalId: string): string | number { + if (!NUMERIC_ID_TYPES.has(type)) { + return `synthetic:${logicalId}` + } + let hash = 0x811c9dc5 + for (const char of `${type}\0${logicalId}`) { + hash ^= char.codePointAt(0) as number + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return SYNTHETIC_ID_BASE + hash +} + +/** + * Every resource's physical id, for the rendering: the preview's entries + * carry the id of everything that exists (including a resource the code only + * references, whose `fromId` construct sends it), and a resource being created + * gets a synthetic one so the codegen has something to key its registrations + * on. Every local resource has an entry afterwards; this is the one place an + * id is minted. + */ +export function physicalIdsFromPlan (diff: readonly DiffEntry[], local: readonly ResourceSync[]): PhysicalIds { + const ids = new Map() + for (const entry of diff) { + if (entry.physicalId !== undefined) { + ids.set(idKey(entry.type, entry.logicalId), entry.physicalId) + } + } + for (const resource of local) { + const key = idKey(resource.type, resource.logicalId) + if (!ids.has(key)) { + ids.set(key, resource.physicalId ?? syntheticId(resource.type, resource.logicalId)) + } + } + return ids +} + +/** + * The payload keys that hold a reference (`{ ref: logicalId }` in a deploy + * payload, a physical id in the import format), and the type they point at. + * `services` is a list of references on a status page card, and its codegen + * reads each element's `id`, so it is substituted to `{ id }` objects rather + * than bare ids. + */ +const REFERENCE_KEYS: Readonly> = { + alertChannelId: 'alert-channel', + checkId: 'check', + componentId: 'status-page-component', + groupId: 'check-group', + parentId: 'status-page-component', + privateLocationId: 'private-location', + serviceId: 'status-page-service', + services: 'status-page-service', + statusPageId: 'status-page', +} + +/** Keys only a deploy reads; the import format has no counterpart and the codegen would ignore them. */ +const DEPLOY_ONLY_KEYS = ['sourceFile', 'codeBundleSha256', 'privateLocations', 'v'] as const + +/** + * Keys of a deployed row that neither side renders: a setup or teardown + * snippet reference. The codegen resolves one through snippet files an + * import registers and a preview has not, a deploy clears it either way, and + * it is a bookkeeping column the plan never reports — so left in `before`, + * `fillUnchangedFromBefore` would copy it onto the local side. + */ +export const UNRENDERED_KEYS = ['setupSnippetId', 'tearDownSnippetId'] as const + +/** Keys of a deployed row that are not properties of the local payload: its id, and the relation rows it carries. */ +const NOT_FILLED: ReadonlySet = new Set(['id', 'alertChannelSubscriptions', 'privateLocationAssignments']) + +/** A deployed key whose change the plan reports under the deploy payload's own spelling. */ +const REPORTED_AS: Readonly> = { '/agenticCheckData': '/agentRuntime' } + +const escapeSegment = (segment: string) => segment.replace(/~/g, '~0').replace(/\//g, '~1') + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +/** + * Fills into the shaped local payload every property the deployed row holds + * that the payload leaves out and the plan does not report as changed. Those + * are the defaults the deploy's validation fills into an absent property + * (`activated: true`, a check type's response-time limits, an empty header + * list), which the row then always carries and the codegen prints: the plan + * compared the validated payload against the row, so a property absent here + * and unreported there is one the deploy leaves as it is. Taking it from + * `before` makes the two renderings agree on it without this CLI keeping a + * copy of any default — and cannot hide a change, since a value copied to + * both sides prints alike on both. + * + * A key is left alone when a reported change path is it, lies under it, or + * lies above it; an explicit `null` is a value, not an absence; and only + * object keys are filled, never the elements of a list. + */ +export function fillUnchangedFromBefore ( + local: Record, + before: Record, + changes: readonly DiffChange[], +): void { + const reported = changes.map(change => change.path) + const touched = (pointer: string) => + reported.some(path => path === pointer || path.startsWith(`${pointer}/`) || pointer.startsWith(`${path}/`)) + const fill = (target: Record, source: Record, prefix: string) => { + for (const [key, value] of Object.entries(source)) { + if (prefix === '' && NOT_FILLED.has(key)) { + continue + } + const pointer = `${prefix}/${escapeSegment(key)}` + const current = target[key] + if (current === undefined) { + if (!touched(REPORTED_AS[pointer] ?? pointer)) { + target[key] = structuredClone(value) + } + } else if (isPlainObject(current) && isPlainObject(value)) { + fill(current, value, pointer) + } + } + } + fill(local, before, '') +} + +function isRef (value: unknown): value is { ref: string } { + return ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) + && Object.keys(value).length === 1 + && typeof (value as { ref?: unknown }).ref === 'string' + ) +} + +function resolveRef (ids: PhysicalIds, key: string, ref: { ref: string }): string | number { + const id = ids.get(idKey(REFERENCE_KEYS[key], ref.ref)) + if (id === undefined) { + throw new UnshapeableError(`'${key}' refers to ${REFERENCE_KEYS[key]} '${ref.ref}', which this plan does not know`) + } + return id +} + +/** + * Rebuild a payload subtree with references substituted. Only a JSON-shaped + * value is accepted: the codegen renders values, and an invalid `Date` or a + * function would either throw mid-render or print nonsense. A value that + * serializes itself (`toJSON`) is taken as what it serializes to, which is + * what the deploy sends. + */ +function substitute (value: unknown, key: string | undefined, ids: PhysicalIds): unknown { + if (value === null || value === undefined) { + return value + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value + } + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) { + throw new UnshapeableError(`'${key}' holds an invalid date`) + } + return value.toISOString() + } + if (Array.isArray(value)) { + if (key === 'services') { + return value.map(element => + isRef(element) ? { id: resolveRef(ids, key, element) } : substitute(element, key, ids), + ) + } + if (key === 'cards') { + // A card's `services` may be left out (or left undefined) by the + // construct; the import format always carries the list and the card + // codegen iterates it unguarded. + return value.map(element => { + const card = substitute(element, key, ids) + return card !== null && typeof card === 'object' && !Array.isArray(card) && !('services' in card) + ? { ...card, services: [] } + : card + }) + } + return value.map(element => substitute(element, key, ids)) + } + if (typeof value !== 'object') { + throw new UnshapeableError(`'${key}' holds a ${typeof value}`) + } + if (isRef(value) && key !== undefined && key in REFERENCE_KEYS) { + return resolveRef(ids, key, value) + } + if (isRef(value)) { + throw new UnshapeableError(`'${key}' holds a reference this shaping does not know how to place`) + } + if (typeof (value as { toJSON?: unknown }).toJSON === 'function') { + return substitute((value as { toJSON: () => unknown }).toJSON(), key, ids) + } + const result: Record = {} + for (const [childKey, child] of Object.entries(value)) { + if (child !== undefined) { + result[childKey] = substitute(child, childKey, ids) + } + } + return result +} + +/** + * A check's intent as the backend stores and the import format returns it: + * the constraints grouped by type, required outcomes first, in the order the + * author gave within each group. The construct keeps the author's + * interleaving, which the store does not. + */ +function groupConstraints (intent: unknown): unknown { + if (intent === null || typeof intent !== 'object' || !Array.isArray((intent as { constraints?: unknown }).constraints)) { + return intent + } + const { constraints, ...rest } = intent as { constraints: Array<{ type?: unknown }> } + const required: unknown[] = [] + const preserved: unknown[] = [] + // A type this CLI does not know keeps its place after the known groups + // rather than vanishing from the rendering. + const others: unknown[] = [] + for (const constraint of constraints) { + const bucket = constraint?.type === 'REQUIRED_OUTCOME' ? required : constraint?.type === 'MUST_PRESERVE' ? preserved : others + bucket.push(constraint) + } + return { ...rest, constraints: [...required, ...preserved, ...others] } +} + +/** + * The local payload of one resource, as an import resource: `id` set, every + * reference a physical id, deploy-only keys dropped, the intent's constraints + * grouped as stored, and the agentic runtime under the name the codegen reads + * (`agenticCheckData`, which is how the backend stores what the deploy + * payload calls `agentRuntime`). No canonicalization of nulls, defaults or + * ordering: the payload has none, and the defaults the deploy would fill + * are taken from the deployed row by `fillUnchangedFromBefore`. + * + * @throws UnshapeableError for a payload the codegen could not render. + */ +export function toImportResource ( + type: ResourceType, + logicalId: string, + payload: unknown, + ids: PhysicalIds, +): Resource { + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + throw new UnshapeableError('the payload is not an object') + } + const id = ids.get(idKey(type, logicalId)) + if (id === undefined) { + throw new UnshapeableError(`${type} '${logicalId}' has no id in this plan`) + } + const shaped = substitute(payload, undefined, ids) as Record + for (const key of DEPLOY_ONLY_KEYS) { + delete shaped[key] + } + if ('intent' in shaped) { + shaped.intent = groupConstraints(shaped.intent) + } + if ('agentRuntime' in shaped) { + const runtime = shaped.agentRuntime as { skills?: unknown } | null | undefined + shaped.agenticCheckData = runtime && typeof runtime === 'object' ? { skills: runtime.skills ?? null } : null + delete shaped.agentRuntime + } + shaped.id = id + return { type, logicalId, payload: shaped } +} + +interface RelationKinds { + parentKey: 'checkId' | 'groupId' + subscription: ResourceType + assignment: ResourceType +} + +/** The relation types a parent of each kind carries, and the parent key on their rows. */ +const RELATIONS: Readonly> = { + 'check': { + parentKey: 'checkId', + subscription: 'alert-channel-subscription', + assignment: 'private-location-check-assignment', + }, + 'check-group': { + parentKey: 'groupId', + subscription: 'alert-channel-subscription', + assignment: 'private-location-group-assignment', + }, +} + +interface RelationRow { + id?: string | number + alertChannelId?: string | number + privateLocationId?: string | number + checkId?: string | number + groupId?: string | number + activated?: boolean +} + +/** The target a relation row or resource points at, for matching the two sides. */ +function relationTarget (payload: RelationRow): string { + return payload.alertChannelId !== undefined + ? `alert-channel:${payload.alertChannelId}` + : `private-location:${payload.privateLocationId}` +} + +/** + * Relations in one stable order, by target, on whichever side they come + * from: the codegen folds them into `alertChannels`/`privateLocations` in + * registration order, and the deployed rows' order (creation order) means + * nothing the diff should show. + */ +function inTargetOrder (resources: Resource[]): Resource[] { + return [...resources].sort((a, b) => + relationTarget(a.payload as RelationRow).localeCompare(relationTarget(b.payload as RelationRow)), + ) +} + +/** + * The relation rows a parent's `before` carries — every live subscription + * and assignment, the way the import format spells them — as the resources + * whose `prepare()` registers them on the codegen context. + */ +export function relationResourcesFromBefore (type: string, before: Record): Resource[] { + const kinds = RELATIONS[type] + if (!kinds) { + return [] + } + const rows = (key: string): RelationRow[] => (Array.isArray(before[key]) ? (before[key] as RelationRow[]) : []) + return inTargetOrder([ + ...rows('alertChannelSubscriptions').map(row => ({ + type: kinds.subscription, + logicalId: `deployed:${kinds.subscription}:${row.id}`, + payload: row, + })), + ...rows('privateLocationAssignments').map(row => ({ + type: kinds.assignment, + logicalId: `deployed:${kinds.assignment}:${row.id}`, + payload: row, + })), + ]) +} + +export interface AfterRelationsInput { + ids: PhysicalIds + /** Every resource of the local deploy payload. */ + local: readonly ResourceSync[] + /** The parent's own preview entry: its type, logical id and `before`. */ + entry: DiffEntry + /** The whole preview, for the relation entries folded into this parent. */ + diff: readonly DiffEntry[] + pruneRelations: boolean +} + +/** + * The relation resources the local side renders with: the project's own + * subscription and assignment constructs for this parent, plus every deployed + * relation the deploy will leave in place. A deployed row is left out when the + * code removed it (a DELETE or DETACH relation entry folded into this parent + * names its row id), when `--prune-relations` will delete it (a pruned entry + * names it too), or when a local construct already stands for the same target. + * What remains is unmanaged: the deploy keeps it, so both sides show it. + * + * @throws UnshapeableError when one of the parent's own relations cannot be shaped. + */ +export function relationResourcesForAfter (input: AfterRelationsInput): Resource[] { + const { ids, local, entry, diff, pruneRelations } = input + const { type, logicalId } = entry + const kinds = RELATIONS[type] + if (!kinds) { + return [] + } + const ownRelations: Resource[] = [] + for (const resource of local) { + if (resource.type !== kinds.subscription && resource.type !== kinds.assignment) { + continue + } + const parentRef = (resource.payload as Record | null)?.[kinds.parentKey] + if (!isRef(parentRef) || parentRef.ref !== logicalId) { + continue + } + ownRelations.push(toImportResource(resource.type as ResourceType, resource.logicalId, resource.payload, ids)) + } + + // Subscriptions and assignments live in different tables, with independent + // ids, so a removed row is known by its type as well. + const removed = new Set() + for (const candidate of diff) { + const folded = candidate.foldedInto?.type === type && candidate.foldedInto.logicalId === logicalId + if (!folded) { + continue + } + const goes = candidate.action === 'DELETE' || candidate.action === 'DETACH' + if (!goes || (candidate.origin === 'unmanaged' && !pruneRelations)) { + continue + } + if (candidate.physicalId === undefined) { + throw new UnshapeableError(`the plan removes a ${candidate.type} of '${logicalId}' without saying which`) + } + removed.add(`${candidate.type}:${candidate.physicalId}`) + } + + const covered = new Set(ownRelations.map(resource => relationTarget(resource.payload as RelationRow))) + const kept = entry.before === undefined + ? [] + : relationResourcesFromBefore(type, entry.before).filter(resource => { + const row = resource.payload as RelationRow + return !removed.has(`${resource.type}:${row.id}`) && !covered.has(relationTarget(row)) + }) + return inTargetOrder([...ownRelations, ...kept]) +} + +const CONDITIONS: Record, (element: Record) => boolean> = { + locked: element => element.locked === true, + lockedOrSecret: element => element.locked === true || element.secret === true, +} + +/** What a redacted value renders as, on both sides: never an empty string, which could pass for a value. */ +export const MASK = MASKED_VALUE + +const placeholderOf = (rule: DiffRedaction) => (rule.kind === 'object' ? null : MASK) + +/** + * Whether a conditional rule applies to the element holding the value. A + * condition this CLI does not know (a newer API) applies regardless — the + * one direction that cannot print a credential. + */ +function qualifies (rule: DiffRedaction, element: unknown): boolean { + if (rule.when === undefined) { + return true + } + const condition = Object.hasOwn(CONDITIONS, rule.when) ? CONDITIONS[rule.when] : undefined + return condition === undefined || (element !== null && typeof element === 'object' && condition(element as Record)) +} + +function blank (node: unknown, segments: readonly string[], rule: DiffRedaction): unknown { + if (segments.length === 0) { + return node + } + const [segment, ...rest] = segments + if (segment === '*') { + // Every position: of a list, or of a map (a free-form header object). + // A trailing `*` names the positions themselves, each blanked when it + // qualifies. + const each = (element: unknown) => + (rest.length === 0 ? (qualifies(rule, element) ? placeholderOf(rule) : element) : blank(element, rest, rule)) + if (Array.isArray(node)) { + return node.map(each) + } + if (node !== null && typeof node === 'object') { + return Object.fromEntries(Object.entries(node as Record).map(([k, v]) => [k, each(v)])) + } + return node + } + if (node === null || typeof node !== 'object' || Array.isArray(node)) { + return node + } + const object = node as Record + const key = segment + if (rest.length > 0) { + const replaced = blank(object[key], rest, rule) + return replaced === object[key] ? node : { ...object, [key]: replaced } + } + if (rule.when === undefined) { + // Unconditional: blank a value that is there. + if (!(key in object) || object[key] === undefined) { + return node + } + } else if (!qualifies(rule, object)) { + // Conditional: the element's own flags decide, and the API blanks a + // qualifying element whether or not the value is there, so this side + // does too. + return node + } + // The placeholder the rule names, which is what the API wrote on its side. + return { ...object, [key]: placeholderOf(rule) } +} + +/** + * A payload with the API's redaction rules applied — the type's whole table, + * whatever the deployed row held — so a credential is masked on both sides + * (the deployed side arrives blanked and is masked the same way, since a + * blank could pass for a value) and never a phantom change, nor printed. A + * rule that matches nothing is simply ignored. No table at all is refused: + * an API that reports a `before` without one predates the rules, and the one + * direction this module must never take is printing a credential. + * + * @throws UnshapeableError when the API reported no rule table. + */ +export function blankRedacted (payload: T, redactions: readonly DiffRedaction[] | undefined): T { + if (redactions === undefined) { + throw new UnshapeableError('the preview reports no redaction rules for this resource') + } + let result: unknown = payload + for (const rule of redactions) { + result = blank(result, pointerSegments(rule.path), rule) + } + return result as T +} + +export function isMaskedMarker (value: unknown): value is DiffMaskedMarker { + return ( + typeof value === 'object' && value !== null && !Array.isArray(value) + && ((value as DiffMaskedMarker).$masked === 'same' || (value as DiffMaskedMarker).$masked === 'changed') + ) +} + +/** Every marker inside a reported value, with its segment path and whether it says `changed`. */ +interface MarkerPosition { + segments: string[] + changed: boolean +} + +function markerPositions (value: unknown, segments: readonly string[] = []): MarkerPosition[] { + if (isMaskedMarker(value)) { + return [{ segments: [...segments], changed: value.$masked === 'changed' }] + } + if (Array.isArray(value)) { + return value.flatMap((element, index) => markerPositions(element, [...segments, String(index)])) + } + if (value !== null && typeof value === 'object') { + return Object.entries(value as Record) + .flatMap(([key, member]) => markerPositions(member, [...segments, key])) + } + return [] +} + +/** Whether a reported value holds a marker anywhere inside it. */ +export const carriesMarker = (value: unknown): boolean => markerPositions(value).length > 0 + +const changedPositions = (value: unknown): string[][] => + markerPositions(value).filter(position => position.changed).map(position => position.segments) + +/** The value at `segments` inside `root`, or undefined where the path does not resolve. */ +export function nodeAt (root: unknown, segments: readonly string[]): unknown { + let node = root + for (const segment of segments) { + if (Array.isArray(node)) { + node = node[Number(segment)] + } else if (node !== null && typeof node === 'object') { + node = (node as Record)[segment] + } else { + return undefined + } + } + return node +} + +/** + * Overwrite the masked value at `segments` with `label`. Only a masked string + * is overwritten: a position the rules blank to `null` (a Playwright config + * subtree) has nothing to print, and one that is not masked at all is not + * this CLI's to touch. + */ +function label (root: unknown, segments: readonly string[], text: string): boolean { + const parent = nodeAt(root, segments.slice(0, -1)) + const last = segments[segments.length - 1] + if (last === undefined || parent === null || typeof parent !== 'object') { + return false + } + const container = parent as Record + if (container[last] !== MASK) { + return false + } + container[last] = text + return true +} + +const keyOf = (element: unknown): string | undefined => { + const key = (element as { key?: unknown } | null)?.key + return typeof key === 'string' ? key : undefined +} + +/** + * Write `text` over the masked value of every element the report marks + * `changed`, in the payload's list at `path`. A reported element is matched + * to the payload's by `key` when that key is unique in both, else by index + * when the key at that index is the same and the lists are equal in length, + * else not at all — the deployed row's list can be ordered differently from + * the report, so an index alone is never trusted, and a name is never + * wrong. A scalar marker names its own path. Returns whether any mark was + * placed. + */ +export function markChanged (payload: unknown, path: string, reported: unknown, text: string): boolean { + const segments = pointerSegments(path) + const root = nodeAt(payload, segments) + let placed = false + if (Array.isArray(reported) && Array.isArray(root)) { + const count = (list: unknown[], key: string) => list.filter(element => keyOf(element) === key).length + reported.forEach((element, index) => { + const positions = changedPositions(element) + if (positions.length === 0) { + return + } + const key = keyOf(element) + let target: number | undefined + if (key !== undefined && count(reported, key) === 1 && count(root, key) === 1) { + target = root.findIndex(candidate => keyOf(candidate) === key) + } else if (reported.length === root.length && keyOf(root[index]) === key) { + target = index + } + if (target === undefined) { + return + } + for (const position of positions) { + placed = label(payload, [...segments, String(target), ...position], text) || placed + } + }) + return placed + } + for (const position of changedPositions(reported)) { + placed = label(payload, [...segments, ...position], text) || placed + } + return placed +} + +type Registrar = (context: Context, id: string | number, name: string, file: ReturnType) => void + +/** How each referenceable type registers on the codegen context. */ +const REGISTRARS: ReadonlyArray<{ type: keyof Project['data'], register: Registrar }> = [ + { type: 'check-group', register: (context, id, name, file) => context.registerCheckGroup(id as number, name, file) }, + { type: 'alert-channel', register: (context, id, name, file) => context.registerAlertChannel(id as number, name, file) }, + { type: 'private-location', register: (context, id, name, file) => context.registerPrivateLocation(id as string, name, file) }, + { type: 'status-page', register: (context, id, name, file) => context.registerStatusPage(id as string, name, file) }, + { type: 'status-page-service', register: (context, id, name, file) => context.registerStatusPageService(id as string, name, file) }, + { type: 'status-page-component', register: (context, id, name, file) => context.registerStatusPageComponent(id as string, name, file) }, +] + +type Lookup = (context: Context, id: string | number) => { file: ReturnType } + +const LOOKUPS: Readonly> = { + 'check-group': (context, id) => context.lookupCheckGroup(id as number), + 'alert-channel': (context, id) => context.lookupAlertChannel(id as number), + 'private-location': (context, id) => context.lookupPrivateLocation(id as string), + 'status-page': (context, id) => context.lookupStatusPage(id as string), + 'status-page-service': (context, id) => context.lookupStatusPageService(id as string), + 'status-page-component': (context, id) => context.lookupStatusPageComponent(id as string), +} + +/** + * Re-register the construct a codegen has just prepared under its logical + * id, in the construct file the codegen chose. A codegen names the variable + * it exports after the resource's content (an alert channel after its + * address, a group after its name), so a change to that content would show + * as a renamed variable beside the change itself; the logical id is the + * same on both sides. A type whose codegen registers no variable is left + * alone. + */ +export function registerUnderLogicalId (context: Context, resource: Resource): void { + const lookup = LOOKUPS[resource.type] + const registrar = REGISTRARS.find(entry => entry.type === resource.type) + if (lookup === undefined || registrar === undefined) { + return + } + const id = (resource.payload as { id: string | number }).id + const { file } = lookup(context, id) + registrar.register(context, id, resource.logicalId, file) +} + +/** + * Register every referenceable construct of the local project on a fresh + * context, under its physical id, so a rendered reference comes out as the + * variable name the construct would have rather than as `fromId(...)`. Both + * sides of a comparison register the same set in the same order, which is + * what makes their identifiers agree. One support file for all of them, so + * the context's per-file identifier namespace tells two constructs with the + * same name apart (`group`, `group2`); a support file, not a construct file, + * because `renderConstruct` counts the construct files a render adds. + */ +export function registerProject (context: Context, program: Program, project: Project, ids: PhysicalIds): void { + const file = program.generatedSupportFile('__preview__/project') + for (const { type, register } of REGISTRARS) { + const constructs = project.data[type] as Record + for (const logicalId of Object.keys(constructs).sort()) { + const id = ids.get(idKey(type, logicalId)) + // A reference construct (`fromId(...)`) has no name of its own: left + // unregistered, the codegen renders the reference as the `fromId(...)` + // it is, on both sides. + if (id === undefined || constructs[logicalId].member === false) { + continue + } + const name = constructs[logicalId].name + register(context, id, typeof name === 'string' && name.length > 0 ? name : logicalId, file) + } + } +} diff --git a/packages/cli/src/services/deploy-diff/legacy-payload.ts b/packages/cli/src/services/deploy-diff/legacy-payload.ts new file mode 100644 index 000000000..2ec9f3dee --- /dev/null +++ b/packages/cli/src/services/deploy-diff/legacy-payload.ts @@ -0,0 +1,55 @@ +import type { DeployResourceSync, ProjectSync } from '../../rest/projects.js' +import { stripContentHashes } from '../snapshot-service.js' + +/** + * The deploy payload as a Checkly API without the preview endpoint accepts it. + * + * `sourceFile`, each snapshot's `sha256` and a Playwright check's + * `codeBundleSha256` were all added alongside that endpoint. Older deploy + * schemas reject a key they do not know rather than ignoring it, so a CLI + * talking to one has to take them back out — otherwise every deploy against + * such an API fails validation. + * + * A snapshot with no storage key is dropped: it describes a file that has not + * been uploaded, which only a preview accepts, and an older API has no + * preview. Dropping the entry leaves the check's snapshots as they are rather + * than failing the deploy. + * + * Returns a copy, so the caller can still send the full payload to an API that + * does support the endpoint. + * + * Temporary by design: once every API a supported CLI talks to accepts these + * fields, this function and its call site go away. + */ +export function stripUnsupportedDeployFields (payload: ProjectSync): ProjectSync { + return { + ...payload, + resources: payload.resources.map(resource => { + const stripped: DeployResourceSync = { ...resource } + delete stripped.sourceFile + + if (stripped.payload === null || typeof stripped.payload !== 'object') { + return stripped + } + + // The content hashes go the same way they do on a run request. + stripped.payload = stripContentHashes(stripped.payload) + + const snapshots = stripped.payload.snapshots + if (Array.isArray(snapshots)) { + const uploaded = snapshots.filter((snapshot: { key?: string }) => typeof snapshot.key === 'string') + if (uploaded.length || snapshots.length === 0) { + // An empty array is meaningful — "this check has no snapshots" — and + // has always been sent, so it is kept as it is. The key is dropped + // only when every entry had to go, which would otherwise turn "none + // of these are uploaded yet" into "this check has none". + stripped.payload.snapshots = uploaded + } else { + delete stripped.payload.snapshots + } + } + + return stripped + }), + } +} diff --git a/packages/cli/src/services/deploy-diff/plan-summary.ts b/packages/cli/src/services/deploy-diff/plan-summary.ts new file mode 100644 index 000000000..cb019a245 --- /dev/null +++ b/packages/cli/src/services/deploy-diff/plan-summary.ts @@ -0,0 +1,162 @@ +import type { DiffChange, DiffEntry } from '../../rest/projects.js' + +/** + * Turns a deploy plan into the two forms `confirmOrAbort` needs: the + * human-readable `changes` lines of its preview, and the machine-readable plan + * that goes into the agent envelope next to them. + */ + +/** + * How many create/update lines the confirmation prompt lists before summing up + * the rest. A first deploy of a large project would otherwise scroll its own + * question off the screen. Deletions are never summarized away: they are the + * part of a plan that loses data, so every one of them is named. + */ +const MAX_LISTED_CHANGES = 20 + +/** + * Largest value the agent envelope carries inline, serialized. Repeating a + * whole script, response body or environment-variable list in a confirmation + * envelope helps nobody and can be megabytes; what is left in its place says + * how long it was. + */ +const MAX_INLINE_VALUE = 256 + +export interface PlanSummaryOptions { + /** Display names per resource type, for the types a user declares. */ + prettyTypes: Record + /** Types reported as part of their owning check or group rather than on their own. */ + foldedTypes: readonly string[] +} + +/** A relation the project does not manage, which this deploy will delete. */ +export function isPrunedRelation (entry: DiffEntry): boolean { + return entry.action === 'DELETE' && entry.origin === 'unmanaged' +} + +/** + * True when every change reported on a resource is a relation the project does + * not manage. Checkly reports those on the owning check or group whether or not + * the deploy would delete them, and without `--prune-relations` it deletes + * nothing — so the resource itself is untouched and naming it as an update + * would describe a write that never happens. + */ +export function onlyUnmanagedChanges (entry: DiffEntry): boolean { + return (entry.changes?.length ?? 0) > 0 && entry.changes!.every(change => change.origin === 'unmanaged') +} + +/** + * Entries worth showing: a resource the deploy touches. An `UNCHANGED` entry + * with no changes of its own is the converged case and says nothing, a relation + * folded into its parent is already reported there, and a resource whose only + * reported changes are unmanaged relations is not written either. + */ +function reportable (entry: DiffEntry, { foldedTypes }: PlanSummaryOptions): boolean { + // A relation the deploy would delete is reported on its own entry, folded + // into its parent for display. It is the one folded entry worth a line of its + // own: nothing else in the plan says which relations are about to go. + if (isPrunedRelation(entry)) { + return true + } + if (entry.foldedInto !== undefined || foldedTypes.includes(entry.type)) { + return false + } + // Whether or not the relations are pruned, the resource they hang off is not + // written: with `--prune-relations` the relation's own entry carries the line. + if (onlyUnmanagedChanges(entry)) { + return false + } + return entry.action !== 'UNCHANGED' || (entry.changes?.length ?? 0) > 0 +} + +function label (entry: DiffEntry, { prettyTypes }: PlanSummaryOptions): string { + return `${prettyTypes[entry.type] ?? entry.type}: ${entry.logicalId}` +} + +/** + * One line per resource the deploy would touch, deletions first and in full, + * for the confirmation prompt and the agent envelope. + */ +export function planChangeLines (diff: DiffEntry[], options: PlanSummaryOptions): string[] { + const shown = diff.filter(entry => reportable(entry, options)) + const deletions = shown.filter(entry => entry.action === 'DELETE') + // `DETACHED` is what an API older than the deploy diff calls the same thing. + const detached = (entry: DiffEntry) => entry.action === 'DETACH' || entry.action === 'DETACHED' + const detachments = shown.filter(detached) + const rest = shown.filter(entry => entry.action !== 'DELETE' && !detached(entry)) + + const lines = [ + ...deletions.map(entry => (isPrunedRelation(entry) + // Not a resource the project declared, so it has no run history to lose; + // what it names is the check or group it hangs off, when the plan says. + ? `Delete the ${options.prettyTypes[entry.type] ?? entry.type} ` + + (entry.foldedInto + ? `on ${options.prettyTypes[entry.foldedInto.type] ?? entry.foldedInto.type}: ` + + `${entry.foldedInto.logicalId}, ` + : `${entry.logicalId}, `) + + 'which this project does not manage' + : `Permanently delete ${label(entry, options)}, losing its run history`)), + ...detachments.map(entry => `Keep ${label(entry, options)} in your Checkly account, managed from the web app`), + ...rest.slice(0, MAX_LISTED_CHANGES).map(entry => { + const verb = entry.action === 'CREATE' ? 'Create' : 'Update' + return `${verb} ${label(entry, options)}` + }), + ] + + const hidden = rest.length - Math.min(rest.length, MAX_LISTED_CHANGES) + if (hidden > 0) { + lines.push(`Create or update ${hidden} more resource(s); run with --preview to see them all`) + } + + return lines +} + +/** + * A value small enough to carry, or `{ $omitted: }` in its place. + */ +function reduceValue (value: unknown): unknown { + if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') { + return value + } + // Not only strings: a leaf can be a whole collection (a check group's + // environment variables, a status page's cards), and one of those holding a + // large value would otherwise go into the envelope in full. + const size = typeof value === 'string' ? value.length : JSON.stringify(value)?.length ?? 0 + return size > MAX_INLINE_VALUE ? { $omitted: size } : value +} + +function reduceChange (change: DiffChange): DiffChange { + return { + ...change, + ...'before' in change ? { before: reduceValue(change.before) } : {}, + ...'after' in change ? { after: reduceValue(change.after) } : {}, + ...change.remote !== undefined + ? { + remote: { + ...'before' in change.remote ? { before: reduceValue(change.remote.before) } : {}, + ...'after' in change.remote ? { after: reduceValue(change.remote.after) } : {}, + }, + } + : {}, + } +} + +/** + * The plan as the agent envelope carries it: every entry and every changed + * property, but without the resources' full current state and without the + * large values the rendered diff needs. Rendering happens in the terminal + * output; the envelope is there to be read, and it is the same plan, pinned by + * the same `planToken`. + */ +export function reducePlanForAgent (diff: DiffEntry[]): DiffEntry[] { + return diff.map(entry => { + const reduced: DiffEntry = { ...entry } + // The rule table is applied to `before`; without the state, it is dead weight. + delete reduced.before + delete reduced.redactions + if (entry.changes !== undefined) { + reduced.changes = entry.changes.map(reduceChange) + } + return reduced + }) +} diff --git a/packages/cli/src/services/deploy-diff/render.ts b/packages/cli/src/services/deploy-diff/render.ts new file mode 100644 index 000000000..93f77235b --- /dev/null +++ b/packages/cli/src/services/deploy-diff/render.ts @@ -0,0 +1,373 @@ +import { randomUUID } from 'node:crypto' +import { ConstructCodegen } from '../../constructs/construct-codegen.js' +import { Context, renderConstruct } from '../../constructs/internal/codegen/index.js' +import type { Project } from '../../constructs/project.js' +import type { DiffChange, DiffEntry, ResourceSync } from '../../rest/projects.js' +import { Program } from '../../sourcegen/index.js' +import { + blankRedacted, + carriesMarker, + fillUnchangedFromBefore, + isMaskedMarker, + markChanged, + MASK, + nodeAt, + type PhysicalIds, + pointerSegments, + UNRENDERED_KEYS, + registerProject, + registerUnderLogicalId, + relationResourcesForAfter, + relationResourcesFromBefore, + toImportResource, + UnshapeableError, +} from './import-shape.js' +import type { Resource, ResourceType } from '../../constructs/construct-codegen.js' +import { isShapeChangePath } from './shape-changes.js' +import { unifiedDiff } from './unified-diff.js' + +/** + * The lines printed under one updated resource of a deploy preview: the + * construct as Checkly has it against the construct as the local code would + * make it, rendered by the import codegen on both sides and diffed as text + * (spec 8.5). Everything here is reporting: a failure falls back to a coarser + * listing of the reported changes, and never to a failed deploy. + */ + +export interface RenderResourceInput { + /** The resource's preview entry, carrying `before`, `changes` and `redactions` under `detail: 'full'`. */ + entry: DiffEntry + /** The resource's own local payload, as sent in the deploy. */ + local: ResourceSync | undefined + /** Every resource of the local deploy payload (for the relations of a check or group). */ + localResources: readonly ResourceSync[] + /** The whole preview (for the relation entries folded into this resource). */ + diff: readonly DiffEntry[] + project: Project + ids: PhysicalIds + pruneRelations: boolean + /** The most lines a diff may take before the listing is printed instead. */ + maxLines?: number +} + +const INLINE_VALUE_CAP = 256 + +/** + * Properties a codegen writes to a file of its own rather than into the + * construct — a browser or multi-step check's script to a spec file, an API + * check's setup and teardown scripts to support files, a dashboard's CSS to + * a style file — leaving an `entrypoint` path behind. A change to one is + * invisible in the construct diff and is shown as a text diff of its own, + * whether or not the constructs differ too. + */ +const OUTSIDE_CONSTRUCT: Readonly>> = { + check: new Set(['/script', '/localSetupScript', '/localTearDownScript']), + dashboard: new Set(['/customCSS']), +} + +function isOutsideConstruct (type: string, path: string): boolean { + return OUTSIDE_CONSTRUCT[type]?.has(path) ?? false +} + +/** + * Whether a redaction rule reaches the path a secret change names: the two + * agree segment for segment as far as the shorter goes, a rule's `*` standing + * for any one segment. A rule under the path blanks inside the list the + * change reports whole; a rule above it blanks the object that holds it. + */ +function ruleReaches (rulePath: string, changePath: string): boolean { + const rule = pointerSegments(rulePath) + const change = pointerSegments(changePath) + const shared = Math.min(rule.length, change.length) + for (let index = 0; index < shared; index += 1) { + if (rule[index] !== '*' && rule[index] !== change[index]) { + return false + } + } + return true +} + +function isHash (value: unknown): boolean { + return typeof value === 'object' && value !== null && !Array.isArray(value) && '$hash' in value +} + +/** A marker spelled the way the construct prints it. */ +const spellMarker = (_key: string, member: unknown): unknown => + (isMaskedMarker(member) ? (member.$masked === 'changed' ? `${MASK} (changed)` : MASK) : member) + +function inline (value: unknown): string { + if (value === undefined) { + return '(absent)' + } + if (isHash(value)) { + return '(content)' + } + const text = JSON.stringify(value, spellMarker) + return text.length > INLINE_VALUE_CAP ? `${text.slice(0, INLINE_VALUE_CAP)}…` : text +} + +/** The value at an RFC 6901 pointer, or undefined when the path does not resolve. */ +const valueAt = (root: unknown, pointer: string): unknown => nodeAt(root, pointerSegments(pointer)) + +function originNote (change: DiffChange): string { + switch (change.origin) { + case 'remote': + return ' (changed in Checkly, overwritten by this deploy)' + case 'both': + return ' (also changed in Checkly, overwritten by this deploy)' + case 'unmanaged': + return ' (not managed by this project)' + default: + return '' + } +} + +/** One change as a line of the listing: path, values, and where it came from. */ +function listChange (change: DiffChange): string { + if (change.cause !== undefined) { + return `${change.path}: changed (${change.cause})${originNote(change)}` + } + return `${change.path}: ${inline(change.before)} -> ${inline(change.after)}${originNote(change)}` +} + +function listing (changes: readonly DiffChange[], reason?: string): string[] { + const lines = reason === undefined ? [] : [`(${reason})`] + for (const change of changes) { + lines.push(listChange(change)) + } + return lines +} + +/** The import codegen, with the rendered construct's own variable named after its logical id on both sides. */ +class SideCodegen extends ConstructCodegen { + prepare (logicalId: string, resource: Resource, context: Context): void { + super.prepare(logicalId, resource, context) + registerUnderLogicalId(context, resource) + } +} + +/** A side of the comparison rendered to source text, with its relations registered first. */ +function renderSide ( + resource: Resource, + relations: readonly Resource[], + project: Project, + ids: PhysicalIds, + maskedValues: ReadonlySet, +): string { + const program = new Program({ + rootDirectory: '.', + constructFileSuffix: '.check', + specFileSuffix: '.spec', + language: 'typescript', + }) + const context = new Context({ maskedValues }) + const codegen = new SideCodegen(program) + registerProject(context, program, project, ids) + for (const relation of relations) { + codegen.prepare(relation.logicalId, relation, context) + } + return renderConstruct(codegen, resource.logicalId, resource, { context }) +} + +/** + * The text diff of a change whose values are content (a script, a body): the + * deployed text read from `before` at the same pointer, the local text from + * the local payload. Either side missing means the pointer is spelled + * differently in the two vocabularies, and only the fact is reported. + */ +function contentDiff (change: DiffChange, before: unknown, local: unknown, maxLines: number | undefined): string[] { + const deployed = valueAt(before, change.path) + const current = valueAt(local, change.path) + if (typeof deployed !== 'string' || typeof current !== 'string') { + return [`${change.path}: content changed${originNote(change)}`] + } + const lines = unifiedDiff(deployed, current, { beforeLabel: 'deployed', afterLabel: 'local', maxLines }) + if (lines === undefined) { + return [`${change.path}: content changed (too large to show)${originNote(change)}`] + } + return [`${change.path}:${originNote(change)}`, ...lines.map(line => ` ${line}`)] +} + +export function renderResourceDiff (input: RenderResourceInput): string[] { + const { entry, local, localResources, diff, project, ids, pruneRelations, maxLines } = input + const lines: string[] = [] + if (entry.sourceFile) { + lines.push(`file: ${entry.sourceFile}`) + } + const changes = entry.changes ?? [] + // A secret is shown inline, masked, with `(changed)` on the element the + // report marks; a secret change whose mark could not be placed (no + // markers reported, an element the payload does not hold, a position the + // rules blank to null) is named after the block instead, so a secret's + // movement is visible exactly once. The block is rendered for a secret + // change even when nothing else is reported, since the API reports the + // list holding a moved secret whole, plain siblings' edits included. + const shown = changes.filter(change => change.secret !== true) + const marked = new Set() + try { + lines.push( + ...renderShown(shown, marked, entry, local, localResources, diff, project, ids, pruneRelations, maxLines), + ) + } catch (cause) { + // A payload this CLI cannot shape like an import resource, a codegen that + // does not cover the type (a Playwright check suite), a script it cannot + // parse, a construct it refuses: the listing says what changed even when + // the rendering cannot. + const reason = cause instanceof UnshapeableError + ? cause.message + : `could not render this resource: ${cause instanceof Error ? cause.message : cause}` + if (shown.length > 0) { + lines.push(...listing(shown, reason)) + } + } + for (const change of changes) { + if (change.secret !== true) { + continue + } + if (!marked.has(change)) { + lines.push(`secret changed: ${change.path}${originNote(change)}`) + } else if (originNote(change) !== '') { + // The inline mark says which secret moved; the note says the deploy overwrites it. + lines.push(`${change.path}:${originNote(change)}`) + } + } + return lines +} + +/** The reported side a mark is read from: the local side follows the code's movement, the deployed side the backend's. */ +function reportedFor (change: DiffChange, side: 'local' | 'deployed'): unknown { + if (side === 'local') { + return change.origin === 'code' || change.origin === 'both' ? change.after : undefined + } + return change.origin === 'both' ? change.remote?.after : change.origin === 'remote' ? change.after : undefined +} + +function renderShown ( + shown: readonly DiffChange[], + marked: Set, + entry: DiffEntry, + local: ResourceSync | undefined, + localResources: readonly ResourceSync[], + diff: readonly DiffEntry[], + project: Project, + ids: PhysicalIds, + pruneRelations: boolean, + maxLines: number | undefined, +): string[] { + // A secret change withholds the list it is in whole, plain siblings' edits + // included, so the construct diff — both sides blanked — is the only place + // such an edit shows: an entry with a secret change is rendered even when + // nothing else is reported, and a cause alone does not stand in for it. + // Rendered only when a reported rule reaches every secret's path, though: + // the local side's blanks come from that table, and a gap in it must not + // print what the API withheld. + const secrets = (entry.changes ?? []).filter(change => change.secret === true) + const withSecrets = secrets.length > 0 + if (shown.length === 0 && !withSecrets) { + return [] + } + if (!withSecrets && shown.every(change => change.cause !== undefined)) { + return [`changed: ${[...new Set(shown.map(change => change.cause))].join(', ')}`] + } + // A change that is secret or carries a marker (a reorder of a list holding + // one) is rendered only when a reported rule reaches its path. + const guarded = (entry.changes ?? []).filter( + change => change.secret === true + || carriesMarker(change.before) || carriesMarker(change.after) || carriesMarker(change.remote), + ) + if (!guarded.every(change => (entry.redactions ?? []).some(rule => ruleReaches(rule.path, change.path)))) { + return listing(shown) + } + if (entry.before === undefined || local === undefined || local.payload === null || local.payload === undefined) { + return listing(shown) + } + const { type, logicalId } = entry + // The deployed side is the import format already, straight from the API, + // less what neither side renders — dropped before the local side is filled + // from it, so the fill cannot copy it across. + const before = { ...entry.before } + for (const key of UNRENDERED_KEYS) { + delete before[key] + } + // Both sides masked by the same rules; the deployed side arrives blanked. + // A copy, since the marks are written in place and `entry.before` is + // read again for relations and text diffs. + const deployed: Resource = { + type: type as ResourceType, + logicalId, + payload: blankRedacted(structuredClone(before), entry.redactions), + } + // Shaped first, then filled with what the deploy leaves as it is, masked + // last: the rules are spelled in the import format's vocabulary, which is + // what the shaped payload is in. + const shaped = toImportResource(deployed.type, logicalId, local.payload, ids) + fillUnchangedFromBefore(shaped.payload as Record, before, entry.changes ?? []) + const after: Resource = { ...shaped, payload: blankRedacted(shaped.payload, entry.redactions) } + // Then the marks: the element whose secret moved reads `(changed)` on the + // side that moved it, so the diff names the secret beside its key. Each + // change writes its own sentinel, unguessable by any value the account or + // the code could hold, so only a change whose sentinel reached the returned + // lines counts as marked; the sentinels read `(changed)` in the output. + // The codegen prints a secret only as one of these strings. + const nonce = randomUUID() + const sentinels = new Map() + secrets.forEach((change, index) => { + const localLabel = `${MASK} (changed#${nonce}-${index})` + const deployedLabel = `${MASK} (changed in Checkly#${nonce}-${index})` + const onLocal = markChanged(after.payload, change.path, reportedFor(change, 'local'), localLabel) + const onDeployed = markChanged(deployed.payload, change.path, reportedFor(change, 'deployed'), deployedLabel) + if (onLocal || onDeployed) { + sentinels.set(change, { local: localLabel, deployed: deployedLabel }) + } + }) + const maskedValues = new Set([MASK, ...[...sentinels.values()].flatMap(pair => [pair.local, pair.deployed])]) + const showing = (lines: string[]): string[] => { + for (const [change, pair] of sentinels) { + if (lines.some(line => line.includes(pair.local) || line.includes(pair.deployed))) { + marked.add(change) + } + } + const sentinel = new RegExp(` \\(changed( in Checkly)?#${nonce}-\\d+\\)`, 'g') + return lines.map(line => line.replace(sentinel, ' (changed$1)')) + } + const afterRelations = relationResourcesForAfter({ ids, local: localResources, entry, diff, pruneRelations }) + const beforeText = renderSide(deployed, relationResourcesFromBefore(type, entry.before), project, ids, maskedValues) + const afterText = renderSide(after, afterRelations, project, ids, maskedValues) + const rendered = unifiedDiff(beforeText, afterText, { beforeLabel: 'deployed', afterLabel: 'local', maxLines }) + if (rendered === undefined) { + return shown.length > 0 ? listing(shown, 'the construct diff is too large to show') : [] + } + if (rendered.length > 0) { + // The constructs differ. What they cannot show follows: a property kept + // in a file of its own as a text diff, and a change that is only a cause + // (a new code bundle, a dependency list) as a line. + const lines = [...rendered] + for (const change of shown) { + if (isOutsideConstruct(type, change.path)) { + lines.push(...contentDiff(change, entry.before, local.payload, maxLines)) + } else if (change.cause !== undefined) { + lines.push(listChange(change)) + } + } + return showing(lines) + } + // The renderings agree. When every change is a known shape change, that is + // the CLI upgrade spelling the same construct differently; the paths alone + // cannot tell an upgrade from a user editing the same property, so a real + // edit shows up as construct lines above and never gets here. + if (shown.length > 0 && shown.every(change => change.origin === 'code' && isShapeChangePath(change.path))) { + return ['payload format changed (CLI upgrade)'] + } + // Otherwise what changed lives outside the construct (a script in its own + // file, a request body), or is a value the codegen elides. + const lines: string[] = [] + for (const change of shown) { + const hashed = isHash(change.before) || isHash(change.after) || isHash(change.remote?.after) + if (hashed || isOutsideConstruct(type, change.path)) { + lines.push(...contentDiff(change, entry.before, local.payload, maxLines)) + } else { + lines.push(listChange(change)) + } + } + return lines +} diff --git a/packages/cli/src/services/deploy-diff/shape-changes.ts b/packages/cli/src/services/deploy-diff/shape-changes.ts new file mode 100644 index 000000000..a00b9c4a3 --- /dev/null +++ b/packages/cli/src/services/deploy-diff/shape-changes.ts @@ -0,0 +1,35 @@ +/** + * Leaf paths whose difference is a CLI upgrade changing how a payload is + * spelled, not a user editing a construct: private locations moving from an + * inline list to assignment resources, `doubleCheck` becoming a retry + * strategy, alert settings and parallel scheduling moving to the v2 group + * defaults. After such an upgrade every affected resource reports a + * code-origin change once and rewrites its snapshot; rather than render a + * property diff of two spellings of the same thing, the preview says so. + * + * An enumerated list, extended with each shape change. It carries no copy of + * any backend default: membership is by path alone, which is why the + * renderer consults it only once the two renderings agree — a user editing + * one of these properties differs in the construct, and prints as that. + */ +const SHAPE_CHANGE_PATHS: ReadonlySet = new Set([ + '/privateLocations', + '/doubleCheck', + '/retryStrategy', + '/alertSettings', + '/useGlobalAlertSettings', + '/runParallel', +]) + +/** Whether a reported change path is one of the known shape changes, or lies under one. */ +export function isShapeChangePath (path: string): boolean { + if (SHAPE_CHANGE_PATHS.has(path)) { + return true + } + for (const known of SHAPE_CHANGE_PATHS) { + if (path.startsWith(`${known}/`)) { + return true + } + } + return false +} diff --git a/packages/cli/src/services/deploy-diff/unified-diff.ts b/packages/cli/src/services/deploy-diff/unified-diff.ts new file mode 100644 index 000000000..18c8ff96c --- /dev/null +++ b/packages/cli/src/services/deploy-diff/unified-diff.ts @@ -0,0 +1,291 @@ +/** + * A line-oriented unified diff, used to show what a deploy would change about + * a resource: the construct as Checkly currently has it against the construct + * the local code would produce. + * + * Deliberately implemented here rather than taken from a package. The output + * is read by people in a terminal, the inputs are a few dozen lines of + * generated code, and a diff library would be a new dependency of a CLI that + * ships to users for the sake of one screen of code. + */ + +/** Lines of leading and trailing context kept around each changed run. */ +const DEFAULT_CONTEXT = 3 + +/** + * Largest number of lines either side may have before the diff is declined. + * The comparison is a quadratic dynamic program over the lines that actually + * differ, so a pathological input (two unrelated thousand-line scripts) would + * cost a multiple of a million cells for output nobody would read. Callers + * have a coarser listing to fall back to. + */ +const DEFAULT_MAX_LINES = 1500 + +/** + * Cells the dynamic program may allocate, whatever `maxLines` says. The line + * guard above is about output nobody would read; this one is about the + * allocation itself, which is the product of the two sides and so grows far + * faster — without it, a caller raising `maxLines` gets a `RangeError` from + * the typed array instead of the `undefined` this function promises for an + * input it declines. Four million cells is 16 MB, and comfortably above what + * the default line guard can reach. + */ +const MAX_CELLS = 4_000_000 + +export interface UnifiedDiffOptions { + /** Lines of context around each change. Defaults to 3. */ + context?: number + /** Name of the left-hand side, shown in the `---` header. */ + beforeLabel?: string + /** Name of the right-hand side, shown in the `+++` header. */ + afterLabel?: string + /** Lines per side above which no diff is produced. Defaults to 1500. */ + maxLines?: number +} + +type Op = ' ' | '-' | '+' + +interface Edit { + op: Op + line: string +} + +/** + * Splits text into lines, treating a trailing newline as a line terminator + * rather than as an empty last line, so `'a\n'` and `'a'` both have one line + * and neither reports a phantom change against the other. + */ +function toLines (text: string): string[] { + if (text === '') { + return [] + } + const lines = text.split('\n') + if (lines[lines.length - 1] === '') { + lines.pop() + } + return lines +} + +/** + * The edit script turning `before` into `after`, one entry per line of either + * side, via the longest common subsequence of the two. + * + * Identical head and tail lines are matched off before the dynamic program + * runs — they are the bulk of any real comparison — and put back as context + * afterwards, so the line numbering downstream needs no adjustment. + * + * @returns `undefined` when what is left after that trim would need more than + * {@link MAX_CELLS} cells to compare. + */ +function editScript (before: string[], after: string[]): Edit[] | undefined { + let head = 0 + while (head < before.length && head < after.length && before[head] === after[head]) { + head += 1 + } + let tail = 0 + while ( + tail < before.length - head + && tail < after.length - head + && before[before.length - 1 - tail] === after[after.length - 1 - tail] + ) { + tail += 1 + } + + const a = before.slice(head, before.length - tail) + const b = after.slice(head, after.length - tail) + + const n = a.length + const m = b.length + if ((n + 1) * (m + 1) > MAX_CELLS) { + return undefined + } + const width = m + 1 + // lengths[i][j] is the LCS length of a[i..] and b[j..], filled from the end + // so the walk below can go forwards and keep the two sides in step. + const lengths = new Int32Array((n + 1) * width) + for (let i = n - 1; i >= 0; i -= 1) { + for (let j = m - 1; j >= 0; j -= 1) { + lengths[i * width + j] = a[i] === b[j] + ? lengths[(i + 1) * width + j + 1] + 1 + : Math.max(lengths[(i + 1) * width + j], lengths[i * width + j + 1]) + } + } + + const edits: Edit[] = [] + for (const line of before.slice(0, head)) { + edits.push({ op: ' ', line }) + } + + let i = 0 + let j = 0 + while (i < n && j < m) { + if (a[i] === b[j]) { + edits.push({ op: ' ', line: a[i] }) + i += 1 + j += 1 + } else if (lengths[(i + 1) * width + j] >= lengths[i * width + j + 1]) { + // Dropping a[i] keeps at least as much of the common subsequence as + // dropping b[j] would; ties go to the deletion so a replaced line reads + // as `-old` then `+new`. + edits.push({ op: '-', line: a[i] }) + i += 1 + } else { + edits.push({ op: '+', line: b[j] }) + j += 1 + } + } + while (i < n) { + edits.push({ op: '-', line: a[i] }) + i += 1 + } + while (j < m) { + edits.push({ op: '+', line: b[j] }) + j += 1 + } + + for (const line of before.slice(before.length - tail)) { + edits.push({ op: ' ', line }) + } + + return edits +} + +interface Hunk { + beforeStart: number + beforeCount: number + afterStart: number + afterCount: number + edits: Edit[] +} + +/** + * Groups the edit script into hunks: every changed line with `context` lines + * either side, and two changed runs separated by no more than twice that + * merged into one, which is what keeps a diff of scattered small edits from + * repeating the same lines under two headers. + */ +function toHunks (edits: Edit[], context: number): Hunk[] { + const ranges: Array<[number, number]> = [] + edits.forEach((edit, index) => { + if (edit.op === ' ') { + return + } + const from = Math.max(0, index - context) + const to = Math.min(edits.length - 1, index + context) + const last = ranges[ranges.length - 1] + if (last !== undefined && from <= last[1] + 1) { + last[1] = Math.max(last[1], to) + } else { + ranges.push([from, to]) + } + }) + if (ranges.length === 0) { + return [] + } + + // Line numbers are 1-based and counted per side, so both counters advance + // only over the lines that side actually has. + const beforeLineAt: number[] = [] + const afterLineAt: number[] = [] + let beforeLine = 1 + let afterLine = 1 + for (const edit of edits) { + beforeLineAt.push(beforeLine) + afterLineAt.push(afterLine) + if (edit.op !== '+') { + beforeLine += 1 + } + if (edit.op !== '-') { + afterLine += 1 + } + } + + return ranges.map(([from, to]) => { + const slice = edits.slice(from, to + 1) + const beforeCount = slice.filter(edit => edit.op !== '+').length + const afterCount = slice.filter(edit => edit.op !== '-').length + return { + // A range holding none of its side's lines is numbered by the lines that + // precede it, so an insertion before the first line reads `-0,0`. (BSD + // diff spells that one case `-1,0`; see the note on the export.) + beforeStart: beforeCount === 0 ? beforeLineAt[from] - 1 : beforeLineAt[from], + beforeCount, + afterStart: afterCount === 0 ? afterLineAt[from] - 1 : afterLineAt[from], + afterCount, + edits: slice, + } + }) +} + +/** + * A hunk header's range for one side. `diff -u` omits the count when the side + * spans exactly one line, so `-1,1` is spelled `-1`. + */ +function range (start: number, count: number): string { + return count === 1 ? `${start}` : `${start},${count}` +} + +/** + * A unified diff of two texts, as lines without a trailing newline and without + * colour — the caller decides how to present them. + * + * Headers and hunk numbering follow `git diff` and GNU `diff -u`, which is the + * spelling a reader recognises and the one their CI prints. Two places where + * that is worth knowing: + * + * - BSD (and therefore macOS) `diff` numbers an empty range at the very start + * of a non-empty side `1,0` where git and GNU say `0,0`. This follows git. + * - When two sides can be aligned in more than one minimal way, which edit + * script you get is a matter of heuristics, and this one's is not git's. The + * result is always a valid, minimal diff of the same two texts; it may group + * its hunks differently from the diff the same input would get from git. + * + * @returns An empty array when the two sides are identical, `undefined` when + * they are too large to compare (see {@link DEFAULT_MAX_LINES} and + * {@link MAX_CELLS}), and otherwise the `---`/`+++` headers followed by one + * `@@` hunk per changed region. + */ +export function unifiedDiff ( + before: string, + after: string, + options: UnifiedDiffOptions = {}, +): string[] | undefined { + const { + context = DEFAULT_CONTEXT, + beforeLabel = 'deployed', + afterLabel = 'local', + maxLines = DEFAULT_MAX_LINES, + } = options + + if (before === after) { + return [] + } + + const beforeLines = toLines(before) + const afterLines = toLines(after) + if (beforeLines.length > maxLines || afterLines.length > maxLines) { + return undefined + } + + const edits = editScript(beforeLines, afterLines) + if (edits === undefined) { + return undefined + } + + const hunks = toHunks(edits, context) + if (hunks.length === 0) { + // The texts differ only in trailing whitespace that `toLines` dropped. + return [] + } + + const lines = [`--- ${beforeLabel}`, `+++ ${afterLabel}`] + for (const hunk of hunks) { + lines.push( + `@@ -${range(hunk.beforeStart, hunk.beforeCount)} +${range(hunk.afterStart, hunk.afterCount)} @@`, + ) + for (const edit of hunk.edits) { + lines.push(`${edit.op}${edit.line}`) + } + } + return lines +} diff --git a/packages/cli/src/services/snapshot-service.ts b/packages/cli/src/services/snapshot-service.ts index c66252777..31d6c66d6 100644 --- a/packages/cli/src/services/snapshot-service.ts +++ b/packages/cli/src/services/snapshot-service.ts @@ -3,12 +3,56 @@ import * as fs from 'node:fs' import * as path from 'node:path' import * as stream from 'node:stream/promises' +import PQueue from 'p-queue' + import { checklyStorage } from '../rest/api.js' +import { sha256OfFile } from './content-hash.js' import { findFilesRecursively, pathToPosix } from './util.js' +/** How many snapshot files are hashed at once. */ +const SNAPSHOT_HASH_CONCURRENCY = 8 + +/** + * A snapshot file as it exists locally, before any upload. `sha256` describes + * the file's content, so a snapshot can be reported to Checkly — in a deploy + * preview, for instance — while `key`, which only the upload produces, is + * still unknown. + */ +export interface RawSnapshot { + absolutePath: string + path: string + sha256: string +} + export interface Snapshot { key: string path: string + /** Absent on snapshots read back from the API, which only stores it on deploy. */ + sha256?: string +} + +/** + * The content hashes only a deploy sends. + * + * A check's payload is synthesized the same way for a deploy and for a test + * session, but only the deploy compares content hashes — and only the deploy + * schemas accept them. Keeping them out of a run request means a CLI published + * before the matching API is deployed still runs tests, rather than having + * every browser check with snapshots and every Playwright suite rejected. + */ +export function stripContentHashes> (payload: T): T { + const stripped: Record = { ...payload } + delete stripped.codeBundleSha256 + + if (Array.isArray(stripped.snapshots)) { + stripped.snapshots = (stripped.snapshots as Snapshot[]).map(snapshot => { + const entry: Partial = { ...snapshot } + delete entry.sha256 + return entry + }) + } + + return stripped as T } export async function pullSnapshots (basePath: string, snapshots?: Snapshot[] | null) { @@ -34,18 +78,27 @@ export async function pullSnapshots (basePath: string, snapshots?: Snapshot[] | } } -export function detectSnapshots (projectBasePath: string, scriptFilePath: string) { +export async function detectSnapshots ( + projectBasePath: string, + scriptFilePath: string, +): Promise { // By default, PWT will store snapshots in the `script.spec.js-snapshots` directory. // Other paths can be configured, though, and we should add support for those as well. // https://playwright.dev/docs/api/class-testconfig#test-config-snapshot-path-template const snapshotFiles = findFilesRecursively(`${scriptFilePath}-snapshots`) - return snapshotFiles.map(absolutePath => ({ + // Hashed here rather than at upload time so the hash is available whether or + // not the snapshot is ever uploaded (see {@link RawSnapshot}). Bounded, + // because each hash opens a read stream and a project can hold hundreds of + // baselines across as many checks as it has. + const queue = new PQueue({ concurrency: SNAPSHOT_HASH_CONCURRENCY }) + return await Promise.all(snapshotFiles.map(absolutePath => queue.add(async () => ({ absolutePath, path: pathToPosix(path.relative(projectBasePath, absolutePath)), - })) + sha256: await sha256OfFile(absolutePath), + })) as Promise)) } -export async function uploadSnapshots (rawSnapshots?: Array<{ absolutePath: string, path: string }>) { +export async function uploadSnapshots (rawSnapshots?: RawSnapshot[]) { if (!rawSnapshots?.length) { return [] } @@ -53,9 +106,22 @@ export async function uploadSnapshots (rawSnapshots?: Array<{ absolutePath: stri try { const snapshots: Array = [] for (const rawSnapshot of rawSnapshots) { + // Re-hashed rather than reusing the hash from detection: a deploy asks + // what it would change before it uploads, and the answer has to be + // confirmed first, so the file may have been edited in between. The hash + // that ships with the upload describes what was uploaded. + const sha256 = await sha256OfFile(rawSnapshot.absolutePath) + if (sha256 !== rawSnapshot.sha256) { + // The plan the user just saw described the older content, so say which + // file moved rather than quietly deploying something else. + process.stderr.write( + `Warning: ${rawSnapshot.path} changed after the deploy was planned; ` + + 'the content being uploaded is the one on disk now.\n', + ) + } const snapshotStream = fs.createReadStream(rawSnapshot.absolutePath) const { data: { key } } = await checklyStorage.upload(snapshotStream) - snapshots.push({ key, path: rawSnapshot.path }) + snapshots.push({ key, path: rawSnapshot.path, sha256 }) } return snapshots } catch (err: any) { diff --git a/packages/cli/src/services/test-runner.ts b/packages/cli/src/services/test-runner.ts index 7e927458d..23dfa217d 100644 --- a/packages/cli/src/services/test-runner.ts +++ b/packages/cli/src/services/test-runner.ts @@ -6,7 +6,7 @@ import { GitInformation } from './util.js' import { Check } from '../constructs/check.js' import { RetryStrategy, SharedFile } from '../constructs/index.js' import { ProjectBundle, ResourceDataBundle } from '../constructs/project-bundle.js' -import { pullSnapshots } from '../services/snapshot-service.js' +import { pullSnapshots, stripContentHashes } from '../services/snapshot-service.js' import { PlaywrightCheckBundle } from '../constructs/playwright-check-bundle.js' export default class TestRunner extends AbstractCheckRunner { @@ -71,9 +71,13 @@ export default class TestRunner extends AbstractCheckRunner { : check.groupId return { - ...bundle.synthesize(), + // The content hashes describe uploads for a deploy to compare; a run + // has no use for them and a run route may not accept them. + ...stripContentHashes(bundle.synthesize()), testRetryStrategy: this.testRetryStrategy, - group: groupId ? this.projectBundle.data['check-group'][groupId.ref].bundle.synthesize() : undefined, + group: groupId + ? stripContentHashes(this.projectBundle.data['check-group'][groupId.ref].bundle.synthesize()) + : undefined, sourceInfo: { checkRunSuiteId, checkRunId: uuid.v4(), diff --git a/packages/cli/src/sourcegen/program.ts b/packages/cli/src/sourcegen/program.ts index 9ed420de2..34f31b97b 100644 --- a/packages/cli/src/sourcegen/program.ts +++ b/packages/cli/src/sourcegen/program.ts @@ -22,6 +22,7 @@ export class Program { #options: ProgramOptions #ext: string #generatedFiles = new Map() + #generatedConstructFiles = new Set() #staticAuxiliaryFiles = new Map() constructor (options: ProgramOptions) { @@ -57,6 +58,18 @@ export class Program { return paths } + /** + * The files declaring constructs, in the order they were first asked for. + * + * Support and static auxiliary files are not included: a caller that wants + * the construct a single `gencode` call produced has no other way to tell + * that file apart from the script, snippet and stylesheet files the same + * call may register beside it. + */ + get generatedConstructFiles (): GeneratedFile[] { + return Array.from(this.#generatedConstructFiles) + } + generatedConstructFile (path: string): GeneratedFile { if (this.#shouldModifyPath(path)) { path += this.#options.constructFileSuffix @@ -72,6 +85,10 @@ export class Program { this.#generatedFiles.set(path, file) } + // Unconditional, and a set: being asked for as a construct file is what + // makes a file one, even if a support file at the same path came first. + this.#generatedConstructFiles.add(file) + return file } @@ -267,14 +284,25 @@ export class GeneratedFile extends ProgramFile { this.#sections.push(content) } - render (output: Output): void { - for (const header of this.#headers) { - header.render(output) - output.endLine() - output.endLine() + /** + * @param options `scaffolding` defaults to true, so writing a file to disk + * needs no options. A caller rendering one construct for a reader — a diff + * of what a deploy would change, say — passes false: the generated-file + * header and the import list belong to the file, not to the construct, and + * an import path invented for an in-memory render would be noise. + */ + render (output: Output, options: { scaffolding?: boolean } = {}): void { + const { scaffolding = true } = options + + if (scaffolding) { + for (const header of this.#headers) { + header.render(output) + output.endLine() + output.endLine() + } } - if (this.#namedImports.size > 0) { + if (scaffolding && this.#namedImports.size > 0) { for (const [pkg, imports] of this.#namedImports.entries()) { output.append('import') output.cosmeticWhitespace() @@ -304,7 +332,7 @@ export class GeneratedFile extends ProgramFile { } } - if (this.#plainImports.size > 0) { + if (scaffolding && this.#plainImports.size > 0) { for (const pkg of this.#plainImports.values()) { output.append('import') output.significantWhitespace()