From 3e6f5e5b796d9bf3623f61649bcdfeb97c1c4f41 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 23 Sep 2026 13:15:49 +0900 Subject: [PATCH] feat(write-back): write runtimeId and the request of every monitor type [RED-991] Co-Authored-By: Claude Fable 5.1 --- .../src/ai-context/references/configure.md | 2 +- .../write-back/__tests__/plan.spec.ts | 276 +++++++++++++++++- packages/cli/src/services/write-back/plan.ts | 122 ++++++-- 3 files changed, 376 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/ai-context/references/configure.md b/packages/cli/src/ai-context/references/configure.md index 087f9a9d..c5185def 100644 --- a/packages/cli/src/ai-context/references/configure.md +++ b/packages/cli/src/ai-context/references/configure.md @@ -76,7 +76,7 @@ Run `npx checkly skills manage plan` for the full reference. - 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 which resources a deploy would create, update, delete or keep, without applying it: an overview of every resource the deploy touches and a diff of each updated resource's construct as deployed against as in code. A plain interactive `checkly deploy` prints that same preview before asking the user to apply the changes or cancel. The machine-readable forms (`--dry-run`, and the `confirmation_required` envelope) additionally carry the individual properties that would change. -- When the preview shows a resource that was edited outside the project (in the web app or through the API), an interactive `checkly deploy` offers a third choice next to apply and cancel: update the code with the values from Checkly and deploy nothing. It rewrites literal values (strings, numbers, booleans, and arrays or objects of those) and the helper-spelled properties `frequency` (`Frequency.EVERY_5M`), `retryStrategy` (`RetryStrategyBuilder`), `alertEscalationPolicy` (`AlertEscalationBuilder`) and `assertions` (the class's assertion builder) inside the `new ApiCheck('id', { … })` call of checks and check groups, adds a helper's import when the file lacks it, leaves the rest of the file untouched, and lists everything it could not update with the reason (references to other resources, secrets, scripts, a helper call holding a variable, `doubleCheck` set beside a retry strategy, a check moved to the global alert policy, which needs `alertEscalationPolicy` removed by hand). It exists only in a terminal; there is no flag for it, and the `confirmation_required` envelope is unchanged. +- When the preview shows a resource that was edited outside the project (in the web app or through the API), an interactive `checkly deploy` offers a third choice next to apply and cancel: update the code with the values from Checkly and deploy nothing. It rewrites literal values (strings, numbers, booleans, and arrays or objects of those, including `runtimeId` on runtime checks and groups and the `request` of every check and monitor type) and the helper-spelled properties `frequency` (`Frequency.EVERY_5M`), `retryStrategy` (`RetryStrategyBuilder`), `alertEscalationPolicy` (`AlertEscalationBuilder`) and `assertions` (the class's assertion builder) inside the `new ApiCheck('id', { … })` call of checks and check groups, adds a helper's import when the file lacks it, leaves the rest of the file untouched, and lists everything it could not update with the reason (references to other resources, secrets, scripts, a helper call holding a variable, `doubleCheck` set beside a retry strategy, a check moved to the global alert policy, which needs `alertEscalationPolicy` removed by hand). It exists only in a terminal; there is no flag for it, and the `confirmation_required` envelope is unchanged. - Use `--skip-plan` to deploy without asking Checkly for a plan: nothing is previewed and no plan token is used, so the deploy applies whatever the account looks like when it runs. Resources to delete are still listed before the confirmation, but the code bundle is uploaded before it rather than after. Incompatible with `--preview`, `--dry-run`, `--plan-token` and `--prune-relations`. Prefer a planned deploy unless the plan itself is the problem. - 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. diff --git a/packages/cli/src/services/write-back/__tests__/plan.spec.ts b/packages/cli/src/services/write-back/__tests__/plan.spec.ts index 6c60b390..52937c82 100644 --- a/packages/cli/src/services/write-back/__tests__/plan.spec.ts +++ b/packages/cli/src/services/write-back/__tests__/plan.spec.ts @@ -4,6 +4,11 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ApiCheck } from '../../../constructs/api-check.js' +import { BrowserCheck } from '../../../constructs/browser-check.js' +import { DnsMonitor } from '../../../constructs/dns-monitor.js' +import { IcmpMonitor } from '../../../constructs/icmp-monitor.js' +import { SslMonitor } from '../../../constructs/ssl-monitor.js' +import { TracerouteMonitor } from '../../../constructs/traceroute-monitor.js' import { CheckGroup } from '../../../constructs/check-group.js' import { EmailAlertChannel } from '../../../constructs/email-alert-channel.js' import { HeartbeatMonitor } from '../../../constructs/heartbeat-monitor.js' @@ -448,16 +453,14 @@ new TcpMonitor('tcp', { name: 'Tcp', request: { hostname: 'example.com', port: 4 project, cwd: dir, }) - expect(plan.skipped).toEqual([ - 'check-group grp /runParallel: not a property this tool can update', - 'check tcp /request/hostname: not a property this tool can update', - ]) + expect(plan.skipped).toEqual(['check-group grp /runParallel: not a property this tool can update']) expect(plan.applied.map(line => [line.logicalId, line.property, line.rendered])).toEqual([ ['grp', 'concurrency', '5'], ['grp', 'apiCheckDefaults.url', '\'https://api.example.com\''], ['beat', 'period', '2'], ['url', 'request.url', '\'https://www.example.com\''], ['url', 'maxResponseTime', '20000'], + ['tcp', 'request.hostname', '\'other.example.com\''], ]) expect(plan.files).toHaveLength(1) expect(plan.files[0].text).toBe(`import { CheckGroup, HeartbeatCheck, UrlMonitor, TcpMonitor } from 'checkly/constructs' @@ -472,7 +475,7 @@ new HeartbeatCheck('beat', { name: 'Beat', period: 2, periodUnit: 'hours', grace new UrlMonitor('url', { name: 'Url', request: { url: 'https://www.example.com' }, maxResponseTime: 20000 }) -new TcpMonitor('tcp', { name: 'Tcp', request: { hostname: 'example.com', port: 443 } }) +new TcpMonitor('tcp', { name: 'Tcp', request: { hostname: 'other.example.com', port: 443 } }) `) }) @@ -870,9 +873,272 @@ new CheckGroupV1('own', { name: 'Own', alertEscalationPolicy: AlertEscalationBui } expect(has(rules, 'alertEscalationPolicy'), `${cls.name} alertEscalationPolicy`).toBe(true) expect(has(rules, 'frequency'), `${cls.name} frequency`).toBe(!isGroup) + expect(has(rules, 'runtimeId'), `${cls.name} runtimeId`).toBe(isGroup || cls.prototype instanceof RuntimeCheck) } }) + it('maps every key of the SSL request onto the construct spelling', () => { + // The one request whose wire shape differs from the construct's; the + // other monitors' lists are checked exhaustively at compile time. + const rules = RULES_BY_CLASS.get(SslMonitor) ?? [] + const targets = rules.filter(rule => rule.target[0] === 'request').map(rule => rule.target.join('.')) + expect(targets.sort()).toEqual([ + 'request.assertions', 'request.hostname', 'request.ipFamily', 'request.port', 'request.sslConfig.alertDaysBeforeExpiry', + 'request.sslConfig.clientCertificateMode', 'request.sslConfig.handshakeTimeout', 'request.sslConfig.securityBaseline', + 'request.sslConfig.serverName', 'request.sslConfig.skipChainValidation', 'request.sslConfig.sslClientCertificateId', + ]) + expect(rules.find(rule => rule.target.join('.') === 'request.sslConfig.handshakeTimeout')?.pointer) + .toEqual(['request', 'sslConfig', 'handshakeTimeoutMs']) + expect(rules.find(rule => rule.target.join('.') === 'request.sslConfig.sslClientCertificateId')?.pointer) + .toEqual(['request', 'sslClientCertificateId']) + }) + + it('writes the request of every monitor type', async () => { + await declare('monitors.check.ts', `import { TcpMonitor, DnsMonitor, IcmpMonitor, GrpcMonitor, SslMonitor, TracerouteMonitor } from 'checkly/constructs' + +new TcpMonitor('tcp', { name: 'Tcp', request: { hostname: 'example.com', port: 443 } }) + +new DnsMonitor('dns', { name: 'Dns', request: { recordType: 'A', query: 'example.com', nameServer: 'ns1.example.com', port: 53 } }) + +new IcmpMonitor('icmp', { name: 'Icmp', request: { hostname: 'example.com' } }) + +new GrpcMonitor('grpc', { + name: 'Grpc', + request: { url: 'grpc.example.com', port: 443, grpcConfig: { mode: 'HEALTH', method: 'Check' } }, +}) + +new SslMonitor('ssl', { + name: 'Ssl', + request: { + hostname: 'example.com', + sslConfig: { alertDaysBeforeExpiry: 7, securityBaseline: { enabled: true, minTLSVersion: { severity: 'fail' } } }, + }, +}) + +new TracerouteMonitor('trace', { name: 'Trace', request: { url: 'example.com', maxHops: 30 } }) +`, () => { + new TcpMonitor('tcp', { name: 'Tcp', request: { hostname: 'example.com', port: 443 } }) + new DnsMonitor('dns', { name: 'Dns', request: { recordType: 'A', query: 'example.com', nameServer: 'ns1.example.com', port: 53 } }) + new IcmpMonitor('icmp', { name: 'Icmp', request: { hostname: 'example.com' } }) + new GrpcMonitor('grpc', { + name: 'Grpc', + request: { url: 'grpc.example.com', port: 443, grpcConfig: { mode: 'HEALTH', method: 'Check' } }, + }) + new SslMonitor('ssl', { + name: 'Ssl', + request: { + hostname: 'example.com', + sslConfig: { alertDaysBeforeExpiry: 7, securityBaseline: { enabled: true, minTLSVersion: { severity: 'fail' } } }, + }, + }) + new TracerouteMonitor('trace', { name: 'Trace', request: { url: 'example.com', maxHops: 30 } }) + }) + const entry = (logicalId: string, checkType: string, changes: DiffEntry['changes'], request: unknown): DiffEntry => + ({ type: 'check', logicalId, action: 'UPDATE', changes, before: { checkType, name: logicalId, request }, redactions: [] }) + const plan = await planWriteBack({ + diff: [ + entry('tcp', 'TCP', [{ path: '/request/data', origin: 'remote', before: null, after: 'ping' }], + { hostname: 'example.com', port: 443, data: 'ping' }), + entry('dns', 'DNS', [ + { path: '/request/query', origin: 'remote', before: 'example.com', after: 'www.example.com' }, + { path: '/request/nameServer', origin: 'remote', before: 'ns1.example.com', after: 'ns2.example.com' }, + { path: '/request/port', origin: 'remote', before: 53, after: 5353 }, + ], { recordType: 'A', query: 'www.example.com', nameServer: 'ns2.example.com', port: 5353 }), + entry('icmp', 'ICMP', [{ path: '/request/pingCount', origin: 'remote', before: null, after: 5 }], + { hostname: 'example.com', pingCount: 5 }), + entry('grpc', 'GRPC', [ + { path: '/request/grpcConfig/method', origin: 'remote', before: 'Check', after: 'Watch' }, + { path: '/request/timeout', origin: 'remote', before: null, after: 5000 }, + ], { url: 'grpc.example.com', port: 443, timeout: 5000, grpcConfig: { mode: 'HEALTH', method: 'Watch', metadata: [] } }), + entry('ssl', 'SSL', [ + { path: '/request/sslConfig/hostname', origin: 'remote', before: 'example.com', after: 'ssl.example.com' }, + { path: '/request/sslConfig/handshakeTimeoutMs', origin: 'remote', before: null, after: 3000 }, + { path: '/request/sslClientCertificateId', origin: 'remote', before: null, after: 'cert-1' }, + { path: '/request/sslConfig/securityBaseline/minTLSVersion/severity', origin: 'remote', before: 'fail', after: 'degrade' }, + ], { + sslConfig: { + hostname: 'ssl.example.com', + port: 443, + alertDaysBeforeExpiry: 7, + handshakeTimeoutMs: 3000, + securityBaseline: { enabled: true, minTLSVersion: { severity: 'degrade' } }, + }, + sslClientCertificateId: 'cert-1', + }), + entry('trace', 'TRACEROUTE', [{ path: '/request/maxHops', origin: 'remote', before: 30, after: 20 }], + { url: 'example.com', maxHops: 20 }), + ], + project, + cwd: dir, + }) + expect(plan.skipped).toEqual([]) + expect(plan.applied.map(line => [line.logicalId, line.property, line.rendered])).toEqual([ + ['tcp', 'request.data', '\'ping\''], + ['dns', 'request.query', '\'www.example.com\''], + ['dns', 'request.nameServer', '\'ns2.example.com\''], + ['dns', 'request.port', '5353'], + ['icmp', 'request.pingCount', '5'], + ['grpc', 'request.grpcConfig.method', '\'Watch\''], + ['grpc', 'request.timeout', '5000'], + // Lines follow the file: a replacement precedes the properties appended after it. + ['ssl', 'request.hostname', '\'ssl.example.com\''], + ['ssl', 'request.sslConfig.securityBaseline', '{ enabled: true, minTLSVersion: { severity: \'degrade\' } }'], + ['ssl', 'request.sslConfig.handshakeTimeout', '3000'], + ['ssl', 'request.sslConfig.sslClientCertificateId', '\'cert-1\''], + ['trace', 'request.maxHops', '20'], + ]) + expect(plan.files[0].text).toBe(`import { TcpMonitor, DnsMonitor, IcmpMonitor, GrpcMonitor, SslMonitor, TracerouteMonitor } from 'checkly/constructs' + +new TcpMonitor('tcp', { name: 'Tcp', request: { hostname: 'example.com', port: 443, data: 'ping' } }) + +new DnsMonitor('dns', { name: 'Dns', request: { recordType: 'A', query: 'www.example.com', nameServer: 'ns2.example.com', port: 5353 } }) + +new IcmpMonitor('icmp', { name: 'Icmp', request: { hostname: 'example.com', pingCount: 5 } }) + +new GrpcMonitor('grpc', { + name: 'Grpc', + request: { url: 'grpc.example.com', port: 443, grpcConfig: { mode: 'HEALTH', method: 'Watch' }, timeout: 5000 }, +}) + +new SslMonitor('ssl', { + name: 'Ssl', + request: { + hostname: 'ssl.example.com', + sslConfig: { alertDaysBeforeExpiry: 7, securityBaseline: { enabled: true, minTLSVersion: { severity: 'degrade' } }, handshakeTimeout: 3000, sslClientCertificateId: 'cert-1' }, + }, +}) + +new TracerouteMonitor('trace', { name: 'Trace', request: { url: 'example.com', maxHops: 20 } }) +`) + }) + + it('refuses what the monitor request rules do not cover', async () => { + await declare('refused.check.ts', `import { GrpcMonitor, SslMonitor, DnsMonitor } from 'checkly/constructs' +const dnsPort = 53 +new GrpcMonitor('grpc', { name: 'Grpc', request: { url: 'grpc.example.com', port: 443, grpcConfig: {} } }) +new GrpcMonitor('grpc2', { name: 'Grpc2', request: { url: 'grpc.example.com', port: 443, grpcConfig: {} } }) +new SslMonitor('ssl', { name: 'Ssl', request: { hostname: 'example.com', sslConfig: { securityBaseline: { enabled: true } } } }) +new DnsMonitor('dns', { name: 'Dns', request: { recordType: 'A', query: 'example.com', nameServer: 'ns1.example.com', port: dnsPort } }) +`, () => { + new GrpcMonitor('grpc', { name: 'Grpc', request: { url: 'grpc.example.com', port: 443, grpcConfig: {} } }) + new GrpcMonitor('grpc2', { name: 'Grpc2', request: { url: 'grpc.example.com', port: 443, grpcConfig: {} } }) + new SslMonitor('ssl', { name: 'Ssl', request: { hostname: 'example.com', sslConfig: { securityBaseline: { enabled: true } } } }) + new DnsMonitor('dns', { name: 'Dns', request: { recordType: 'A', query: 'example.com', nameServer: 'ns1.example.com', port: 53 } }) + }) + // The account blanks every gRPC metadata value, as its redaction table says. + const metadataRedactions: DiffRedaction[] = [{ path: '/request/grpcConfig/metadata/*/value', kind: 'value' }] + const plan = await planWriteBack({ + diff: [ + { + type: 'check', + logicalId: 'grpc', + action: 'UPDATE', + changes: [ + { path: '/request/grpcConfig/encoding', origin: 'remote', before: 'PROTOBUF', after: 'FLATBUFFERS' }, + { path: '/request/grpcConfig/metadata', origin: 'remote', secret: true }, + ], + before: { + checkType: 'GRPC', + name: 'Grpc', + request: { url: 'grpc.example.com', port: 443, grpcConfig: { encoding: 'FLATBUFFERS', metadata: [{ key: 'k', value: '' }] } }, + }, + redactions: metadataRedactions, + }, + { + type: 'check', + logicalId: 'grpc2', + action: 'UPDATE', + changes: [{ path: '/request/grpcConfig/metadata', origin: 'remote', before: [], after: [{ key: 'k', value: '' }] }], + before: { checkType: 'GRPC', name: 'Grpc2', request: { url: 'grpc.example.com', port: 443, grpcConfig: { metadata: [{ key: 'k', value: '' }] } } }, + redactions: metadataRedactions, + }, + { + type: 'check', + logicalId: 'ssl', + action: 'UPDATE', + changes: [ + { path: '/request/sslConfig/serverName', origin: 'remote', before: 'example.com', after: null }, + { path: '/request/sslConfig/securityBaseline/minTLSVersion/severity', origin: 'remote', before: 'fail', after: null }, + ], + // The import format leaves out a cleared optional key. + before: { checkType: 'SSL', name: 'Ssl', request: { sslConfig: { hostname: 'example.com', securityBaseline: { enabled: true } } } }, + redactions: [], + }, + { + type: 'check', + logicalId: 'dns', + action: 'UPDATE', + changes: [ + { path: '/request/nameServer', origin: 'remote', before: 'ns1.example.com', after: 'ns2.example.com' }, + { path: '/request/port', origin: 'remote', before: 53, after: 5353 }, + ], + before: { checkType: 'DNS', name: 'Dns', request: { recordType: 'A', query: 'example.com', nameServer: 'ns2.example.com', port: 5353 } }, + redactions: [], + }, + ], + project, + cwd: dir, + }) + expect(plan.applied).toEqual([]) + expect(plan.skipped).toEqual([ + 'check grpc /request/grpcConfig/encoding: not a property this tool can update', + 'check grpc /request/grpcConfig/metadata: a secret changed; Checkly does not return its value', + 'check grpc2 request.grpcConfig.metadata: contains a locked or secret value that Checkly does not return', + 'check ssl request.sslConfig.securityBaseline: Checkly reported two different current values', + 'check ssl request.sslConfig.serverName: Checkly has no value for request.sslConfig.serverName; edit the property by hand', + 'check dns request.nameServer: written together with request.port', + 'check dns request.port: request.port is the variable dnsPort, not a plain literal', + ]) + }) + + it('writes runtimeId on runtime checks and groups', async () => { + await declare('runtime.check.ts', `import { ApiCheck, BrowserCheck, CheckGroup, TcpMonitor } from 'checkly/constructs' +new ApiCheck('api', { name: 'API', runtimeId: '2024.02', request: { url: 'https://example.com', method: 'GET' } }) +new BrowserCheck('browser', { name: 'Browser', runtimeId: '2024.02', code: { content: '' } }) +new CheckGroup('grp', { name: 'Group' }) +new TcpMonitor('tcp', { name: 'Tcp', request: { hostname: 'example.com', port: 443 } }) +`, () => { + new ApiCheck('api', { name: 'API', runtimeId: '2024.02', request: { url: 'https://example.com', method: 'GET' } }) + new BrowserCheck('browser', { name: 'Browser', runtimeId: '2024.02', code: { content: '' } }) + new CheckGroup('grp', { name: 'Group' }) + new TcpMonitor('tcp', { name: 'Tcp', request: { hostname: 'example.com', port: 443 } }) + }) + const check = (logicalId: string, checkType: string, before: unknown, after: unknown): DiffEntry => ({ + type: 'check', + logicalId, + action: 'UPDATE', + changes: [{ path: '/runtimeId', origin: 'remote', before, after }], + before: { checkType, name: logicalId, runtimeId: after }, + redactions: [], + }) + const plan = await planWriteBack({ + diff: [ + check('api', 'API', '2024.02', '2025.04'), + { + type: 'check-group', + logicalId: 'grp', + action: 'UPDATE', + changes: [{ path: '/runtimeId', origin: 'remote', before: null, after: '2025.04' }], + before: { name: 'Group', runtimeId: '2025.04' }, + redactions: [], + }, + // A cleared runtime is a null the import format keeps. + check('browser', 'BROWSER', '2024.02', null), + check('tcp', 'TCP', null, '2025.04'), + ], + project, + cwd: dir, + }) + expect(plan.skipped).toEqual([ + 'check tcp /runtimeId: not a property this tool can update', + 'check browser runtimeId: Checkly has no value for runtimeId; edit the property by hand', + ]) + expect(plan.applied.map(line => [line.logicalId, line.property, line.previous, line.rendered])).toEqual([ + ['api', 'runtimeId', '\'2024.02\'', '\'2025.04\''], + ['grp', 'runtimeId', undefined, '\'2025.04\''], + ]) + }) + it('accepts a leaf that became a subtree, but not an object the account no longer holds', async () => { await declare('api.check.ts', API_SOURCE, () => { new ApiCheck('api', { name: 'API', request: { url: 'https://example.com', method: 'GET' } }) diff --git a/packages/cli/src/services/write-back/plan.ts b/packages/cli/src/services/write-back/plan.ts index c264b592..9bdd7b14 100644 --- a/packages/cli/src/services/write-back/plan.ts +++ b/packages/cli/src/services/write-back/plan.ts @@ -6,21 +6,28 @@ import { isDeepStrictEqual } from 'node:util' import * as constructs from '../../constructs/index.js' import { AgenticCheck } from '../../constructs/agentic-check.js' import { ApiCheck } from '../../constructs/api-check.js' +import type { Request } from '../../constructs/api-request.js' import { BrowserCheck } from '../../constructs/browser-check.js' import { CheckGroupV1 } from '../../constructs/check-group-v1.js' import { CheckGroupV2 } from '../../constructs/check-group-v2.js' import type { Construct } from '../../constructs/construct.js' import { DnsMonitor } from '../../constructs/dns-monitor.js' +import type { DnsRequest } from '../../constructs/dns-request.js' import { GrpcMonitor } from '../../constructs/grpc-monitor.js' +import type { GrpcConfig, GrpcRequest } from '../../constructs/grpc-request.js' import { HeartbeatMonitor } from '../../constructs/heartbeat-monitor.js' import { IcmpMonitor } from '../../constructs/icmp-monitor.js' +import type { IcmpRequest } from '../../constructs/icmp-request.js' import { MultiStepCheck } from '../../constructs/multi-step-check.js' import { PlaywrightCheck } from '../../constructs/playwright-check.js' import type { Project, ProjectData } from '../../constructs/project.js' import { SslMonitor } from '../../constructs/ssl-monitor.js' -import { TcpMonitor } from '../../constructs/tcp-monitor.js' +import type { SslConfig, SslRequest } from '../../constructs/ssl-request.js' +import { TcpMonitor, type TcpRequest } from '../../constructs/tcp-monitor.js' import { TracerouteMonitor } from '../../constructs/traceroute-monitor.js' +import type { TracerouteRequest } from '../../constructs/traceroute-request.js' import { UrlMonitor } from '../../constructs/url-monitor.js' +import type { UrlRequest } from '../../constructs/url-request.js' import type { DiffChange, DiffEntry } from '../../rest/projects.js' import { hasEscalationPolicy } from '../../constructs/alert-escalation-policy-codegen.js' import { AGENTIC_CHECK_OMITTED_PROPS } from '../../constructs/internal/agentic-check-defaults.js' @@ -126,7 +133,8 @@ type AlertPolicyHolder = 'check' | 'group' | 'group-v2' const identity = (...segments: string[]): Rule => ({ pointer: segments, target: segments }) const set = (segment: string): Rule => ({ pointer: [segment], target: [segment], set: true }) -const under = (parent: string, keys: string[]): Rule[] => keys.map(key => identity(parent, key)) +const under = (parent: string | readonly string[], keys: readonly string[]): Rule[] => + keys.map(key => identity(...(typeof parent === 'string' ? [parent] : parent), key)) const assertions = (builder: AssertionBuilderName, ...parent: string[]): Rule => ({ pointer: [...parent, 'assertions'], target: [...parent, 'assertions'], helper: { kind: 'assertions', builder } }) @@ -169,15 +177,78 @@ const CHECK_RULES: Rule[] = [ ] const omitting = (rules: readonly Rule[], props: readonly string[]): Rule[] => rules.filter(rule => !props.includes(rule.target[0])) +// Only the classes extending RuntimeCheck take a runtime and environment +// variables; a monitor or an agentic check would drop them when synthesized. +const RUNTIME_CHECK_RULES: Rule[] = [identity('runtimeId'), identity('environmentVariables')] const RESPONSE_TIME_RULES: Rule[] = [identity('degradedResponseTime'), identity('maxResponseTime')] + +// The keys of each request type the account reports under the same name +// the construct uses, typed against the construct's interface so a renamed +// property fails the build; `Covers` below fails it for a key added to the +// interface but listed nowhere. `assertions` has its own helper rule. const API_REQUEST_KEYS = [ 'url', 'method', 'ipFamily', 'followRedirects', 'skipSSL', 'body', 'bodyType', 'headers', 'queryParameters', 'basicAuth', +] as const satisfies readonly (keyof Request)[] +const URL_REQUEST_KEYS = ['url', 'ipFamily', 'followRedirects', 'skipSSL'] as const satisfies readonly (keyof UrlRequest)[] +const TCP_REQUEST_KEYS = ['hostname', 'port', 'data', 'ipFamily'] as const satisfies readonly (keyof TcpRequest)[] +const DNS_REQUEST_KEYS = [ + 'recordType', 'query', 'nameServer', 'port', 'protocol', +] as const satisfies readonly (keyof DnsRequest)[] +const ICMP_REQUEST_KEYS = ['hostname', 'ipFamily', 'pingCount'] as const satisfies readonly (keyof IcmpRequest)[] +const GRPC_REQUEST_KEYS = ['url', 'port', 'ipFamily', 'skipSSL', 'timeout'] as const satisfies readonly (keyof GrpcRequest)[] +// `metadata` is never written — the account blanks every metadata value — +// but with a rule the refusal names that reason rather than a generic one. +const GRPC_CONFIG_KEYS = [ + 'mode', 'tls', 'metadata', 'serviceDefinition', 'method', 'protoContent', 'message', 'service', +] as const satisfies readonly (keyof GrpcConfig)[] +const TRACEROUTE_REQUEST_KEYS = [ + 'url', 'protocol', 'port', 'ipFamily', 'maxHops', 'maxUnknownHops', 'ptrLookup', 'timeout', +] as const satisfies readonly (keyof TracerouteRequest)[] +// A DNS monitor refuses a name server without a port and a port without a +// name server, so the two are written together or not at all. +const DNS_REQUEST_RULES: Rule[] = DNS_REQUEST_KEYS.map(key => + key === 'nameServer' || key === 'port' ? { ...identity('request', key), group: 'nameServer' } : identity('request', key)) +/** + * The SSL request is the one the account spells differently from the + * construct: the wire shape nests the host, port and IP family under + * `sslConfig`, names the handshake timeout in milliseconds, and lifts the + * client certificate id to the request level. + */ +const SSL_NESTED_KEYS = ['hostname', 'port', 'ipFamily'] as const satisfies readonly (keyof SslRequest)[] +const SSL_CONFIG_KEYS = [ + 'serverName', 'skipChainValidation', 'alertDaysBeforeExpiry', 'clientCertificateMode', 'securityBaseline', +] as const satisfies readonly (keyof SslConfig)[] +const SSL_REQUEST_RULES: Rule[] = [ + ...SSL_NESTED_KEYS.map(key => ({ pointer: ['request', 'sslConfig', key], target: ['request', key] })), + ...under(['request', 'sslConfig'], SSL_CONFIG_KEYS), + { pointer: ['request', 'sslConfig', 'handshakeTimeoutMs'], target: ['request', 'sslConfig', 'handshakeTimeout'] }, + { pointer: ['request', 'sslClientCertificateId'], target: ['request', 'sslConfig', 'sslClientCertificateId'] }, ] -const URL_REQUEST_KEYS = ['url', 'ipFamily', 'followRedirects', 'skipSSL'] + +/** + * `true` when every key of `T` is in `Listed`, `never` otherwise: a key a + * construct's request gains has to be added to its list here, or named + * below as one the write-back leaves out on purpose, before the build passes. + */ +type Covers = Exclude extends never ? true : never +// A build-time assertion only; nothing reads it. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _everyRequestKeyIsListed: [ + Covers, + Covers, + Covers, + Covers, + Covers, + Covers, + Covers, + Covers, + Covers, + Covers, +] = [true, true, true, true, true, true, true, true, true, true] const HEARTBEAT_KEYS = ['period', 'periodUnit', 'grace', 'graceUnit'] const GROUP_RULES: Rule[] = [ identity('name'), identity('activated'), identity('muted'), set('tags'), set('locations'), identity('concurrency'), - identity('environmentVariables'), + identity('environmentVariables'), identity('runtimeId'), ...under('apiCheckDefaults', ['url', 'headers', 'queryParameters', 'basicAuth']), assertions('AssertionBuilder', 'apiCheckDefaults'), ...RETRY_RULES, @@ -188,9 +259,9 @@ const GROUP_RULES: Rule[] = [ * import-format spelling and construct spelling are both literals with the * same shape, and the ones the construct spells with a helper this module * can render (`frequency`, `retryStrategy`, `alertEscalationPolicy`, the - * `assertions` of a request). Anything else — references, scripts, the - * TCP/DNS/ICMP request whose keys differ between the two spellings — is left - * to the user. + * `assertions` of a request), and the SSL request whose wire keys map onto + * the construct's (`SSL_REQUEST_RULES`). Anything else — references, + * scripts, a request key the construct does not take — is left to the user. * * Keyed by the exact class, not by `instanceof`: a class this table does not * name gets nothing rather than a base class's rules, so a construct that @@ -205,24 +276,32 @@ export type ConstructClass = abstract new (...args: any[]) => Construct export const RULES_BY_CLASS: ReadonlyMap = new Map([ [ApiCheck, [ - ...CHECK_RULES, identity('environmentVariables'), ...RESPONSE_TIME_RULES, + ...CHECK_RULES, ...RUNTIME_CHECK_RULES, ...RESPONSE_TIME_RULES, ...under('request', API_REQUEST_KEYS), assertions('AssertionBuilder', 'request'), ]], - [BrowserCheck, [...CHECK_RULES, identity('environmentVariables')]], - [MultiStepCheck, [...CHECK_RULES, identity('environmentVariables')]], - [PlaywrightCheck, [...omitting(CHECK_RULES, PLAYWRIGHT_CHECK_OMITTED_PROPS), identity('environmentVariables')]], + [BrowserCheck, [...CHECK_RULES, ...RUNTIME_CHECK_RULES]], + [MultiStepCheck, [...CHECK_RULES, ...RUNTIME_CHECK_RULES]], + [PlaywrightCheck, [...omitting(CHECK_RULES, PLAYWRIGHT_CHECK_OMITTED_PROPS), ...RUNTIME_CHECK_RULES]], [AgenticCheck, omitting(CHECK_RULES, AGENTIC_CHECK_OMITTED_PROPS)], [UrlMonitor, [ ...CHECK_RULES, ...RESPONSE_TIME_RULES, ...under('request', URL_REQUEST_KEYS), assertions('UrlAssertionBuilder', 'request'), ]], - [TcpMonitor, [...CHECK_RULES, ...RESPONSE_TIME_RULES, assertions('TcpAssertionBuilder', 'request')]], - [DnsMonitor, [...CHECK_RULES, ...RESPONSE_TIME_RULES, assertions('DnsAssertionBuilder', 'request')]], - [GrpcMonitor, [...CHECK_RULES, ...RESPONSE_TIME_RULES, assertions('GrpcAssertionBuilder', 'request')]], - [SslMonitor, [...CHECK_RULES, ...RESPONSE_TIME_RULES, assertions('SslAssertionBuilder', 'request')]], - [TracerouteMonitor, [...CHECK_RULES, ...RESPONSE_TIME_RULES, assertions('TracerouteAssertionBuilder', 'request')]], + [TcpMonitor, [ + ...CHECK_RULES, ...RESPONSE_TIME_RULES, ...under('request', TCP_REQUEST_KEYS), assertions('TcpAssertionBuilder', 'request'), + ]], + [DnsMonitor, [...CHECK_RULES, ...RESPONSE_TIME_RULES, ...DNS_REQUEST_RULES, assertions('DnsAssertionBuilder', 'request')]], + [GrpcMonitor, [ + ...CHECK_RULES, ...RESPONSE_TIME_RULES, ...under('request', GRPC_REQUEST_KEYS), + ...under(['request', 'grpcConfig'], GRPC_CONFIG_KEYS), assertions('GrpcAssertionBuilder', 'request'), + ]], + [SslMonitor, [...CHECK_RULES, ...RESPONSE_TIME_RULES, ...SSL_REQUEST_RULES, assertions('SslAssertionBuilder', 'request')]], + [TracerouteMonitor, [ + ...CHECK_RULES, ...RESPONSE_TIME_RULES, ...under('request', TRACEROUTE_REQUEST_KEYS), + assertions('TracerouteAssertionBuilder', 'request'), + ]], [IcmpMonitor, [ ...CHECK_RULES, identity('degradedPacketLossThreshold'), identity('maxPacketLossThreshold'), - assertions('IcmpAssertionBuilder', 'request'), + ...under('request', ICMP_REQUEST_KEYS), assertions('IcmpAssertionBuilder', 'request'), ]], [HeartbeatMonitor, [ ...CHECK_RULES, @@ -315,7 +394,14 @@ function agrees (change: DiffChange, rule: Rule, remaining: readonly string[], r const wasLeaf = previous === null || typeof previous !== 'object' return held === undefined || (wasLeaf && held !== null && typeof held === 'object') } - return withheld(current.value) || isDeepStrictEqual(nodeAt(raw, remaining), current.value) + const held = nodeAt(raw, remaining) + // The import format leaves out an optional key the account cleared + // (`serverName`, `nameServer`, `data`, …), which the change reports as + // null: at the rule's own leaf that is the same absence, and the writer + // then refuses it as a value Checkly does not hold. Below the leaf the + // parent would be written without the key, so the disagreement stands. + const cleared = remaining.length === 0 && held === undefined && current.value === null + return withheld(current.value) || cleared || isDeepStrictEqual(held, current.value) } interface Candidate {