@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.
- package/CHANGELOG.md +23 -0
- package/package.json +23 -0
- package/src/collectors/build-history.test.ts +106 -0
- package/src/collectors/build-history.ts +280 -0
- package/src/collectors/index.ts +5 -0
- package/src/collectors/job-status.test.ts +146 -0
- package/src/collectors/job-status.ts +241 -0
- package/src/collectors/node-health.test.ts +149 -0
- package/src/collectors/node-health.ts +305 -0
- package/src/collectors/queue-info.test.ts +113 -0
- package/src/collectors/queue-info.ts +215 -0
- package/src/collectors/server-info.test.ts +90 -0
- package/src/collectors/server-info.ts +169 -0
- package/src/index.ts +43 -0
- package/src/plugin-metadata.ts +9 -0
- package/src/strategy.test.ts +198 -0
- package/src/strategy.ts +228 -0
- package/src/transport-client.ts +38 -0
- package/tsconfig.json +3 -0
|
@@ -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
|
+
});
|
package/src/strategy.ts
ADDED
|
@@ -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