-
Notifications
You must be signed in to change notification settings - Fork 223
Expand file tree
/
Copy pathlog-utils.ts
More file actions
342 lines (319 loc) · 11.2 KB
/
log-utils.ts
File metadata and controls
342 lines (319 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
import { expect } from "@playwright/test";
import { execFile, exec } from "child_process";
import { type JsonObject } from "@backstage/types";
import {
Log,
type LogRequest,
type EventStatus,
type EventSeverityLevel,
} from "./logs";
import { getBackstageDeploySelector } from "../../utils/helper";
export class LogUtils {
/**
* Executes a command and returns the output as a promise.
*
* @param command The command to execute
* @param args An array of arguments for the command
* @returns A promise that resolves with the command output
*/
static executeCommand(command: string, args: string[] = []): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { encoding: "utf8" }, (error, stdout, stderr) => {
if (error) {
reject(`Error: ${error.message}`);
return;
}
if (stderr) {
console.warn("stderr warning:", stderr);
}
resolve(stdout);
});
});
}
/**
* Executes a command with retry logic.
*
* @param command The command to execute
* @param args An array of arguments for the command
* @param maxRetries Maximum number of retry attempts (default: 3)
* @returns A promise that resolves with the command output
*/
static async executeCommandWithRetries(
command: string,
args: string[] = [],
maxRetries: number = 3,
): Promise<string> {
let attempt = 0;
while (attempt <= maxRetries) {
try {
console.log(
`Attempt ${attempt + 1}/${maxRetries}: Executing command: ${command} ${args.join(" ")}`,
);
const output = await LogUtils.executeCommand(command, args);
console.log(`Command executed successfully on attempt ${attempt + 1}`);
return output;
} catch (error) {
console.error(
`Error executing command on attempt ${attempt + 1}:`,
error,
);
attempt++;
}
}
throw new Error(
`Failed to execute command "${command} ${args.join(" ")}" after ${maxRetries} attempts.`,
);
}
/**
* Executes a shell command and returns the output as a promise.
*
* @param command The shell command to execute
* @returns A promise that resolves with the command output
*/
static executeShellCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, { encoding: "utf8" }, (error, stdout, stderr) => {
if (error) {
reject(`Error: ${error.message}`);
return;
}
if (stderr) {
console.warn("stderr warning:", stderr);
}
resolve(stdout);
});
});
}
/**
* Validates if the actual log matches the expected log values.
* It compares both primitive and nested object properties.
*
* @param actual The actual log returned by the system
* @param expected The expected log values to validate against
*/
public static validateLog(actual: Log, expected: Partial<Log>) {
Object.keys(expected).forEach((key) => {
const expectedValue = expected[key as keyof Log];
if (expectedValue !== undefined) {
const actualValue = actual[key as keyof Log];
LogUtils.compareValues(actualValue, expectedValue);
}
});
}
/**
* Compare the actual and expected values. Uses 'toBe' for numbers and 'toContain' for strings/arrays.
* Handles nested object comparison.
*
* @param actual The actual value to compare
* @param expected The expected value
*/
private static compareValues(actual: unknown, expected: unknown) {
if (typeof expected === "object" && expected !== null) {
Object.keys(expected).forEach((subKey) => {
const expectedSubValue = expected[subKey];
const actualSubValue = actual?.[subKey];
LogUtils.compareValues(actualSubValue, expectedSubValue);
});
} else if (typeof expected === "number") {
expect(actual).toBe(expected);
} else if (typeof expected === "string") {
if (actual === undefined || actual === null) {
throw new Error(`Expected value "${expected}" but got ${actual}`);
}
expect(String(actual)).toContain(expected);
} else {
expect(actual).toBe(expected);
}
}
/**
* Lists all pods in the specified namespace and returns their details.
*
* @param namespace The namespace to list pods from
* @returns A promise that resolves with the pod details
*/
static async listPods(namespace: string): Promise<string> {
const args = ["get", "pods", "-n", namespace, "-o", "wide"];
try {
console.log("Fetching pod list with command:", "oc", args.join(" "));
return await LogUtils.executeCommand("oc", args);
} catch (error) {
console.error("Error listing pods:", error);
throw new Error(
`Failed to list pods in namespace "${namespace}": ${error}`,
);
}
}
/**
* Fetches detailed information about a specific pod.
*
* @param podName The name of the pod to fetch details for
* @param namespace The namespace where the pod is located
* @returns A promise that resolves with the pod details in JSON format
*/
static async getPodDetails(
podName: string,
namespace: string,
): Promise<string> {
const args = ["get", "pod", podName, "-n", namespace, "-o", "json"];
try {
const output = await LogUtils.executeCommand("oc", args);
console.log(`Details for pod ${podName}:`, output);
return output;
} catch (error) {
console.error(`Error fetching details for pod ${podName}:`, error);
throw new Error(`Failed to fetch pod details: ${error}`);
}
}
/**
* Fetches logs using grep for filtering directly in the shell.
*
* @param filterWords The required words the logs must contain to filter the logs
* @param namespace The namespace to use to retrieve logs from pod
* @param maxRetries Maximum number of retry attempts
* @param retryDelay Delay (in milliseconds) between retries
* @returns The log line matching the filter, or throws an error if not found
*/
static async getPodLogsWithGrep(
filterWords: string[] = [],
namespace: string = process.env.NAME_SPACE || "showcase-ci-nightly",
maxRetries: number = 4,
retryDelay: number = 2000,
): Promise<string> {
const deploySelector = getBackstageDeploySelector();
const tailNumber = 500;
// Resolve the deployment by its metadata labels, then fetch logs from it.
// This works for both Helm and Operator since both set app.kubernetes.io/name
// on the Deployment (with different values), even though pod labels differ.
const deployTarget = `$(oc get deploy -n ${namespace} -l ${deploySelector} -o name)`;
let grepCommand = `oc logs ${deployTarget} --tail=${tailNumber} -c backstage-backend -n ${namespace}`;
for (const word of filterWords) {
grepCommand += ` | grep '${word}'`;
}
let attempt = 0;
while (attempt <= maxRetries) {
try {
console.log(
`Attempt ${attempt + 1}/${maxRetries + 1}: Fetching logs with grep...`,
);
const output = await LogUtils.executeShellCommand(grepCommand);
const logLines = output
.split("\n")
.filter((line) => line.trim() !== "");
if (logLines.length > 0) {
console.log("Matching log line found:", logLines[0]);
return logLines[0]; // Return the first matching log
}
console.warn(
`No matching logs found for filter "${filterWords}" on attempt ${attempt + 1}. Retrying...`,
);
} catch (error) {
console.error(
`Error fetching logs on attempt ${attempt + 1}:`,
error.message,
);
}
attempt++;
if (attempt <= maxRetries) {
console.log(`Waiting ${retryDelay / 1000} seconds before retrying...`);
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
}
throw new Error(
`Failed to fetch logs for filter "${filterWords}" after ${maxRetries + 1} attempts.`,
);
}
/**
* Logs in to OpenShift using a token and server URL.
*
* @returns A promise that resolves when the login is successful
*/
static async loginToOpenShift(): Promise<void> {
const token = process.env.K8S_CLUSTER_TOKEN || "";
const server = process.env.K8S_CLUSTER_URL || "";
if (!token || !server) {
throw new Error(
"Environment variables K8S_CLUSTER_TOKEN and K8S_CLUSTER_URL must be set.",
);
}
const command = "oc";
const args = [
"login",
`--token=${token}`,
`--server=${server}`,
`--insecure-skip-tls-verify=true`,
];
try {
await LogUtils.executeCommand(command, args);
console.log("Login successful.");
} catch (error) {
console.error("Error during login: ", error);
throw new Error(`Failed to login to OpenShift`);
}
}
/**
* Validates if the actual log matches the expected log values for a specific event.
* This is a reusable method for different log validations across various tests.
*
* @param eventId The id of the event to filter in the logs
* @param actorId The id of actor initiating the request
* @param request The url endpoint and HTTP method (GET, POST, etc.) hit
* @param meta The metadata about the event
* @param error The error that occurred
* @param status The status of event
* @param plugin The plugin name that triggered the log event
* @param severityLevel The level of severity of the event
* @param filterWords The required words the logs must contain to filter the logs besides eventId and request url if specified
* @param namespace The namespace to use to retrieve logs from pod
*/
public static async validateLogEvent(
eventId: string,
actorId: string,
request?: LogRequest,
meta?: JsonObject,
error?: string,
status: EventStatus = "succeeded",
plugin: string = "catalog",
severityLevel: EventSeverityLevel = "medium",
filterWords: string[] = [],
namespace: string = process.env.NAME_SPACE || "showcase-ci-nightly",
) {
const filterWordsAll = [eventId, status, ...filterWords];
if (request?.method) filterWordsAll.push(request.method);
if (request?.url) filterWordsAll.push(request.url);
try {
const actualLog = await LogUtils.getPodLogsWithGrep(
filterWordsAll,
namespace,
);
let parsedLog: Log;
try {
parsedLog = JSON.parse(actualLog);
} catch (parseError) {
console.error("Failed to parse log JSON. Log content:", actualLog);
throw new Error(`Invalid JSON received for log: ${parseError}`);
}
const expectedLog: Partial<Log> = {
actor: {
actorId,
},
plugin,
request,
meta,
stack: error,
status,
severityLevel,
};
console.log("Validating log with expected values:", expectedLog);
LogUtils.validateLog(parsedLog, expectedLog);
} catch (error) {
console.error("Error validating log event:", error);
console.error("Event id:", eventId);
console.error("Actor id:", actorId);
console.error("Meta:", meta);
console.error("Expected method:", request?.method);
console.error("Expected URL:", request?.url);
console.error("Plugin:", plugin);
throw error;
}
}
}