@checkstack/healthcheck-jenkins-backend 0.2.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.
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it, spyOn, afterEach } from "bun:test";
2
+ import { JenkinsHealthCheckStrategy } from "./strategy";
3
+
4
+ describe("JenkinsHealthCheckStrategy", () => {
5
+ const strategy = new JenkinsHealthCheckStrategy();
6
+
7
+ afterEach(() => {
8
+ spyOn(globalThis, "fetch").mockRestore();
9
+ });
10
+
11
+ describe("createClient", () => {
12
+ it("should return a connected client with exec function", async () => {
13
+ const connectedClient = await strategy.createClient({
14
+ baseUrl: "https://jenkins.example.com",
15
+ username: "admin",
16
+ apiToken: "api-token-123",
17
+ timeout: 5000,
18
+ });
19
+
20
+ expect(connectedClient.client).toBeDefined();
21
+ expect(connectedClient.client.exec).toBeDefined();
22
+ expect(connectedClient.close).toBeDefined();
23
+ });
24
+
25
+ it("should allow closing the client without error", async () => {
26
+ const connectedClient = await strategy.createClient({
27
+ baseUrl: "https://jenkins.example.com",
28
+ username: "admin",
29
+ apiToken: "api-token-123",
30
+ timeout: 5000,
31
+ });
32
+
33
+ expect(() => connectedClient.close()).not.toThrow();
34
+ });
35
+ });
36
+
37
+ describe("client.exec", () => {
38
+ it("should return successful response for valid request", async () => {
39
+ spyOn(globalThis, "fetch").mockResolvedValue(
40
+ new Response(JSON.stringify({ mode: "NORMAL", numExecutors: 2 }), {
41
+ status: 200,
42
+ statusText: "OK",
43
+ headers: { "X-Jenkins": "2.426.1" },
44
+ })
45
+ );
46
+
47
+ const connectedClient = await strategy.createClient({
48
+ baseUrl: "https://jenkins.example.com",
49
+ username: "admin",
50
+ apiToken: "api-token-123",
51
+ timeout: 5000,
52
+ });
53
+
54
+ const result = await connectedClient.client.exec({
55
+ path: "/api/json",
56
+ });
57
+
58
+ expect(result.statusCode).toBe(200);
59
+ expect(result.error).toBeUndefined();
60
+ expect(result.jenkinsVersion).toBe("2.426.1");
61
+ expect(result.data).toEqual({ mode: "NORMAL", numExecutors: 2 });
62
+
63
+ connectedClient.close();
64
+ });
65
+
66
+ it("should include query parameters in request", async () => {
67
+ let capturedUrl = "";
68
+ spyOn(globalThis, "fetch").mockImplementation((async (
69
+ url: RequestInfo | URL
70
+ ) => {
71
+ capturedUrl = url.toString();
72
+ return new Response(JSON.stringify({}), { status: 200 });
73
+ }) as unknown as typeof fetch);
74
+
75
+ const connectedClient = await strategy.createClient({
76
+ baseUrl: "https://jenkins.example.com",
77
+ username: "admin",
78
+ apiToken: "api-token-123",
79
+ timeout: 5000,
80
+ });
81
+
82
+ await connectedClient.client.exec({
83
+ path: "/api/json",
84
+ query: { tree: "jobs[name,color]" },
85
+ });
86
+
87
+ expect(capturedUrl).toContain("/api/json?");
88
+ expect(capturedUrl).toContain("tree=jobs%5Bname%2Ccolor%5D");
89
+
90
+ connectedClient.close();
91
+ });
92
+
93
+ it("should send Basic Auth header", async () => {
94
+ let capturedHeaders: Record<string, string> | undefined;
95
+ spyOn(globalThis, "fetch").mockImplementation((async (
96
+ _url: RequestInfo | URL,
97
+ options?: RequestInit
98
+ ) => {
99
+ capturedHeaders = options?.headers as Record<string, string>;
100
+ return new Response(JSON.stringify({}), { status: 200 });
101
+ }) as unknown as typeof fetch);
102
+
103
+ const connectedClient = await strategy.createClient({
104
+ baseUrl: "https://jenkins.example.com",
105
+ username: "admin",
106
+ apiToken: "api-token-123",
107
+ timeout: 5000,
108
+ });
109
+
110
+ await connectedClient.client.exec({ path: "/api/json" });
111
+
112
+ expect(capturedHeaders?.["Authorization"]).toContain("Basic ");
113
+ const decoded = Buffer.from(
114
+ capturedHeaders?.["Authorization"]?.replace("Basic ", "") || "",
115
+ "base64"
116
+ ).toString();
117
+ expect(decoded).toBe("admin:api-token-123");
118
+
119
+ connectedClient.close();
120
+ });
121
+
122
+ it("should return error for HTTP error response", async () => {
123
+ spyOn(globalThis, "fetch").mockResolvedValue(
124
+ new Response(null, { status: 401, statusText: "Unauthorized" })
125
+ );
126
+
127
+ const connectedClient = await strategy.createClient({
128
+ baseUrl: "https://jenkins.example.com",
129
+ username: "admin",
130
+ apiToken: "wrong-token",
131
+ timeout: 5000,
132
+ });
133
+
134
+ const result = await connectedClient.client.exec({ path: "/api/json" });
135
+
136
+ expect(result.statusCode).toBe(401);
137
+ expect(result.error).toBe("HTTP 401: Unauthorized");
138
+ expect(result.data).toBeUndefined();
139
+
140
+ connectedClient.close();
141
+ });
142
+
143
+ it("should return error for network failure", async () => {
144
+ spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
145
+
146
+ const connectedClient = await strategy.createClient({
147
+ baseUrl: "https://jenkins.example.com",
148
+ username: "admin",
149
+ apiToken: "api-token-123",
150
+ timeout: 5000,
151
+ });
152
+
153
+ const result = await connectedClient.client.exec({ path: "/api/json" });
154
+
155
+ expect(result.statusCode).toBe(0);
156
+ expect(result.error).toBe("Network error");
157
+ expect(result.data).toBeUndefined();
158
+
159
+ connectedClient.close();
160
+ });
161
+ });
162
+
163
+ describe("aggregateResult", () => {
164
+ it("should calculate success rate from runs", () => {
165
+ const runs: Parameters<typeof strategy.aggregateResult>[0] = [
166
+ {
167
+ status: "healthy" as const,
168
+ latencyMs: 100,
169
+ metadata: { connected: true, responseTimeMs: 150 },
170
+ },
171
+ {
172
+ status: "healthy" as const,
173
+ latencyMs: 200,
174
+ metadata: { connected: true, responseTimeMs: 200 },
175
+ },
176
+ {
177
+ status: "unhealthy" as const,
178
+ latencyMs: 50,
179
+ metadata: { connected: false, error: "Connection failed" },
180
+ },
181
+ ];
182
+
183
+ const aggregated = strategy.aggregateResult(runs);
184
+
185
+ expect(aggregated.successRate).toBe(67); // 2/3
186
+ expect(aggregated.avgResponseTimeMs).toBe(175); // (150+200)/2
187
+ expect(aggregated.errorCount).toBe(1);
188
+ });
189
+
190
+ it("should handle empty runs", () => {
191
+ const aggregated = strategy.aggregateResult([]);
192
+
193
+ expect(aggregated.successRate).toBe(0);
194
+ expect(aggregated.avgResponseTimeMs).toBe(0);
195
+ expect(aggregated.errorCount).toBe(0);
196
+ });
197
+ });
198
+ });
@@ -0,0 +1,228 @@
1
+ import {
2
+ HealthCheckStrategy,
3
+ HealthCheckRunForAggregation,
4
+ Versioned,
5
+ z,
6
+ configString,
7
+ configNumber,
8
+ type ConnectedClient,
9
+ } from "@checkstack/backend-api";
10
+ import {
11
+ healthResultNumber,
12
+ healthResultString,
13
+ } from "@checkstack/healthcheck-common";
14
+ import type {
15
+ JenkinsTransportClient,
16
+ JenkinsRequest,
17
+ JenkinsResponse,
18
+ } from "./transport-client";
19
+
20
+ // ============================================================================
21
+ // SCHEMAS
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Jenkins health check configuration schema.
26
+ * Provides connectivity settings for the Jenkins API.
27
+ */
28
+ export const jenkinsConfigSchema = z.object({
29
+ baseUrl: z
30
+ .string()
31
+ .url()
32
+ .describe("Jenkins server URL (e.g., https://jenkins.example.com)"),
33
+ username: configString({}).describe(
34
+ "Jenkins username for API authentication"
35
+ ),
36
+ apiToken: configString({ "x-secret": true }).describe(
37
+ "Jenkins API token (generate from User > Configure > API Token)"
38
+ ),
39
+ timeout: configNumber({})
40
+ .int()
41
+ .min(1000)
42
+ .default(30_000)
43
+ .describe("Request timeout in milliseconds"),
44
+ });
45
+
46
+ export type JenkinsConfig = z.infer<typeof jenkinsConfigSchema>;
47
+
48
+ /** Per-run result metadata */
49
+ const jenkinsResultSchema = z.object({
50
+ connected: z.boolean().meta({
51
+ "x-chart-type": "boolean",
52
+ "x-chart-label": "Connected",
53
+ }),
54
+ responseTimeMs: healthResultNumber({
55
+ "x-chart-type": "line",
56
+ "x-chart-label": "Response Time",
57
+ "x-chart-unit": "ms",
58
+ }).optional(),
59
+ error: healthResultString({
60
+ "x-chart-type": "status",
61
+ "x-chart-label": "Error",
62
+ }).optional(),
63
+ });
64
+
65
+ type JenkinsResult = z.infer<typeof jenkinsResultSchema>;
66
+
67
+ /** Aggregated metadata for buckets */
68
+ const jenkinsAggregatedSchema = z.object({
69
+ successRate: healthResultNumber({
70
+ "x-chart-type": "gauge",
71
+ "x-chart-label": "Success Rate",
72
+ "x-chart-unit": "%",
73
+ }),
74
+ avgResponseTimeMs: healthResultNumber({
75
+ "x-chart-type": "line",
76
+ "x-chart-label": "Avg Response Time",
77
+ "x-chart-unit": "ms",
78
+ }),
79
+ errorCount: healthResultNumber({
80
+ "x-chart-type": "counter",
81
+ "x-chart-label": "Errors",
82
+ }),
83
+ });
84
+
85
+ type JenkinsAggregatedResult = z.infer<typeof jenkinsAggregatedSchema>;
86
+
87
+ // ============================================================================
88
+ // STRATEGY
89
+ // ============================================================================
90
+
91
+ export class JenkinsHealthCheckStrategy
92
+ implements
93
+ HealthCheckStrategy<
94
+ JenkinsConfig,
95
+ JenkinsTransportClient,
96
+ JenkinsResult,
97
+ JenkinsAggregatedResult
98
+ >
99
+ {
100
+ id = "jenkins";
101
+ displayName = "Jenkins Health Check";
102
+ description = "Monitor Jenkins CI/CD server health and job status";
103
+
104
+ config: Versioned<JenkinsConfig> = new Versioned({
105
+ version: 1,
106
+ schema: jenkinsConfigSchema,
107
+ });
108
+
109
+ result: Versioned<JenkinsResult> = new Versioned({
110
+ version: 1,
111
+ schema: jenkinsResultSchema,
112
+ });
113
+
114
+ aggregatedResult: Versioned<JenkinsAggregatedResult> = new Versioned({
115
+ version: 1,
116
+ schema: jenkinsAggregatedSchema,
117
+ });
118
+
119
+ /**
120
+ * Create a Jenkins transport client for API requests.
121
+ */
122
+ async createClient(
123
+ config: JenkinsConfig
124
+ ): Promise<ConnectedClient<JenkinsTransportClient>> {
125
+ const validatedConfig = this.config.validate(config);
126
+ const baseUrl = validatedConfig.baseUrl.replace(/\/$/, ""); // Remove trailing slash
127
+
128
+ // Create Basic Auth header
129
+ const authHeader = `Basic ${Buffer.from(
130
+ `${validatedConfig.username}:${validatedConfig.apiToken}`
131
+ ).toString("base64")}`;
132
+
133
+ const client: JenkinsTransportClient = {
134
+ async exec(request: JenkinsRequest): Promise<JenkinsResponse> {
135
+ // Build URL with query params
136
+ let url = `${baseUrl}${request.path}`;
137
+ if (request.query && Object.keys(request.query).length > 0) {
138
+ const params = new URLSearchParams(request.query);
139
+ url += `?${params.toString()}`;
140
+ }
141
+
142
+ const controller = new AbortController();
143
+ const timeoutId = setTimeout(
144
+ () => controller.abort(),
145
+ validatedConfig.timeout
146
+ );
147
+
148
+ try {
149
+ const response = await fetch(url, {
150
+ method: "GET",
151
+ headers: {
152
+ Authorization: authHeader,
153
+ Accept: "application/json",
154
+ },
155
+ signal: controller.signal,
156
+ });
157
+
158
+ clearTimeout(timeoutId);
159
+
160
+ // Get Jenkins version from header
161
+ const jenkinsVersion = response.headers.get("X-Jenkins") || undefined;
162
+
163
+ if (!response.ok) {
164
+ return {
165
+ statusCode: response.status,
166
+ data: undefined,
167
+ error: `HTTP ${response.status}: ${response.statusText}`,
168
+ jenkinsVersion,
169
+ };
170
+ }
171
+
172
+ const data = await response.json();
173
+
174
+ return {
175
+ statusCode: response.status,
176
+ data,
177
+ jenkinsVersion,
178
+ };
179
+ } catch (error) {
180
+ clearTimeout(timeoutId);
181
+
182
+ const errorMessage =
183
+ error instanceof Error ? error.message : String(error);
184
+ return {
185
+ statusCode: 0,
186
+ data: undefined,
187
+ error: errorMessage,
188
+ };
189
+ }
190
+ },
191
+ };
192
+
193
+ return {
194
+ client,
195
+ close: () => {
196
+ // HTTP is stateless, nothing to close
197
+ },
198
+ };
199
+ }
200
+
201
+ aggregateResult(
202
+ runs: HealthCheckRunForAggregation<JenkinsResult>[]
203
+ ): JenkinsAggregatedResult {
204
+ const validRuns = runs.filter((r) => r.metadata);
205
+
206
+ if (validRuns.length === 0) {
207
+ return { successRate: 0, avgResponseTimeMs: 0, errorCount: 0 };
208
+ }
209
+
210
+ const responseTimes = validRuns
211
+ .map((r) => r.metadata?.responseTimeMs)
212
+ .filter((t): t is number => typeof t === "number");
213
+
214
+ const successCount = validRuns.filter((r) => r.metadata?.connected).length;
215
+ const errorCount = validRuns.filter((r) => r.metadata?.error).length;
216
+
217
+ return {
218
+ successRate: Math.round((successCount / validRuns.length) * 100),
219
+ avgResponseTimeMs:
220
+ responseTimes.length > 0
221
+ ? Math.round(
222
+ responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
223
+ )
224
+ : 0,
225
+ errorCount,
226
+ };
227
+ }
228
+ }
@@ -0,0 +1,38 @@
1
+ import type { TransportClient } from "@checkstack/common";
2
+
3
+ // ============================================================================
4
+ // JENKINS TRANSPORT TYPES
5
+ // ============================================================================
6
+
7
+ /**
8
+ * Jenkins API request configuration.
9
+ */
10
+ export interface JenkinsRequest {
11
+ /** API path relative to base URL (e.g., "/api/json", "/job/my-job/api/json") */
12
+ path: string;
13
+ /** Optional query parameters (e.g., { tree: "jobs[name,color]" }) */
14
+ query?: Record<string, string>;
15
+ }
16
+
17
+ /**
18
+ * Jenkins API response result.
19
+ */
20
+ export interface JenkinsResponse {
21
+ /** HTTP status code */
22
+ statusCode: number;
23
+ /** Parsed JSON response data */
24
+ data: unknown;
25
+ /** Error message if request failed */
26
+ error?: string;
27
+ /** Jenkins version from X-Jenkins header */
28
+ jenkinsVersion?: string;
29
+ }
30
+
31
+ /**
32
+ * Jenkins transport client type.
33
+ * Requests are API paths with optional query params, results include parsed JSON.
34
+ */
35
+ export type JenkinsTransportClient = TransportClient<
36
+ JenkinsRequest,
37
+ JenkinsResponse
38
+ >;
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json"
3
+ }