@blogic-cz/agent-tools 0.7.0 → 0.8.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,508 @@
1
+ import { aggregateByField, deduplicateLines, formatCountSummary, parseTextTable } from "#shared";
2
+
3
+ type PodIssueByOwner = {
4
+ owner: string;
5
+ status: string;
6
+ count: number;
7
+ restarts: number;
8
+ pods: string[];
9
+ };
10
+
11
+ type PodIssueIndividual = {
12
+ name: string;
13
+ status: string;
14
+ restarts: number;
15
+ };
16
+
17
+ type PodSummary = {
18
+ summary: string;
19
+ healthy: number;
20
+ issues: Array<PodIssueByOwner | PodIssueIndividual>;
21
+ };
22
+
23
+ type ParsedPod = {
24
+ name: string;
25
+ namespace: string;
26
+ status: string;
27
+ restarts: number;
28
+ ready: string;
29
+ owner?: string;
30
+ reason?: string;
31
+ };
32
+
33
+ const HEALTHY_POD_STATUSES = new Set(["Running", "Completed", "Succeeded"]);
34
+ const STANDARD_VOLUME_TYPES = ["emptydir", "configmap", "secret"];
35
+
36
+ function normalizeReplicaSetOwner(ownerName: string): string {
37
+ return ownerName.replace(/-[a-z0-9]{5,10}$/i, "");
38
+ }
39
+
40
+ function getStatusFromContainerState(containerState: unknown): string | undefined {
41
+ if (!containerState || typeof containerState !== "object") {
42
+ return undefined;
43
+ }
44
+
45
+ const stateRecord = containerState as Record<string, unknown>;
46
+
47
+ const waiting = stateRecord.waiting;
48
+ if (waiting && typeof waiting === "object") {
49
+ const reason = (waiting as Record<string, unknown>).reason;
50
+ if (typeof reason === "string" && reason.length > 0) {
51
+ return reason;
52
+ }
53
+ }
54
+
55
+ const terminated = stateRecord.terminated;
56
+ if (terminated && typeof terminated === "object") {
57
+ const reason = (terminated as Record<string, unknown>).reason;
58
+ if (typeof reason === "string" && reason.length > 0) {
59
+ return reason;
60
+ }
61
+ }
62
+
63
+ return undefined;
64
+ }
65
+
66
+ function getPodReason(
67
+ status: Record<string, unknown>,
68
+ firstContainer: Record<string, unknown> | undefined,
69
+ ): string | undefined {
70
+ const containerState = firstContainer?.state;
71
+
72
+ if (containerState && typeof containerState === "object") {
73
+ const waiting = (containerState as Record<string, unknown>).waiting;
74
+ if (waiting && typeof waiting === "object") {
75
+ const waitingReason = (waiting as Record<string, unknown>).reason;
76
+ if (typeof waitingReason === "string" && waitingReason.length > 0) {
77
+ return waitingReason;
78
+ }
79
+ }
80
+ }
81
+
82
+ const statusReason = status.reason;
83
+ if (typeof statusReason === "string" && statusReason.length > 0) {
84
+ return statusReason;
85
+ }
86
+
87
+ return undefined;
88
+ }
89
+
90
+ function parseCpuToMillicores(cpu: string): number {
91
+ const trimmed = cpu.trim();
92
+ if (trimmed.length === 0) return 0;
93
+ if (trimmed.endsWith("m")) {
94
+ const value = Number(trimmed.slice(0, -1));
95
+ return Number.isFinite(value) ? Math.round(value) : 0;
96
+ }
97
+
98
+ const cores = Number(trimmed);
99
+ if (!Number.isFinite(cores)) return 0;
100
+ return Math.round(cores * 1000);
101
+ }
102
+
103
+ function parseMemoryToMi(memory: string): number {
104
+ const trimmed = memory.trim();
105
+ if (trimmed.length === 0) return 0;
106
+
107
+ const match = trimmed.match(/^([0-9]+(?:\.[0-9]+)?)([A-Za-z]+)?$/);
108
+ if (!match) return 0;
109
+
110
+ const value = Number(match[1]);
111
+ const unit = (match[2] ?? "Mi").toLowerCase();
112
+ if (!Number.isFinite(value)) return 0;
113
+
114
+ switch (unit) {
115
+ case "ki":
116
+ return Math.round(value / 1024);
117
+ case "mi":
118
+ return Math.round(value);
119
+ case "gi":
120
+ return Math.round(value * 1024);
121
+ case "ti":
122
+ return Math.round(value * 1024 * 1024);
123
+ case "k":
124
+ return Math.round(value / 1000 / 1024);
125
+ case "m":
126
+ return Math.round(value / 1000 / 1000 / 1024);
127
+ case "g":
128
+ return Math.round(value / 1024);
129
+ default:
130
+ return Math.round(value);
131
+ }
132
+ }
133
+
134
+ function parseKeyValueBlock(lines: string[], initialValue: string): Array<[string, string]> {
135
+ const collected = [initialValue, ...lines]
136
+ .map((line) => line.trim())
137
+ .filter((line) => line.length > 0 && line !== "<none>");
138
+
139
+ const entries: Array<[string, string]> = [];
140
+
141
+ for (const line of collected) {
142
+ const separator = line.includes(":") ? ":" : "=";
143
+ const parts = line.split(separator);
144
+ if (parts.length < 2) continue;
145
+
146
+ const key = parts[0]?.trim() ?? "";
147
+ const value = parts.slice(1).join(separator).trim();
148
+
149
+ if (key.length > 0) {
150
+ entries.push([key, value]);
151
+ }
152
+ }
153
+
154
+ return entries;
155
+ }
156
+
157
+ function parseDescribeSections(
158
+ textOutput: string,
159
+ ): Map<string, { initial: string; lines: string[] }> {
160
+ const sections = new Map<string, { initial: string; lines: string[] }>();
161
+ const lines = textOutput.split(/\r?\n/);
162
+ const headerPattern = /^([A-Za-z][A-Za-z0-9 _\-()/.]*):\s*(.*)$/;
163
+
164
+ let currentSection: string | undefined;
165
+
166
+ for (const line of lines) {
167
+ const headerMatch = line.match(headerPattern);
168
+ if (headerMatch && !line.startsWith(" ") && !line.startsWith("\t")) {
169
+ const sectionName = headerMatch[1].trim();
170
+ const initial = headerMatch[2] ?? "";
171
+ sections.set(sectionName, { initial, lines: [] });
172
+ currentSection = sectionName;
173
+ continue;
174
+ }
175
+
176
+ if (currentSection) {
177
+ const section = sections.get(currentSection);
178
+ if (section) {
179
+ section.lines.push(line);
180
+ }
181
+ }
182
+ }
183
+
184
+ return sections;
185
+ }
186
+
187
+ function stripLowValueColumns(rows: Record<string, string>[]): Record<string, string>[] {
188
+ if (rows.length === 0) {
189
+ return rows;
190
+ }
191
+
192
+ const keys = Object.keys(rows[0] ?? {});
193
+ const keysToRemove = new Set<string>();
194
+
195
+ for (const key of keys) {
196
+ const allNone = rows.every((row) => {
197
+ const value = (row[key] ?? "").trim().toLowerCase();
198
+ return value === "<none>";
199
+ });
200
+
201
+ if (allNone) {
202
+ keysToRemove.add(key);
203
+ }
204
+ }
205
+
206
+ if (keysToRemove.size === 0) {
207
+ return rows;
208
+ }
209
+
210
+ return rows.map((row) => {
211
+ const filtered: Record<string, string> = {};
212
+ for (const [key, value] of Object.entries(row)) {
213
+ if (!keysToRemove.has(key)) {
214
+ filtered[key] = value;
215
+ }
216
+ }
217
+ return filtered;
218
+ });
219
+ }
220
+
221
+ export function transformPods(jsonOutput: string): PodSummary | string {
222
+ try {
223
+ const parsed = JSON.parse(jsonOutput) as { items?: unknown[] };
224
+ if (!Array.isArray(parsed.items)) {
225
+ return jsonOutput;
226
+ }
227
+
228
+ const pods: ParsedPod[] = parsed.items.map((item) => {
229
+ const pod = (item ?? {}) as Record<string, unknown>;
230
+ const metadata = (pod.metadata ?? {}) as Record<string, unknown>;
231
+ const status = (pod.status ?? {}) as Record<string, unknown>;
232
+ const containerStatuses = Array.isArray(status.containerStatuses)
233
+ ? (status.containerStatuses as unknown[])
234
+ : [];
235
+
236
+ const firstContainer =
237
+ containerStatuses.length > 0 && typeof containerStatuses[0] === "object"
238
+ ? (containerStatuses[0] as Record<string, unknown>)
239
+ : undefined;
240
+ const stateReason = getStatusFromContainerState(firstContainer?.state);
241
+
242
+ const restarts = containerStatuses.reduce<number>((sum, entry) => {
243
+ if (!entry || typeof entry !== "object") {
244
+ return sum;
245
+ }
246
+ const restartCount = Number((entry as Record<string, unknown>).restartCount);
247
+ return sum + (Number.isFinite(restartCount) ? restartCount : 0);
248
+ }, 0);
249
+
250
+ const readyCount = containerStatuses.reduce<number>((sum, entry) => {
251
+ if (!entry || typeof entry !== "object") {
252
+ return sum;
253
+ }
254
+ return sum + ((entry as Record<string, unknown>).ready === true ? 1 : 0);
255
+ }, 0);
256
+
257
+ const ownerReferences = Array.isArray(metadata.ownerReferences)
258
+ ? (metadata.ownerReferences as unknown[])
259
+ : [];
260
+ const ownerRef =
261
+ ownerReferences.length > 0 && typeof ownerReferences[0] === "object"
262
+ ? (ownerReferences[0] as Record<string, unknown>)
263
+ : undefined;
264
+ const ownerNameRaw = typeof ownerRef?.name === "string" ? ownerRef.name : undefined;
265
+
266
+ const podReason = getPodReason(status, firstContainer);
267
+
268
+ return {
269
+ name: typeof metadata.name === "string" ? metadata.name : "unknown",
270
+ namespace: typeof metadata.namespace === "string" ? metadata.namespace : "default",
271
+ status:
272
+ stateReason ??
273
+ (typeof status.phase === "string" && status.phase.length > 0 ? status.phase : "Unknown"),
274
+ restarts,
275
+ ready: `${readyCount}/${containerStatuses.length}`,
276
+ owner: ownerNameRaw ? normalizeReplicaSetOwner(ownerNameRaw) : undefined,
277
+ reason: podReason,
278
+ };
279
+ });
280
+
281
+ const countsByStatus = aggregateByField(pods, "status");
282
+ const summary = formatCountSummary(countsByStatus, pods.length, "pods");
283
+ const healthyPods = pods.filter((pod) => HEALTHY_POD_STATUSES.has(pod.status));
284
+ const issuePods = pods.filter((pod) => !HEALTHY_POD_STATUSES.has(pod.status));
285
+
286
+ const groupedIssues = new Map<string, PodIssueByOwner>();
287
+ const individualIssues: PodIssueIndividual[] = [];
288
+
289
+ for (const pod of issuePods) {
290
+ if (pod.owner) {
291
+ const key = `${pod.owner}|${pod.status}`;
292
+ const existing = groupedIssues.get(key);
293
+
294
+ if (existing) {
295
+ existing.count += 1;
296
+ existing.restarts += pod.restarts;
297
+ existing.pods.push(pod.name);
298
+ } else {
299
+ groupedIssues.set(key, {
300
+ owner: pod.owner,
301
+ status: pod.status,
302
+ count: 1,
303
+ restarts: pod.restarts,
304
+ pods: [pod.name],
305
+ });
306
+ }
307
+ } else {
308
+ individualIssues.push({
309
+ name: pod.name,
310
+ status: pod.status,
311
+ restarts: pod.restarts,
312
+ });
313
+ }
314
+ }
315
+
316
+ return {
317
+ summary,
318
+ healthy: healthyPods.length,
319
+ issues: [...groupedIssues.values(), ...individualIssues],
320
+ };
321
+ } catch {
322
+ return jsonOutput;
323
+ }
324
+ }
325
+
326
+ export function transformTop(textOutput: string): Record<string, unknown> {
327
+ const table = parseTextTable(textOutput);
328
+ const rows = table.rows;
329
+
330
+ const pods = rows.map((row) => {
331
+ const name = row.NAME ?? row.POD ?? row.PODS ?? "";
332
+ const cpu = row["CPU(cores)"] ?? row.CPU ?? row.CPU_CORES ?? "0m";
333
+ const memory = row["MEMORY(bytes)"] ?? row.MEMORY ?? row.MEMORY_BYTES ?? "0Mi";
334
+
335
+ return { name, cpu, memory };
336
+ });
337
+
338
+ const totalCpuMillicores = pods.reduce((sum, pod) => sum + parseCpuToMillicores(pod.cpu), 0);
339
+ const totalMemoryMi = pods.reduce((sum, pod) => sum + parseMemoryToMi(pod.memory), 0);
340
+
341
+ return {
342
+ summary: `${pods.length} pods: total CPU ${totalCpuMillicores}m, Memory ${totalMemoryMi}Mi`,
343
+ pods,
344
+ };
345
+ }
346
+
347
+ export function transformDescribe(textOutput: string): string {
348
+ const sections = parseDescribeSections(textOutput);
349
+ if (sections.size === 0) {
350
+ return textOutput;
351
+ }
352
+
353
+ const outputLines: string[] = [];
354
+
355
+ const simpleFields = ["Name", "Namespace", "Status", "Type"];
356
+ for (const field of simpleFields) {
357
+ const section = sections.get(field);
358
+ if (!section) continue;
359
+ const value = section.initial.trim();
360
+ if (value.length > 0) {
361
+ outputLines.push(`${field}: ${value}`);
362
+ }
363
+ }
364
+
365
+ const labelsSection = sections.get("Labels");
366
+ if (labelsSection) {
367
+ const labels = parseKeyValueBlock(labelsSection.lines, labelsSection.initial);
368
+ if (labels.length > 0) {
369
+ outputLines.push("Labels:");
370
+ for (const [key, value] of labels) {
371
+ outputLines.push(` ${key}=${value}`);
372
+ }
373
+ }
374
+ }
375
+
376
+ const annotationsSection = sections.get("Annotations");
377
+ if (annotationsSection) {
378
+ const annotations = parseKeyValueBlock(annotationsSection.lines, annotationsSection.initial);
379
+ if (annotations.length > 0) {
380
+ outputLines.push("Annotations:");
381
+ for (const [key, value] of annotations) {
382
+ const compact = value.length > 100 ? `${value.slice(0, 100)}…` : value;
383
+ outputLines.push(` ${key}: ${compact}`);
384
+ }
385
+ }
386
+ }
387
+
388
+ const volumesSection = sections.get("Volumes");
389
+ if (volumesSection) {
390
+ const volumeTypes = volumesSection.lines
391
+ .map((line) => line.trim())
392
+ .filter((line) => line.startsWith("Type:"))
393
+ .map((line) => line.slice("Type:".length).trim().toLowerCase());
394
+
395
+ if (volumeTypes.length > 0) {
396
+ const allStandard = volumeTypes.every((type) =>
397
+ STANDARD_VOLUME_TYPES.some((standard) => type.includes(standard)),
398
+ );
399
+
400
+ if (allStandard) {
401
+ outputLines.push(
402
+ `Volumes: ${volumeTypes.length} standard volumes (emptyDir/configMap/secret)`,
403
+ );
404
+ }
405
+ }
406
+ }
407
+
408
+ const conditionsSection = sections.get("Conditions");
409
+ if (conditionsSection) {
410
+ const conditionLines = conditionsSection.lines
411
+ .map((line) => line.trim())
412
+ .filter((line) => line.length > 0)
413
+ .filter((line) => !/^type\s+status/i.test(line));
414
+
415
+ const parsedConditions = conditionLines
416
+ .map((line) => line.split(/\s{2,}/).filter((part) => part.length > 0))
417
+ .filter((parts) => parts.length >= 2)
418
+ .map((parts) => ({
419
+ type: parts[0] ?? "",
420
+ status: parts[1] ?? "",
421
+ reason: parts[2] ?? "",
422
+ message: parts.slice(3).join(" "),
423
+ }));
424
+
425
+ if (parsedConditions.length > 0) {
426
+ const problematic = parsedConditions.filter((condition) => condition.status !== "True");
427
+
428
+ if (problematic.length === 0) {
429
+ outputLines.push(`Conditions: All ${parsedConditions.length} conditions True`);
430
+ } else {
431
+ outputLines.push("Conditions:");
432
+ for (const condition of problematic) {
433
+ const reasonPart = condition.reason.length > 0 ? ` reason=${condition.reason}` : "";
434
+ const messagePart = condition.message.length > 0 ? ` message=${condition.message}` : "";
435
+ outputLines.push(` ${condition.type}: ${condition.status}${reasonPart}${messagePart}`);
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ const eventsSection = sections.get("Events");
442
+ if (eventsSection) {
443
+ const eventLines = eventsSection.lines
444
+ .map((line) => line.trimEnd())
445
+ .filter((line) => line.trim().length > 0)
446
+ .filter((line) => !line.trimStart().toLowerCase().startsWith("type"));
447
+
448
+ if (eventLines.length > 0) {
449
+ outputLines.push("Events:");
450
+ for (const line of eventLines.slice(-10)) {
451
+ outputLines.push(` ${line.trim()}`);
452
+ }
453
+ }
454
+ }
455
+
456
+ return outputLines.join("\n").trim();
457
+ }
458
+
459
+ export function transformLogs(textOutput: string): string {
460
+ return deduplicateLines(textOutput, {
461
+ normalizeTimestamps: true,
462
+ normalizeUUIDs: true,
463
+ });
464
+ }
465
+
466
+ export function transformGenericKubectl(
467
+ textOutput: string,
468
+ _command: string,
469
+ ): string | Record<string, unknown> {
470
+ const trimmed = textOutput.trim();
471
+
472
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
473
+ try {
474
+ const parsed = JSON.parse(trimmed);
475
+ if (Array.isArray(parsed)) {
476
+ return { items: parsed };
477
+ }
478
+ if (parsed && typeof parsed === "object") {
479
+ return parsed as Record<string, unknown>;
480
+ }
481
+ } catch (error) {
482
+ const ignored = error;
483
+ void ignored;
484
+ }
485
+ }
486
+
487
+ const lines = textOutput.split(/\r?\n/).filter((line) => line.trim().length > 0);
488
+ const parsedTable = parseTextTable(textOutput);
489
+ const looksLikeTable =
490
+ lines.length >= 2 &&
491
+ parsedTable.headers.length >= 2 &&
492
+ parsedTable.rows.length >= 1 &&
493
+ parsedTable.headers.every((header) => /^[A-Z0-9_()\-/]+$/.test(header));
494
+
495
+ if (looksLikeTable) {
496
+ const rows = stripLowValueColumns(parsedTable.rows);
497
+ const headers = parsedTable.headers.filter((headerName) =>
498
+ rows.length === 0 ? true : rows.some((row) => headerName in row),
499
+ );
500
+ return { headers, rows };
501
+ }
502
+
503
+ if (lines.length > 50) {
504
+ return deduplicateLines(textOutput);
505
+ }
506
+
507
+ return textOutput;
508
+ }
@@ -2,7 +2,7 @@ export type Environment = "test" | "prod";
2
2
 
3
3
  export type CommandResult = {
4
4
  success: boolean;
5
- output?: string;
5
+ output?: string | Record<string, unknown>;
6
6
  error?: string;
7
7
  command?: string;
8
8
  executionTimeMs: number;
@@ -8,6 +8,7 @@ import { K8sService, K8sServiceLayer } from "#k8s/service";
8
8
  import { ConfigService, ConfigServiceLayer, getToolConfig } from "#config/loader";
9
9
  import type { LogsConfig } from "#config/types";
10
10
  import { LogsNotFoundError, LogsReadError, type LogsError } from "./errors";
11
+ import { transformLogOutput } from "./transformers";
11
12
 
12
13
  export const parseLogFiles = (output: string): LogFile[] => {
13
14
  const lines = output.trim().split("\n").slice(1);
@@ -44,6 +45,8 @@ export const formatPrettyOutput = (output: string): string => {
44
45
  */
45
46
  export const sanitizeShellArg = (input: string): string => `'${input.replace(/'/g, "'\\''")}'`;
46
47
 
48
+ const readCommandOutput = (output: unknown): string => (typeof output === "string" ? output : "");
49
+
47
50
  export class LogsService extends ServiceMap.Service<
48
51
  LogsService,
49
52
  {
@@ -138,7 +141,7 @@ export class LogsService extends ServiceMap.Service<
138
141
  ),
139
142
  );
140
143
 
141
- const pod = (podResult.output ?? "").replace(/'/g, "");
144
+ const pod = readCommandOutput(podResult.output).replace(/'/g, "");
142
145
 
143
146
  const listResult = yield* k8s.runKubectl(`exec ${pod} -- ls -la ${remotePath}`, false).pipe(
144
147
  Effect.mapError(
@@ -150,7 +153,7 @@ export class LogsService extends ServiceMap.Service<
150
153
  ),
151
154
  );
152
155
 
153
- const files = parseLogFiles(listResult.output ?? "");
156
+ const files = parseLogFiles(readCommandOutput(listResult.output));
154
157
  if (files.length === 0) {
155
158
  return yield* new LogsNotFoundError({
156
159
  message: "No log files found",
@@ -210,11 +213,11 @@ export class LogsService extends ServiceMap.Service<
210
213
  }
211
214
 
212
215
  const output = result.stdout.trim();
213
- if (options.pretty && output) {
214
- return formatPrettyOutput(output);
216
+ if (!output) {
217
+ return "(no matching lines)";
215
218
  }
216
219
 
217
- return output || "(no matching lines)";
220
+ return transformLogOutput(output);
218
221
  });
219
222
 
220
223
  const readRemoteLogs = Effect.fn("LogsService.readRemoteLogs")(function* (
@@ -236,7 +239,7 @@ export class LogsService extends ServiceMap.Service<
236
239
  ),
237
240
  );
238
241
 
239
- const pod = (podResult.output ?? "").replace(/'/g, "");
242
+ const pod = readCommandOutput(podResult.output).replace(/'/g, "");
240
243
  const logFile = options.file ?? "app.log";
241
244
  const logPath = `${remotePath}/${logFile}`;
242
245
  let command = `tail -${options.tail} ${sanitizeShellArg(logPath)}`;
@@ -263,12 +266,12 @@ export class LogsService extends ServiceMap.Service<
263
266
  );
264
267
  },
265
268
  onSuccess: (result) => {
266
- const trimmed = (result.output ?? "").trim();
267
- if (options.pretty && trimmed) {
268
- return Effect.succeed(formatPrettyOutput(trimmed));
269
+ const trimmed = readCommandOutput(result.output).trim();
270
+ if (!trimmed) {
271
+ return Effect.succeed("(no matching lines)");
269
272
  }
270
273
 
271
- return Effect.succeed(trimmed || "(no matching lines)");
274
+ return Effect.succeed(transformLogOutput(trimmed));
272
275
  },
273
276
  });
274
277
  });