diff --git a/packages/cli/src/constructs/__tests__/dns-monitor.spec.ts b/packages/cli/src/constructs/__tests__/dns-monitor.spec.ts new file mode 100644 index 000000000..6ac9c4423 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/dns-monitor.spec.ts @@ -0,0 +1,232 @@ +import { describe, it, expect } from 'vitest' + +import { DnsMonitor, CheckGroup, DnsRequest, Diagnostics } from '../index' +import { Project, Session } from '../project' + +const request: DnsRequest = { + recordType: 'A', + query: 'acme.com', +} + +describe('DnsMonitor', () => { + it('should apply default check settings', () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + Session.checkDefaults = { tags: ['default tags'] } + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request, + }) + Session.checkDefaults = undefined + expect(check).toMatchObject({ tags: ['default tags'] }) + }) + + it('should overwrite default check settings with check-specific config', () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + Session.checkDefaults = { tags: ['default tags'] } + const check = new DnsMonitor('test-check', { + name: 'Test Check', + tags: ['test check'], + request, + }) + Session.checkDefaults = undefined + expect(check).toMatchObject({ tags: ['test check'] }) + }) + + it('should support setting groups with `groupId`', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + const group = new CheckGroup('main-group', { name: 'Main Group', locations: [] }) + const check = new DnsMonitor('main-check', { + name: 'Main Check', + request, + groupId: group.ref(), + }) + const bundle = await check.bundle() + expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) + }) + + it('should support setting groups with `group`', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + const group = new CheckGroup('main-group', { name: 'Main Group', locations: [] }) + const check = new DnsMonitor('main-check', { + name: 'Main Check', + request, + group, + }) + const bundle = await check.bundle() + expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) + }) + + describe('validation', () => { + it('should error if doubleCheck is set', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request, + // @ts-expect-error Not available in the type. + doubleCheck: true, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Property "doubleCheck" is not supported.'), + }), + ])) + }) + + it('should error if degradedResponseTime is above 500', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request, + degradedResponseTime: 501, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('The value of "degradedResponseTime" must be 500 or lower.'), + }), + ])) + }) + + it('should error if maxResponseTime is above 1000', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request, + maxResponseTime: 1001, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('The value of "maxResponseTime" must be 1000 or lower.'), + }), + ])) + }) + + it('should error if nameServer is set but port is not', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + nameServer: '1.1.1.1', + }, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('A value for "port" is required when "nameServer" is set.'), + }), + ])) + }) + + it('should error if port is set but nameServer is not', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + port: 53, + }, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('A value for "nameServer" is required when "port" is set.'), + }), + ])) + }) + + it('should not error if neither nameServer nor port are set', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + }, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(false) + }) + + it('should not error if both nameServer and port are set', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + port: 53, + nameServer: '1.1.1.1', + }, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(false) + }) + }) +}) diff --git a/packages/cli/src/constructs/check-codegen.ts b/packages/cli/src/constructs/check-codegen.ts index 79bdcc124..878cfe775 100644 --- a/packages/cli/src/constructs/check-codegen.ts +++ b/packages/cli/src/constructs/check-codegen.ts @@ -14,6 +14,7 @@ import { TcpMonitorCodegen, TcpMonitorResource } from './tcp-monitor-codegen' import { UrlMonitorCodegen, UrlMonitorResource } from './url-monitor-codegen' import { valueForPrivateLocationFromId } from './private-location-codegen' import { valueForAlertChannelFromId } from './alert-channel-codegen' +import { DnsMonitorCodegen, DnsMonitorResource } from './dns-monitor-codegen' export interface CheckResource { id: string @@ -214,6 +215,7 @@ export class CheckCodegen extends Codegen { multiStepCheckCodegen: MultiStepCheckCodegen tcpMonitorCodegen: TcpMonitorCodegen urlMonitorCodegen: UrlMonitorCodegen + dnsMonitorCodegen: DnsMonitorCodegen constructor (program: Program) { super(program) @@ -224,6 +226,7 @@ export class CheckCodegen extends Codegen { this.multiStepCheckCodegen = new MultiStepCheckCodegen(program) this.tcpMonitorCodegen = new TcpMonitorCodegen(program) this.urlMonitorCodegen = new UrlMonitorCodegen(program) + this.dnsMonitorCodegen = new DnsMonitorCodegen(program) } describe (resource: CheckResource): string { @@ -242,6 +245,8 @@ export class CheckCodegen extends Codegen { return this.heartbeatMonitorCodegen.describe(resource as HeartbeatMonitorResource) case 'URL': return this.urlMonitorCodegen.describe(resource as UrlMonitorResource) + case 'DNS': + return this.dnsMonitorCodegen.describe(resource as DnsMonitorResource) default: throw new Error(`Unable to describe unsupported check type '${checkType}'.`) } @@ -269,6 +274,9 @@ export class CheckCodegen extends Codegen { case 'URL': this.urlMonitorCodegen.gencode(logicalId, resource as UrlMonitorResource, context) return + case 'DNS': + this.dnsMonitorCodegen.gencode(logicalId, resource as DnsMonitorResource, context) + return default: throw new Error(`Unable to generate code for unsupported check type '${checkType}'.`) } diff --git a/packages/cli/src/constructs/construct-diagnostics.ts b/packages/cli/src/constructs/construct-diagnostics.ts index 36baff0d5..2bbd9a91e 100644 --- a/packages/cli/src/constructs/construct-diagnostics.ts +++ b/packages/cli/src/constructs/construct-diagnostics.ts @@ -18,6 +18,23 @@ export class InvalidPropertyValueDiagnostic extends ErrorDiagnostic { } } +export class RequiredPropertyDiagnostic extends ErrorDiagnostic { + property: string + + constructor (property: string, error: Error) { + super({ + title: `Missing required property`, + message: + `Property "${property}" is required and must be set.` + + `\n\n` + + `Reason: ${error.message}`, + error, + }) + + this.property = property + } +} + export class ConflictingPropertyDiagnostic extends ErrorDiagnostic { property1: string property2: string diff --git a/packages/cli/src/constructs/dns-assertion-codegen.ts b/packages/cli/src/constructs/dns-assertion-codegen.ts new file mode 100644 index 000000000..90de328b5 --- /dev/null +++ b/packages/cli/src/constructs/dns-assertion-codegen.ts @@ -0,0 +1,29 @@ +import { GeneratedFile, Value } from '../sourcegen' +import { valueForGeneralAssertion, valueForNumericAssertion } from './internal/assertion-codegen' +import { DnsAssertion } from './dns-assertion' + +export function valueForDnsAssertion (genfile: GeneratedFile, assertion: DnsAssertion): Value { + genfile.namedImport('DnsAssertionBuilder', 'checkly/constructs') + + switch (assertion.source) { + case 'RESPONSE_CODE': + return valueForGeneralAssertion('DnsAssertionBuilder', 'responseCode', assertion, { + hasProperty: false, + hasRegex: false, + }) + case 'RESPONSE_TIME': + return valueForNumericAssertion('DnsAssertionBuilder', 'responseTime', assertion) + case 'TEXT_ANSWER': + return valueForGeneralAssertion('DnsAssertionBuilder', 'textAnswer', assertion, { + hasProperty: false, + hasRegex: true, + }) + case 'JSON_ANSWER': + return valueForGeneralAssertion('DnsAssertionBuilder', 'jsonAnswer', assertion, { + hasProperty: true, + hasRegex: false, + }) + default: + throw new Error(`Unsupported DNS assertion source ${assertion.source}`) + } +} diff --git a/packages/cli/src/constructs/dns-assertion.ts b/packages/cli/src/constructs/dns-assertion.ts new file mode 100644 index 000000000..a26a35ed2 --- /dev/null +++ b/packages/cli/src/constructs/dns-assertion.ts @@ -0,0 +1,59 @@ +import { Assertion as CoreAssertion, NumericAssertionBuilder, GeneralAssertionBuilder } from './internal/assertion' + +type DnsAssertionSource = + | 'RESPONSE_CODE' + | 'RESPONSE_TIME' + | 'TEXT_ANSWER' + | 'JSON_ANSWER' + +export type DnsAssertion = CoreAssertion + +/** + * Builder class for creating DNS monitor assertions. + * Provides methods to create assertions for DNS query responses. + * + * @example + * ```typescript + * // Response time assertions + * DnsAssertionBuilder.responseTime().lessThan(1000) + * DnsAssertionBuilder.responseTime().greaterThan(100) + * + * // Response code assertions + * DnsAssertionBuilder.responseCode().equals('NOERROR') + * DnsAssertionBuilder.responseCode().equals('NXDOMAIN') + * ``` + */ +export class DnsAssertionBuilder { + /** + * Creates an assertion builder for DNS response codes. + * @returns A general assertion builder for the response code. + */ + static responseCode () { + return new GeneralAssertionBuilder('RESPONSE_CODE') + } + + /** + * Creates an assertion builder for DNS response time. + * @returns A numeric assertion builder for response time in milliseconds. + */ + static responseTime () { + return new NumericAssertionBuilder('RESPONSE_TIME') + } + + /** + * Creates an assertion builder for the answer in common plain text format. + * @returns A general assertion builder for the answer. + */ + static textAnswer (regex?: string) { + return new GeneralAssertionBuilder('TEXT_ANSWER', undefined, regex) + } + + /** + * Creates an assertion builder for the JSON formatted answer. + * @param property Optional JSON path to specific property (e.g., '$.Answer[0].data') + * @returns A general assertion builder for the JSON formatted answer. + */ + static jsonAnswer (property?: string) { + return new GeneralAssertionBuilder('JSON_ANSWER', property) + } +} diff --git a/packages/cli/src/constructs/dns-monitor-codegen.ts b/packages/cli/src/constructs/dns-monitor-codegen.ts new file mode 100644 index 000000000..021bbe550 --- /dev/null +++ b/packages/cli/src/constructs/dns-monitor-codegen.ts @@ -0,0 +1,50 @@ +import { Codegen, Context } from './internal/codegen' +import { expr, ident } from '../sourcegen' +import { buildMonitorProps, MonitorResource } from './monitor-codegen' +import { DnsRequest } from './dns-request' +import { valueForDnsRequest } from './dns-request-codegen' + +export interface DnsMonitorResource extends MonitorResource { + checkType: 'DNS' + request: DnsRequest + degradedResponseTime?: number + maxResponseTime?: number +} + +const construct = 'DnsMonitor' + +export class DnsMonitorCodegen extends Codegen { + describe (resource: DnsMonitorResource): string { + return `DNS Monitor: ${resource.name}` + } + + gencode (logicalId: string, resource: DnsMonitorResource, context: Context): void { + const filePath = context.filePath('resources/dns-monitors', resource.name, { + tags: resource.tags, + unique: true, + }) + + const file = this.program.generatedConstructFile(filePath.fullPath) + + file.namedImport(construct, 'checkly/constructs') + + file.section(expr(ident(construct), builder => { + builder.new(builder => { + builder.string(logicalId) + builder.object(builder => { + builder.value('request', valueForDnsRequest(this.program, file, context, resource.request)) + + if (resource.degradedResponseTime !== undefined) { + builder.number('degradedResponseTime', resource.degradedResponseTime) + } + + if (resource.maxResponseTime !== undefined) { + builder.number('maxResponseTime', resource.maxResponseTime) + } + + buildMonitorProps(this.program, file, builder, resource, context) + }) + }) + })) + } +} diff --git a/packages/cli/src/constructs/dns-monitor.ts b/packages/cli/src/constructs/dns-monitor.ts new file mode 100644 index 000000000..f10d6d810 --- /dev/null +++ b/packages/cli/src/constructs/dns-monitor.ts @@ -0,0 +1,116 @@ +import { Monitor, MonitorProps } from './monitor' +import { Session } from './project' +import { Diagnostics } from './diagnostics' +import { validateResponseTimes } from './internal/common-diagnostics' +import { DnsRequest } from './dns-request' +import { RequiredPropertyDiagnostic } from './construct-diagnostics' + +export interface DnsMonitorProps extends MonitorProps { + /** + * Determines the request that the monitor is going to run. + */ + request: DnsRequest + + /** + * The response time in milliseconds where the monitor should be considered + * degraded. + * + * DNS monitors have lower thresholds than most other checks and monitors. + * + * @defaultValue 500 + * @minimum 0 + * @maximum 500 + * @example + * ```typescript + * degradedResponseTime: 200 // Alert when DNS request takes longer than 400 milliseconds + * ``` + */ + degradedResponseTime?: number + + /** + * The response time in milliseconds where the monitor should be considered + * failing. + * + * DNS monitors have lower thresholds than most other checks and monitors. + * + * @defaultValue 1000 + * @minimum 0 + * @maximum 1000 + * @example + * ```typescript + * maxResponseTime: 200 // Fail if DNS request takes longer than 200 milliseconds + * ``` + */ + maxResponseTime?: number +} + +/** + * Creates a DNS Monitor + */ +export class DnsMonitor extends Monitor { + request: DnsRequest + degradedResponseTime?: number + maxResponseTime?: number + + /** + * Constructs the DNS Monitor instance + * + * @param logicalId unique project-scoped resource name identification + * @param props configuration properties + * + * {@link https://checklyhq.com/docs/cli/constructs-reference/#dnsmonitor Read more in the docs} + */ + + constructor (logicalId: string, props: DnsMonitorProps) { + super(logicalId, props) + + this.request = props.request + this.degradedResponseTime = props.degradedResponseTime + this.maxResponseTime = props.maxResponseTime + + Session.registerConstruct(this) + this.addSubscriptions() + this.addPrivateLocationCheckAssignments() + } + + describe (): string { + return `DnsMonitor:${this.logicalId}` + } + + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + if (this.request.nameServer && this.request.port === undefined) { + diagnostics.add(new RequiredPropertyDiagnostic( + 'port', + new Error( + `A value for "port" is required when "nameServer" is set.`, + ), + )) + } + + if (!this.request.nameServer && this.request.port !== undefined) { + diagnostics.add(new RequiredPropertyDiagnostic( + 'nameServer', + new Error( + `A value for "nameServer" is required when "port" is set.`, + ), + )) + } + + await validateResponseTimes(diagnostics, this, { + degradedResponseTime: 500, + maxResponseTime: 1000, + }) + } + + synthesize () { + return { + ...super.synthesize(), + checkType: 'DNS', + request: this.request, + degradedResponseTime: this.degradedResponseTime, + maxResponseTime: this.maxResponseTime, + } + } +} diff --git a/packages/cli/src/constructs/dns-request-codegen.ts b/packages/cli/src/constructs/dns-request-codegen.ts new file mode 100644 index 000000000..94f5d432a --- /dev/null +++ b/packages/cli/src/constructs/dns-request-codegen.ts @@ -0,0 +1,39 @@ +import { GeneratedFile, object, Program, Value } from '../sourcegen' +import { valueForDnsAssertion } from './dns-assertion-codegen' +import { DnsRequest } from './dns-request' +import { Context } from './internal/codegen' + +export function valueForDnsRequest ( + program: Program, + genfile: GeneratedFile, + context: Context, + request: DnsRequest, +): Value { + return object(builder => { + builder.string('recordType', request.recordType) + builder.string('query', request.query) + + if (request.nameServer) { + builder.string('nameServer', request.nameServer) + } + + if (request.port && request.port !== 53) { + builder.number('port', request.port) + } + + if (request.protocol && request.protocol !== 'UDP') { + builder.string('protocol', request.protocol) + } + + if (request.assertions) { + const assertions = request.assertions + if (assertions.length > 0) { + builder.array('assertions', builder => { + for (const assertion of assertions) { + builder.value(valueForDnsAssertion(genfile, assertion)) + } + }) + } + } + }) +} diff --git a/packages/cli/src/constructs/dns-request.ts b/packages/cli/src/constructs/dns-request.ts new file mode 100644 index 000000000..3a933f5da --- /dev/null +++ b/packages/cli/src/constructs/dns-request.ts @@ -0,0 +1,70 @@ +import { DnsAssertion } from './dns-assertion' + +export type DnsRecordType = + | 'A' + | 'AAAA' + | 'CNAME' + | 'MX' + | 'NS' + | 'TXT' + | 'SOA' + +export type DnsProtocol = + | 'UDP' + | 'TCP' + +/** + * Configuration for DNS requests. + * Defines the query parameters and validation rules. + */ +export interface DnsRequest { + /** + * The DNS record type to query for. + * + * @example "A" + * @example "AAAA" + * @example "TXT" + */ + recordType: DnsRecordType + + /** + * The DNS query. Value should be appropriate for the record type you've + * selected. + * + * @example 'api.example.com' | '192.168.1.1' + */ + query: string + + /** + * The name server the query should be made to. If not set, an appropriate + * name server will be used automatically. + * + * @example "8.8.4.4" + * @example "resolver1.opendns.com" + */ + nameServer?: string + + /** + * The port of the name server. + * + * @minimum 1 + * @maximum 65535 + * @example 53 + * @default 53 + */ + port?: number + + /** + * The protocol used to connect to the name server. + * + * @example "UDP" + * @example "TCP" + * @default "UDP" + */ + protocol?: DnsProtocol + + /** + * Assertions to validate the DNS response. + */ + assertions?: Array +} diff --git a/packages/cli/src/constructs/index.ts b/packages/cli/src/constructs/index.ts index 2d548b68a..54e6616f2 100644 --- a/packages/cli/src/constructs/index.ts +++ b/packages/cli/src/constructs/index.ts @@ -42,3 +42,6 @@ export * from './url-monitor' export * from './url-assertion' export * from './url-request' export * from './ip' +export * from './dns-monitor' +export * from './dns-assertion' +export * from './dns-request' diff --git a/packages/cli/src/constructs/internal/assertion-codegen.ts b/packages/cli/src/constructs/internal/assertion-codegen.ts index 270cb44ec..a47d20184 100644 --- a/packages/cli/src/constructs/internal/assertion-codegen.ts +++ b/packages/cli/src/constructs/internal/assertion-codegen.ts @@ -42,17 +42,29 @@ export function valueForNumericAssertion ( }) } +export interface ValueForGeneralAssertionOptions { + hasProperty?: boolean + hasRegex?: boolean +} + export function valueForGeneralAssertion ( klass: string, method: string, assertion: Assertion, + options?: ValueForGeneralAssertionOptions, ): Value { return expr(ident(klass), builder => { builder.member(ident(method)) builder.call(builder => { - if (assertion.property !== '') { + const hasProperty = options?.hasProperty ?? true + if (hasProperty && assertion.property !== '') { builder.string(assertion.property) } + + const hasRegex = options?.hasRegex ?? true + if (hasRegex && assertion.regex !== '' && assertion.regex !== null) { + builder.string(assertion.regex) + } }) switch (assertion.comparison) { case 'EQUALS': diff --git a/packages/cli/src/constructs/internal/common-diagnostics.ts b/packages/cli/src/constructs/internal/common-diagnostics.ts index 746e488ed..8fb8da7ad 100644 --- a/packages/cli/src/constructs/internal/common-diagnostics.ts +++ b/packages/cli/src/constructs/internal/common-diagnostics.ts @@ -1,4 +1,9 @@ -import { DeprecatedPropertyDiagnostic, InvalidPropertyValueDiagnostic, RemovedPropertyDiagnostic } from '../construct-diagnostics' +import { + DeprecatedPropertyDiagnostic, + InvalidPropertyValueDiagnostic, + RemovedPropertyDiagnostic, + UnsupportedPropertyDiagnostic, +} from '../construct-diagnostics' import { Diagnostics, Diagnostic } from '../diagnostics' import { RetryStrategy } from '../retry-strategy' @@ -60,6 +65,10 @@ export async function validateRemovedDoubleCheck (diagnostics: Diagnostics, prop await validateDoubleCheck(diagnostics, RemovedPropertyDiagnostic, props) } +export async function validateUnsupportedDoubleCheck (diagnostics: Diagnostics, props: RetryStrategyProps) { + await validateDoubleCheck(diagnostics, UnsupportedPropertyDiagnostic, props) +} + type ResponseTimeProps = { degradedResponseTime?: number maxResponseTime?: number diff --git a/packages/cli/src/constructs/monitor.ts b/packages/cli/src/constructs/monitor.ts index 6df99555a..f026854fa 100644 --- a/packages/cli/src/constructs/monitor.ts +++ b/packages/cli/src/constructs/monitor.ts @@ -16,7 +16,7 @@ import { } from './retry-strategy' import { Check, CheckProps } from './check' import { Diagnostics } from './diagnostics' -import { validateRemovedDoubleCheck } from './internal/common-diagnostics' +import { validateUnsupportedDoubleCheck } from './internal/common-diagnostics' /** * Retry strategies supported by monitors. @@ -117,7 +117,7 @@ export abstract class Monitor extends Check { } protected async validateDoubleCheck (diagnostics: Diagnostics): Promise { - await validateRemovedDoubleCheck(diagnostics, this) + await validateUnsupportedDoubleCheck(diagnostics, this) } synthesize () {