@checkstack/healthcheck-dns-backend 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # @checkstack/healthcheck-dns-backend
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f5b1f49: Refactored health check strategies to use `createClient()` pattern with built-in collectors.
8
+
9
+ **Strategy Changes:**
10
+
11
+ - Replaced `execute()` with `createClient()` that returns a transport client
12
+ - Strategy configs now only contain connection parameters
13
+ - Collector configs handle what to do with the connection
14
+
15
+ **Built-in Collectors Added:**
16
+
17
+ - DNS: `LookupCollector` for hostname resolution
18
+ - gRPC: `HealthCollector` for gRPC health protocol
19
+ - HTTP: `RequestCollector` for HTTP requests
20
+ - MySQL: `QueryCollector` for database queries
21
+ - Ping: `PingCollector` for ICMP ping
22
+ - Postgres: `QueryCollector` for database queries
23
+ - Redis: `CommandCollector` for Redis commands
24
+ - Script: `ExecuteCollector` for script execution
25
+ - SSH: `CommandCollector` for SSH commands
26
+ - TCP: `BannerCollector` for TCP banner grabbing
27
+ - TLS: `CertificateCollector` for certificate inspection
28
+
29
+ ### Patch Changes
30
+
31
+ - Updated dependencies [f5b1f49]
32
+ - Updated dependencies [f5b1f49]
33
+ - Updated dependencies [f5b1f49]
34
+ - Updated dependencies [f5b1f49]
35
+ - @checkstack/backend-api@0.1.0
36
+ - @checkstack/healthcheck-common@0.1.0
37
+ - @checkstack/common@0.0.3
38
+
39
+ ## 0.0.3
40
+
41
+ ### Patch Changes
42
+
43
+ - Updated dependencies [cb82e4d]
44
+ - @checkstack/healthcheck-common@0.0.3
45
+
3
46
  ## 0.0.2
4
47
 
5
48
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-dns-backend",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -1,9 +1,7 @@
1
- import {
2
- createBackendPlugin,
3
- coreServices,
4
- } from "@checkstack/backend-api";
1
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
5
2
  import { DnsHealthCheckStrategy } from "./strategy";
6
3
  import { pluginMetadata } from "./plugin-metadata";
4
+ import { LookupCollector } from "./lookup-collector";
7
5
 
8
6
  export default createBackendPlugin({
9
7
  metadata: pluginMetadata,
@@ -11,13 +9,17 @@ export default createBackendPlugin({
11
9
  env.registerInit({
12
10
  deps: {
13
11
  healthCheckRegistry: coreServices.healthCheckRegistry,
12
+ collectorRegistry: coreServices.collectorRegistry,
14
13
  logger: coreServices.logger,
15
14
  },
16
- init: async ({ healthCheckRegistry, logger }) => {
15
+ init: async ({ healthCheckRegistry, collectorRegistry, logger }) => {
17
16
  logger.debug("🔌 Registering DNS Health Check Strategy...");
18
17
  const strategy = new DnsHealthCheckStrategy();
19
18
  healthCheckRegistry.register(strategy);
19
+ collectorRegistry.register(new LookupCollector());
20
20
  },
21
21
  });
22
22
  },
23
23
  });
24
+
25
+ export { pluginMetadata } from "./plugin-metadata";
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { LookupCollector, type LookupConfig } from "./lookup-collector";
3
+ import type { DnsTransportClient } from "./transport-client";
4
+
5
+ describe("LookupCollector", () => {
6
+ const createMockClient = (
7
+ response: { values: string[]; error?: string } = { values: ["192.168.1.1"] }
8
+ ): DnsTransportClient => ({
9
+ exec: mock(() => Promise.resolve(response)),
10
+ });
11
+
12
+ describe("execute", () => {
13
+ it("should resolve DNS records successfully", async () => {
14
+ const collector = new LookupCollector();
15
+ const client = createMockClient({
16
+ values: ["192.168.1.1", "192.168.1.2"],
17
+ });
18
+
19
+ const result = await collector.execute({
20
+ config: { hostname: "example.com", recordType: "A" },
21
+ client,
22
+ pluginId: "test",
23
+ });
24
+
25
+ expect(result.result.values).toEqual(["192.168.1.1", "192.168.1.2"]);
26
+ expect(result.result.recordCount).toBe(2);
27
+ expect(result.result.resolutionTimeMs).toBeGreaterThanOrEqual(0);
28
+ expect(result.error).toBeUndefined();
29
+ });
30
+
31
+ it("should return error for failed lookups", async () => {
32
+ const collector = new LookupCollector();
33
+ const client = createMockClient({ values: [], error: "NXDOMAIN" });
34
+
35
+ const result = await collector.execute({
36
+ config: { hostname: "nonexistent.invalid", recordType: "A" },
37
+ client,
38
+ pluginId: "test",
39
+ });
40
+
41
+ expect(result.result.recordCount).toBe(0);
42
+ expect(result.error).toBe("NXDOMAIN");
43
+ });
44
+
45
+ it("should pass correct parameters to client", async () => {
46
+ const collector = new LookupCollector();
47
+ const client = createMockClient();
48
+
49
+ await collector.execute({
50
+ config: { hostname: "example.com", recordType: "MX" },
51
+ client,
52
+ pluginId: "test",
53
+ });
54
+
55
+ expect(client.exec).toHaveBeenCalledWith({
56
+ hostname: "example.com",
57
+ recordType: "MX",
58
+ });
59
+ });
60
+ });
61
+
62
+ describe("aggregateResult", () => {
63
+ it("should calculate average resolution time", () => {
64
+ const collector = new LookupCollector();
65
+ const runs = [
66
+ {
67
+ id: "1",
68
+ status: "healthy" as const,
69
+ latencyMs: 10,
70
+ checkId: "c1",
71
+ timestamp: new Date(),
72
+ metadata: {
73
+ values: ["1.1.1.1"],
74
+ recordCount: 1,
75
+ resolutionTimeMs: 50,
76
+ },
77
+ },
78
+ {
79
+ id: "2",
80
+ status: "healthy" as const,
81
+ latencyMs: 15,
82
+ checkId: "c1",
83
+ timestamp: new Date(),
84
+ metadata: {
85
+ values: ["1.1.1.1"],
86
+ recordCount: 1,
87
+ resolutionTimeMs: 100,
88
+ },
89
+ },
90
+ ];
91
+
92
+ const aggregated = collector.aggregateResult(runs);
93
+
94
+ expect(aggregated.avgResolutionTimeMs).toBe(75);
95
+ expect(aggregated.successRate).toBe(100);
96
+ });
97
+
98
+ it("should calculate success rate correctly", () => {
99
+ const collector = new LookupCollector();
100
+ const runs = [
101
+ {
102
+ id: "1",
103
+ status: "healthy" as const,
104
+ latencyMs: 10,
105
+ checkId: "c1",
106
+ timestamp: new Date(),
107
+ metadata: {
108
+ values: ["1.1.1.1"],
109
+ recordCount: 1,
110
+ resolutionTimeMs: 50,
111
+ },
112
+ },
113
+ {
114
+ id: "2",
115
+ status: "unhealthy" as const,
116
+ latencyMs: 15,
117
+ checkId: "c1",
118
+ timestamp: new Date(),
119
+ metadata: { values: [], recordCount: 0, resolutionTimeMs: 100 },
120
+ },
121
+ ];
122
+
123
+ const aggregated = collector.aggregateResult(runs);
124
+
125
+ expect(aggregated.successRate).toBe(50);
126
+ });
127
+ });
128
+
129
+ describe("metadata", () => {
130
+ it("should have correct static properties", () => {
131
+ const collector = new LookupCollector();
132
+
133
+ expect(collector.id).toBe("lookup");
134
+ expect(collector.displayName).toBe("DNS Lookup");
135
+ expect(collector.allowMultiple).toBe(true);
136
+ expect(collector.supportedPlugins).toHaveLength(1);
137
+ });
138
+ });
139
+ });
@@ -0,0 +1,147 @@
1
+ import {
2
+ Versioned,
3
+ z,
4
+ type HealthCheckRunForAggregation,
5
+ type CollectorResult,
6
+ type CollectorStrategy,
7
+ } from "@checkstack/backend-api";
8
+ import { healthResultNumber } from "@checkstack/healthcheck-common";
9
+ import { pluginMetadata } from "./plugin-metadata";
10
+ import type { DnsTransportClient } from "./transport-client";
11
+
12
+ // ============================================================================
13
+ // CONFIGURATION SCHEMA
14
+ // ============================================================================
15
+
16
+ const lookupConfigSchema = z.object({
17
+ hostname: z.string().min(1).describe("Hostname to resolve"),
18
+ recordType: z
19
+ .enum(["A", "AAAA", "CNAME", "MX", "TXT", "NS"])
20
+ .default("A")
21
+ .describe("DNS record type"),
22
+ nameserver: z.string().optional().describe("Custom nameserver (optional)"),
23
+ });
24
+
25
+ export type LookupConfig = z.infer<typeof lookupConfigSchema>;
26
+
27
+ // ============================================================================
28
+ // RESULT SCHEMAS
29
+ // ============================================================================
30
+
31
+ const lookupResultSchema = z.object({
32
+ values: z.array(z.string()).meta({
33
+ "x-chart-type": "text",
34
+ "x-chart-label": "Resolved Values",
35
+ }),
36
+ recordCount: healthResultNumber({
37
+ "x-chart-type": "counter",
38
+ "x-chart-label": "Record Count",
39
+ }),
40
+ resolutionTimeMs: healthResultNumber({
41
+ "x-chart-type": "line",
42
+ "x-chart-label": "Resolution Time",
43
+ "x-chart-unit": "ms",
44
+ }),
45
+ });
46
+
47
+ export type LookupResult = z.infer<typeof lookupResultSchema>;
48
+
49
+ const lookupAggregatedSchema = z.object({
50
+ avgResolutionTimeMs: healthResultNumber({
51
+ "x-chart-type": "line",
52
+ "x-chart-label": "Avg Resolution Time",
53
+ "x-chart-unit": "ms",
54
+ }),
55
+ successRate: healthResultNumber({
56
+ "x-chart-type": "gauge",
57
+ "x-chart-label": "Success Rate",
58
+ "x-chart-unit": "%",
59
+ }),
60
+ });
61
+
62
+ export type LookupAggregatedResult = z.infer<typeof lookupAggregatedSchema>;
63
+
64
+ // ============================================================================
65
+ // LOOKUP COLLECTOR
66
+ // ============================================================================
67
+
68
+ /**
69
+ * Built-in DNS lookup collector.
70
+ * Resolves DNS records and checks results.
71
+ */
72
+ export class LookupCollector
73
+ implements
74
+ CollectorStrategy<
75
+ DnsTransportClient,
76
+ LookupConfig,
77
+ LookupResult,
78
+ LookupAggregatedResult
79
+ >
80
+ {
81
+ id = "lookup";
82
+ displayName = "DNS Lookup";
83
+ description = "Resolve DNS records and check the results";
84
+
85
+ supportedPlugins = [pluginMetadata];
86
+
87
+ allowMultiple = true;
88
+
89
+ config = new Versioned({ version: 1, schema: lookupConfigSchema });
90
+ result = new Versioned({ version: 1, schema: lookupResultSchema });
91
+ aggregatedResult = new Versioned({
92
+ version: 1,
93
+ schema: lookupAggregatedSchema,
94
+ });
95
+
96
+ async execute({
97
+ config,
98
+ client,
99
+ }: {
100
+ config: LookupConfig;
101
+ client: DnsTransportClient;
102
+ pluginId: string;
103
+ }): Promise<CollectorResult<LookupResult>> {
104
+ const startTime = Date.now();
105
+
106
+ const response = await client.exec({
107
+ hostname: config.hostname,
108
+ recordType: config.recordType,
109
+ });
110
+
111
+ const resolutionTimeMs = Date.now() - startTime;
112
+
113
+ return {
114
+ result: {
115
+ values: response.values,
116
+ recordCount: response.values.length,
117
+ resolutionTimeMs,
118
+ },
119
+ error: response.error,
120
+ };
121
+ }
122
+
123
+ aggregateResult(
124
+ runs: HealthCheckRunForAggregation<LookupResult>[]
125
+ ): LookupAggregatedResult {
126
+ const times = runs
127
+ .map((r) => r.metadata?.resolutionTimeMs)
128
+ .filter((v): v is number => typeof v === "number");
129
+
130
+ const recordCounts = runs
131
+ .map((r) => r.metadata?.recordCount)
132
+ .filter((v): v is number => typeof v === "number");
133
+
134
+ const successCount = recordCounts.filter((c) => c > 0).length;
135
+
136
+ return {
137
+ avgResolutionTimeMs:
138
+ times.length > 0
139
+ ? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
140
+ : 0,
141
+ successRate:
142
+ recordCounts.length > 0
143
+ ? Math.round((successCount / recordCounts.length) * 100)
144
+ : 0,
145
+ };
146
+ }
147
+ }
@@ -53,102 +53,83 @@ describe("DnsHealthCheckStrategy", () => {
53
53
  } as DnsResolver);
54
54
  };
55
55
 
56
- describe("execute", () => {
57
- it("should return healthy for successful A record resolution", async () => {
58
- const strategy = new DnsHealthCheckStrategy(
59
- createMockResolver({ resolve4: ["1.2.3.4", "5.6.7.8"] })
60
- );
56
+ describe("createClient", () => {
57
+ it("should return a connected client", async () => {
58
+ const strategy = new DnsHealthCheckStrategy(createMockResolver());
59
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
60
+
61
+ expect(connectedClient.client).toBeDefined();
62
+ expect(connectedClient.client.exec).toBeDefined();
63
+ expect(connectedClient.close).toBeDefined();
64
+ });
61
65
 
62
- const result = await strategy.execute({
63
- hostname: "example.com",
64
- recordType: "A",
65
- timeout: 5000,
66
- });
66
+ it("should allow closing the client", async () => {
67
+ const strategy = new DnsHealthCheckStrategy(createMockResolver());
68
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
67
69
 
68
- expect(result.status).toBe("healthy");
69
- expect(result.metadata?.resolvedValues).toEqual(["1.2.3.4", "5.6.7.8"]);
70
- expect(result.metadata?.recordCount).toBe(2);
70
+ expect(() => connectedClient.close()).not.toThrow();
71
71
  });
72
72
 
73
- it("should return unhealthy for DNS error", async () => {
74
- const strategy = new DnsHealthCheckStrategy(
75
- createMockResolver({ resolve4: new Error("NXDOMAIN") })
76
- );
73
+ it("should use custom nameserver when provided", async () => {
74
+ const setServersMock = mock(() => {});
75
+ const strategy = new DnsHealthCheckStrategy(() => ({
76
+ setServers: setServersMock,
77
+ resolve4: mock(() => Promise.resolve(["1.2.3.4"])),
78
+ resolve6: mock(() => Promise.resolve([])),
79
+ resolveCname: mock(() => Promise.resolve([])),
80
+ resolveMx: mock(() => Promise.resolve([])),
81
+ resolveTxt: mock(() => Promise.resolve([])),
82
+ resolveNs: mock(() => Promise.resolve([])),
83
+ }));
77
84
 
78
- const result = await strategy.execute({
79
- hostname: "nonexistent.example.com",
80
- recordType: "A",
85
+ const connectedClient = await strategy.createClient({
86
+ nameserver: "8.8.8.8",
81
87
  timeout: 5000,
82
88
  });
83
89
 
84
- expect(result.status).toBe("unhealthy");
85
- expect(result.message).toContain("NXDOMAIN");
86
- expect(result.metadata?.error).toBeDefined();
87
- });
88
-
89
- it("should pass recordExists assertion when records found", async () => {
90
- const strategy = new DnsHealthCheckStrategy(
91
- createMockResolver({ resolve4: ["1.2.3.4"] })
92
- );
93
-
94
- const result = await strategy.execute({
90
+ // Execute to trigger resolver setup
91
+ await connectedClient.client.exec({
95
92
  hostname: "example.com",
96
93
  recordType: "A",
97
- timeout: 5000,
98
- assertions: [{ field: "recordExists", operator: "isTrue" }],
99
94
  });
100
95
 
101
- expect(result.status).toBe("healthy");
96
+ expect(setServersMock).toHaveBeenCalledWith(["8.8.8.8"]);
97
+
98
+ connectedClient.close();
102
99
  });
100
+ });
103
101
 
104
- it("should fail recordExists assertion when no records", async () => {
102
+ describe("client.exec", () => {
103
+ it("should return resolved values for successful A record resolution", async () => {
105
104
  const strategy = new DnsHealthCheckStrategy(
106
- createMockResolver({ resolve4: [] })
105
+ createMockResolver({ resolve4: ["1.2.3.4", "5.6.7.8"] })
107
106
  );
107
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
108
108
 
109
- const result = await strategy.execute({
109
+ const result = await connectedClient.client.exec({
110
110
  hostname: "example.com",
111
111
  recordType: "A",
112
- timeout: 5000,
113
- assertions: [{ field: "recordExists", operator: "isTrue" }],
114
112
  });
115
113
 
116
- expect(result.status).toBe("unhealthy");
117
- expect(result.message).toContain("Assertion failed");
118
- });
119
-
120
- it("should pass recordValue assertion with matching value", async () => {
121
- const strategy = new DnsHealthCheckStrategy(
122
- createMockResolver({ resolveCname: ["cdn.example.com"] })
123
- );
124
-
125
- const result = await strategy.execute({
126
- hostname: "example.com",
127
- recordType: "CNAME",
128
- timeout: 5000,
129
- assertions: [
130
- { field: "recordValue", operator: "contains", value: "cdn" },
131
- ],
132
- });
114
+ expect(result.values).toEqual(["1.2.3.4", "5.6.7.8"]);
133
115
 
134
- expect(result.status).toBe("healthy");
116
+ connectedClient.close();
135
117
  });
136
118
 
137
- it("should pass recordCount assertion", async () => {
119
+ it("should return error for DNS error", async () => {
138
120
  const strategy = new DnsHealthCheckStrategy(
139
- createMockResolver({ resolve4: ["1.2.3.4", "5.6.7.8", "9.10.11.12"] })
121
+ createMockResolver({ resolve4: new Error("NXDOMAIN") })
140
122
  );
123
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
141
124
 
142
- const result = await strategy.execute({
143
- hostname: "example.com",
125
+ const result = await connectedClient.client.exec({
126
+ hostname: "nonexistent.example.com",
144
127
  recordType: "A",
145
- timeout: 5000,
146
- assertions: [
147
- { field: "recordCount", operator: "greaterThanOrEqual", value: 2 },
148
- ],
149
128
  });
150
129
 
151
- expect(result.status).toBe("healthy");
130
+ expect(result.error).toContain("NXDOMAIN");
131
+
132
+ connectedClient.close();
152
133
  });
153
134
 
154
135
  it("should resolve MX records correctly", async () => {
@@ -160,37 +141,16 @@ describe("DnsHealthCheckStrategy", () => {
160
141
  ],
161
142
  })
162
143
  );
144
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
163
145
 
164
- const result = await strategy.execute({
146
+ const result = await connectedClient.client.exec({
165
147
  hostname: "example.com",
166
148
  recordType: "MX",
167
- timeout: 5000,
168
149
  });
169
150
 
170
- expect(result.status).toBe("healthy");
171
- expect(result.metadata?.resolvedValues).toContain("0 mail1.example.com");
172
- });
151
+ expect(result.values).toContain("0 mail1.example.com");
173
152
 
174
- it("should use custom nameserver when provided", async () => {
175
- const setServersMock = mock(() => {});
176
- const strategy = new DnsHealthCheckStrategy(() => ({
177
- setServers: setServersMock,
178
- resolve4: mock(() => Promise.resolve(["1.2.3.4"])),
179
- resolve6: mock(() => Promise.resolve([])),
180
- resolveCname: mock(() => Promise.resolve([])),
181
- resolveMx: mock(() => Promise.resolve([])),
182
- resolveTxt: mock(() => Promise.resolve([])),
183
- resolveNs: mock(() => Promise.resolve([])),
184
- }));
185
-
186
- await strategy.execute({
187
- hostname: "example.com",
188
- recordType: "A",
189
- nameserver: "8.8.8.8",
190
- timeout: 5000,
191
- });
192
-
193
- expect(setServersMock).toHaveBeenCalledWith(["8.8.8.8"]);
153
+ connectedClient.close();
194
154
  });
195
155
  });
196
156
 
@@ -264,7 +224,7 @@ describe("DnsHealthCheckStrategy", () => {
264
224
  const aggregated = strategy.aggregateResult(runs);
265
225
 
266
226
  expect(aggregated.errorCount).toBe(1);
267
- expect(aggregated.failureCount).toBe(1);
227
+ expect(aggregated.failureCount).toBe(2);
268
228
  });
269
229
  });
270
230
  });
package/src/strategy.ts CHANGED
@@ -1,63 +1,48 @@
1
1
  import * as dns from "node:dns/promises";
2
2
  import {
3
3
  HealthCheckStrategy,
4
- HealthCheckResult,
5
4
  HealthCheckRunForAggregation,
6
5
  Versioned,
7
6
  z,
8
- booleanField,
9
- stringField,
10
- numericField,
11
- timeThresholdField,
12
- evaluateAssertions,
7
+ type ConnectedClient,
13
8
  } from "@checkstack/backend-api";
14
9
  import {
15
10
  healthResultNumber,
16
11
  healthResultString,
17
12
  } from "@checkstack/healthcheck-common";
13
+ import type {
14
+ DnsTransportClient,
15
+ DnsLookupRequest,
16
+ DnsLookupResult,
17
+ } from "./transport-client";
18
18
 
19
19
  // ============================================================================
20
20
  // SCHEMAS
21
21
  // ============================================================================
22
22
 
23
- /**
24
- * Assertion schema for DNS health checks using shared factories.
25
- */
26
- const dnsAssertionSchema = z.discriminatedUnion("field", [
27
- booleanField("recordExists"),
28
- stringField("recordValue"),
29
- numericField("recordCount", { min: 0 }),
30
- timeThresholdField("resolutionTime"),
31
- ]);
32
-
33
- export type DnsAssertion = z.infer<typeof dnsAssertionSchema>;
34
-
35
23
  /**
36
24
  * Configuration schema for DNS health checks.
25
+ * Resolver configuration only - action params moved to LookupCollector.
37
26
  */
38
27
  export const dnsConfigSchema = z.object({
39
- hostname: z.string().describe("Hostname to resolve"),
40
- recordType: z
41
- .enum(["A", "AAAA", "CNAME", "MX", "TXT", "NS"])
42
- .default("A")
43
- .describe("DNS record type to query"),
44
- nameserver: z
45
- .string()
46
- .optional()
47
- .describe("Custom nameserver (optional, uses system default)"),
28
+ nameserver: z.string().optional().describe("Custom nameserver (optional)"),
48
29
  timeout: z
49
30
  .number()
50
31
  .min(100)
51
32
  .default(5000)
52
33
  .describe("Timeout in milliseconds"),
53
- assertions: z
54
- .array(dnsAssertionSchema)
55
- .optional()
56
- .describe("Conditions for validation"),
57
34
  });
58
35
 
59
36
  export type DnsConfig = z.infer<typeof dnsConfigSchema>;
60
37
 
38
+ // Legacy config type for migrations
39
+ interface DnsConfigV1 {
40
+ hostname: string;
41
+ recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS";
42
+ nameserver?: string;
43
+ timeout: number;
44
+ }
45
+
61
46
  /**
62
47
  * Per-run result metadata.
63
48
  */
@@ -70,23 +55,18 @@ const dnsResultSchema = z.object({
70
55
  "x-chart-type": "counter",
71
56
  "x-chart-label": "Record Count",
72
57
  }),
73
- nameserver: healthResultString({
74
- "x-chart-type": "text",
75
- "x-chart-label": "Nameserver",
76
- }).optional(),
77
58
  resolutionTimeMs: healthResultNumber({
78
59
  "x-chart-type": "line",
79
60
  "x-chart-label": "Resolution Time",
80
61
  "x-chart-unit": "ms",
81
62
  }),
82
- failedAssertion: dnsAssertionSchema.optional(),
83
63
  error: healthResultString({
84
64
  "x-chart-type": "status",
85
65
  "x-chart-label": "Error",
86
66
  }).optional(),
87
67
  });
88
68
 
89
- export type DnsResult = z.infer<typeof dnsResultSchema>;
69
+ type DnsResult = z.infer<typeof dnsResultSchema>;
90
70
 
91
71
  /**
92
72
  * Aggregated metadata for buckets.
@@ -107,7 +87,7 @@ const dnsAggregatedSchema = z.object({
107
87
  }),
108
88
  });
109
89
 
110
- export type DnsAggregatedResult = z.infer<typeof dnsAggregatedSchema>;
90
+ type DnsAggregatedResult = z.infer<typeof dnsAggregatedSchema>;
111
91
 
112
92
  // ============================================================================
113
93
  // RESOLVER INTERFACE (for testability)
@@ -128,21 +108,25 @@ export interface DnsResolver {
128
108
  export type ResolverFactory = () => DnsResolver;
129
109
 
130
110
  // Default factory using Node.js dns module
131
- const defaultResolverFactory: ResolverFactory = () =>
132
- new dns.Resolver() as DnsResolver;
111
+ const defaultResolverFactory: ResolverFactory = () => new dns.Resolver();
133
112
 
134
113
  // ============================================================================
135
114
  // STRATEGY
136
115
  // ============================================================================
137
116
 
138
117
  export class DnsHealthCheckStrategy
139
- implements HealthCheckStrategy<DnsConfig, DnsResult, DnsAggregatedResult>
118
+ implements
119
+ HealthCheckStrategy<
120
+ DnsConfig,
121
+ DnsTransportClient,
122
+ DnsResult,
123
+ DnsAggregatedResult
124
+ >
140
125
  {
141
126
  id = "dns";
142
127
  displayName = "DNS Health Check";
143
128
  description = "DNS record resolution with response validation";
144
129
 
145
- // Injected resolver factory for testing
146
130
  private resolverFactory: ResolverFactory;
147
131
 
148
132
  constructor(resolverFactory: ResolverFactory = defaultResolverFactory) {
@@ -150,13 +134,32 @@ export class DnsHealthCheckStrategy
150
134
  }
151
135
 
152
136
  config: Versioned<DnsConfig> = new Versioned({
153
- version: 1,
137
+ version: 2,
154
138
  schema: dnsConfigSchema,
139
+ migrations: [
140
+ {
141
+ fromVersion: 1,
142
+ toVersion: 2,
143
+ description: "Remove hostname/recordType (moved to LookupCollector)",
144
+ migrate: (data: DnsConfigV1): DnsConfig => ({
145
+ nameserver: data.nameserver,
146
+ timeout: data.timeout,
147
+ }),
148
+ },
149
+ ],
155
150
  });
156
151
 
157
152
  result: Versioned<DnsResult> = new Versioned({
158
- version: 1,
153
+ version: 2,
159
154
  schema: dnsResultSchema,
155
+ migrations: [
156
+ {
157
+ fromVersion: 1,
158
+ toVersion: 2,
159
+ description: "Migrate to createClient pattern (no result changes)",
160
+ migrate: (data: unknown) => data,
161
+ },
162
+ ],
160
163
  });
161
164
 
162
165
  aggregatedResult: Versioned<DnsAggregatedResult> = new Versioned({
@@ -167,148 +170,103 @@ export class DnsHealthCheckStrategy
167
170
  aggregateResult(
168
171
  runs: HealthCheckRunForAggregation<DnsResult>[]
169
172
  ): DnsAggregatedResult {
170
- let totalResolutionTime = 0;
171
- let failureCount = 0;
172
- let errorCount = 0;
173
- let validRuns = 0;
174
-
175
- for (const run of runs) {
176
- if (run.metadata?.error) {
177
- errorCount++;
178
- continue;
179
- }
180
- if (run.status === "unhealthy") {
181
- failureCount++;
182
- }
183
- if (run.metadata) {
184
- totalResolutionTime += run.metadata.resolutionTimeMs;
185
- validRuns++;
186
- }
173
+ const validRuns = runs.filter((r) => r.metadata);
174
+
175
+ if (validRuns.length === 0) {
176
+ return { avgResolutionTime: 0, failureCount: 0, errorCount: 0 };
187
177
  }
188
178
 
189
- return {
190
- avgResolutionTime: validRuns > 0 ? totalResolutionTime / validRuns : 0,
191
- failureCount,
192
- errorCount,
193
- };
179
+ const resolutionTimes = validRuns
180
+ .map((r) => r.metadata?.resolutionTimeMs)
181
+ .filter((t): t is number => typeof t === "number");
182
+
183
+ const avgResolutionTime =
184
+ resolutionTimes.length > 0
185
+ ? Math.round(
186
+ resolutionTimes.reduce((a, b) => a + b, 0) / resolutionTimes.length
187
+ )
188
+ : 0;
189
+
190
+ const failureCount = validRuns.filter(
191
+ (r) => r.metadata?.recordCount === 0
192
+ ).length;
193
+
194
+ const errorCount = validRuns.filter(
195
+ (r) => r.metadata?.error !== undefined
196
+ ).length;
197
+
198
+ return { avgResolutionTime, failureCount, errorCount };
194
199
  }
195
200
 
196
- async execute(config: DnsConfig): Promise<HealthCheckResult<DnsResult>> {
201
+ async createClient(
202
+ config: DnsConfig
203
+ ): Promise<ConnectedClient<DnsTransportClient>> {
197
204
  const validatedConfig = this.config.validate(config);
198
- const start = performance.now();
205
+ const resolver = this.resolverFactory();
199
206
 
200
- try {
201
- // Configure resolver with custom nameserver if provided
202
- const resolver = this.resolverFactory();
203
- if (validatedConfig.nameserver) {
204
- resolver.setServers([validatedConfig.nameserver]);
205
- }
207
+ if (validatedConfig.nameserver) {
208
+ resolver.setServers([validatedConfig.nameserver]);
209
+ }
206
210
 
207
- // Perform DNS lookup based on record type
208
- const resolvedValues = await this.resolveRecords(
209
- resolver,
210
- validatedConfig.hostname,
211
- validatedConfig.recordType,
212
- validatedConfig.timeout
213
- );
214
-
215
- const end = performance.now();
216
- const resolutionTimeMs = Math.round(end - start);
217
-
218
- const result: Omit<DnsResult, "failedAssertion" | "error"> = {
219
- resolvedValues,
220
- recordCount: resolvedValues.length,
221
- nameserver: validatedConfig.nameserver,
222
- resolutionTimeMs,
223
- };
224
-
225
- // Evaluate assertions using shared utility
226
- const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
227
- recordExists: resolvedValues.length > 0,
228
- recordValue: resolvedValues[0] ?? "",
229
- recordCount: resolvedValues.length,
230
- resolutionTime: resolutionTimeMs,
231
- });
232
-
233
- if (failedAssertion) {
234
- return {
235
- status: "unhealthy",
236
- latencyMs: resolutionTimeMs,
237
- message: `Assertion failed: ${failedAssertion.field} ${
238
- failedAssertion.operator
239
- }${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
240
- metadata: { ...result, failedAssertion },
241
- };
242
- }
211
+ const client: DnsTransportClient = {
212
+ exec: async (request: DnsLookupRequest): Promise<DnsLookupResult> => {
213
+ const timeout = validatedConfig.timeout;
214
+ const timeoutPromise = new Promise<never>((_, reject) =>
215
+ setTimeout(() => reject(new Error("DNS resolution timeout")), timeout)
216
+ );
217
+
218
+ try {
219
+ const resolvePromise = this.resolveRecords(
220
+ resolver,
221
+ request.hostname,
222
+ request.recordType
223
+ );
224
+
225
+ const values = await Promise.race([resolvePromise, timeoutPromise]);
226
+ return { values };
227
+ } catch (error) {
228
+ return {
229
+ values: [],
230
+ error: error instanceof Error ? error.message : String(error),
231
+ };
232
+ }
233
+ },
234
+ };
243
235
 
244
- return {
245
- status: "healthy",
246
- latencyMs: resolutionTimeMs,
247
- message: `Resolved ${validatedConfig.hostname} (${
248
- validatedConfig.recordType
249
- }): ${resolvedValues.slice(0, 3).join(", ")}${
250
- resolvedValues.length > 3 ? "..." : ""
251
- }`,
252
- metadata: result,
253
- };
254
- } catch (error: unknown) {
255
- const end = performance.now();
256
- const isError = error instanceof Error;
257
- return {
258
- status: "unhealthy",
259
- latencyMs: Math.round(end - start),
260
- message: isError ? error.message : "DNS resolution failed",
261
- metadata: {
262
- resolvedValues: [],
263
- recordCount: 0,
264
- nameserver: validatedConfig.nameserver,
265
- resolutionTimeMs: Math.round(end - start),
266
- error: isError ? error.name : "UnknownError",
267
- },
268
- };
269
- }
236
+ return {
237
+ client,
238
+ close: () => {
239
+ // DNS resolver is stateless, nothing to close
240
+ },
241
+ };
270
242
  }
271
243
 
272
- /**
273
- * Resolve DNS records based on type.
274
- */
275
244
  private async resolveRecords(
276
245
  resolver: DnsResolver,
277
246
  hostname: string,
278
- recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS",
279
- timeout: number
247
+ recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS"
280
248
  ): Promise<string[]> {
281
- // Create timeout promise
282
- const timeoutPromise = new Promise<never>((_, reject) => {
283
- setTimeout(() => reject(new Error("DNS resolution timeout")), timeout);
284
- });
285
-
286
- // Resolve based on record type
287
- const resolvePromise = (async () => {
288
- switch (recordType) {
289
- case "A": {
290
- return await resolver.resolve4(hostname);
291
- }
292
- case "AAAA": {
293
- return await resolver.resolve6(hostname);
294
- }
295
- case "CNAME": {
296
- return await resolver.resolveCname(hostname);
297
- }
298
- case "MX": {
299
- const records = await resolver.resolveMx(hostname);
300
- return records.map((r) => `${r.priority} ${r.exchange}`);
301
- }
302
- case "TXT": {
303
- const records = await resolver.resolveTxt(hostname);
304
- return records.map((r) => r.join(""));
305
- }
306
- case "NS": {
307
- return await resolver.resolveNs(hostname);
308
- }
249
+ switch (recordType) {
250
+ case "A": {
251
+ return resolver.resolve4(hostname);
309
252
  }
310
- })();
311
-
312
- return Promise.race([resolvePromise, timeoutPromise]);
253
+ case "AAAA": {
254
+ return resolver.resolve6(hostname);
255
+ }
256
+ case "CNAME": {
257
+ return resolver.resolveCname(hostname);
258
+ }
259
+ case "MX": {
260
+ const records = await resolver.resolveMx(hostname);
261
+ return records.map((r) => `${r.priority} ${r.exchange}`);
262
+ }
263
+ case "TXT": {
264
+ const records = await resolver.resolveTxt(hostname);
265
+ return records.map((r) => r.join(""));
266
+ }
267
+ case "NS": {
268
+ return resolver.resolveNs(hostname);
269
+ }
270
+ }
313
271
  }
314
272
  }
@@ -0,0 +1,25 @@
1
+ import type { TransportClient } from "@checkstack/common";
2
+
3
+ /**
4
+ * DNS lookup request.
5
+ */
6
+ export interface DnsLookupRequest {
7
+ hostname: string;
8
+ recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS";
9
+ }
10
+
11
+ /**
12
+ * DNS lookup result.
13
+ */
14
+ export interface DnsLookupResult {
15
+ values: string[];
16
+ error?: string;
17
+ }
18
+
19
+ /**
20
+ * DNS transport client for record lookups.
21
+ */
22
+ export type DnsTransportClient = TransportClient<
23
+ DnsLookupRequest,
24
+ DnsLookupResult
25
+ >;