@oneuptime/common 12.0.16 → 12.0.18

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.
Files changed (36) hide show
  1. package/Models/DatabaseModels/DetectionRule.ts +81 -0
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.ts +39 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/BillingService.ts +8 -0
  5. package/Server/Utils/SecurityEvent/DetectionRuleEvaluator.ts +303 -46
  6. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +17 -0
  7. package/Tests/App/Dashboard/SecurityEventsDetectionRulesPage.test.tsx +250 -0
  8. package/Tests/App/Dashboard/SecurityEventsMonitorStepForm.test.tsx +106 -0
  9. package/Tests/App/Dashboard/SecurityEventsMonitorsPage.test.tsx +185 -0
  10. package/Tests/App/Dashboard/SecurityEventsSetupGuide.test.tsx +200 -0
  11. package/Tests/Models/DetectionRuleCreateContract.test.ts +35 -0
  12. package/Tests/Server/Services/BillingService.test.ts +20 -0
  13. package/Tests/Server/Utils/SecurityEvent/DetectionRuleEvaluator.test.ts +438 -0
  14. package/Tests/Types/Kubernetes/KubernetesObjectParsers.test.ts +794 -0
  15. package/Tests/Types/Kubernetes/KubernetesRightSizing.test.ts +53 -0
  16. package/Tests/Types/Measurement/MeasurementAggregationType.test.ts +100 -0
  17. package/Tests/Types/Monitor/MonitorStepConfigHelpers.test.ts +114 -0
  18. package/Tests/Types/Workspace/WorkspaceType.test.ts +53 -0
  19. package/Tests/Utils/Dashboard/Components/DashboardComponentDefaults.test.ts +257 -0
  20. package/Types/Billing/PayAsYouGoPricing.ts +1 -1
  21. package/Types/SecurityEvent/DetectionFindingConstants.ts +24 -0
  22. package/build/dist/Models/DatabaseModels/DetectionRule.js +81 -0
  23. package/build/dist/Models/DatabaseModels/DetectionRule.js.map +1 -1
  24. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.js +24 -0
  25. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.js.map +1 -0
  26. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  27. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  28. package/build/dist/Server/Services/BillingService.js +6 -0
  29. package/build/dist/Server/Services/BillingService.js.map +1 -1
  30. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js +220 -24
  31. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js.map +1 -1
  32. package/build/dist/Types/Billing/PayAsYouGoPricing.js +1 -1
  33. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -1
  34. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js +18 -0
  35. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js.map +1 -0
  36. package/package.json +1 -1
@@ -0,0 +1,794 @@
1
+ import { JSONObject } from "../../../Types/JSON";
2
+ import {
3
+ parseCronJobObject,
4
+ parseDaemonSetObject,
5
+ parseDeploymentObject,
6
+ parseHPAObject,
7
+ parseJobObject,
8
+ parseNamespaceObject,
9
+ parseNodeObject,
10
+ parsePVCObject,
11
+ parsePVObject,
12
+ parsePodObject,
13
+ parseStatefulSetObject,
14
+ } from "../../../Types/Kubernetes/KubernetesObjectParser";
15
+ import { describe, expect, test } from "@jest/globals";
16
+
17
+ /*
18
+ * The parse<Kind>Object functions turn the OTLP kvlistValue an agent ships a
19
+ * Kubernetes object in into a typed inventory record. The low-level kv helpers
20
+ * they build on are covered by KubernetesObjectParser.test.ts; this suite
21
+ * covers the object-level parsers themselves, which had none.
22
+ *
23
+ * These parsers are defensive by contract: each is wrapped in try/catch and
24
+ * returns null on a malformed payload rather than throwing, because they run in
25
+ * the ingest hot path over untrusted agent data. That contract — null on
26
+ * garbage, correct decode on well-formed input, and no crash on partial input —
27
+ * is exactly what is pinned here, alongside the numeric parseInt fallbacks and
28
+ * the camelCase/snake_case dual encoding the wire format uses.
29
+ */
30
+
31
+ // --- OTLP value-wrapper builders (mirror the wire encoding) ---
32
+
33
+ function kv(entries: Array<[string, JSONObject]>): JSONObject {
34
+ return {
35
+ values: entries.map(([key, value]: [string, JSONObject]) => {
36
+ return { key, value };
37
+ }),
38
+ };
39
+ }
40
+
41
+ function str(value: string): JSONObject {
42
+ return { stringValue: value };
43
+ }
44
+
45
+ // Wrap a kvlist as the value of a key.
46
+ function obj(kvList: JSONObject): JSONObject {
47
+ return { kvlistValue: kvList };
48
+ }
49
+
50
+ // An array of kvlist items (e.g. containers, conditions, addresses).
51
+ function arrOfObjects(items: Array<JSONObject>): JSONObject {
52
+ return {
53
+ arrayValue: {
54
+ values: items.map((item: JSONObject) => {
55
+ return { kvlistValue: item };
56
+ }),
57
+ },
58
+ };
59
+ }
60
+
61
+ // An array of strings (e.g. accessModes, command, args).
62
+ function arrOfStrings(items: Array<string>): JSONObject {
63
+ return {
64
+ arrayValue: {
65
+ values: items.map((s: string) => {
66
+ return { stringValue: s };
67
+ }),
68
+ },
69
+ };
70
+ }
71
+
72
+ describe("parsePodObject", () => {
73
+ test("returns null when metadata is absent", () => {
74
+ expect(parsePodObject(kv([["spec", obj(kv([]))]]))).toBeNull();
75
+ });
76
+
77
+ test("returns null when metadata is a flat string, not a kvlist", () => {
78
+ expect(parsePodObject(kv([["metadata", str("oops")]]))).toBeNull();
79
+ });
80
+
81
+ test("decodes a fully-specified pod", () => {
82
+ const pod: JSONObject = kv([
83
+ [
84
+ "metadata",
85
+ obj(
86
+ kv([
87
+ ["name", str("web-1")],
88
+ ["namespace", str("prod")],
89
+ ["uid", str("uid-123")],
90
+ ["labels", obj(kv([["app", str("web")]]))],
91
+ ]),
92
+ ),
93
+ ],
94
+ [
95
+ "spec",
96
+ obj(
97
+ kv([
98
+ ["serviceAccountName", str("web-sa")],
99
+ ["nodeName", str("node-a")],
100
+ ["nodeSelector", obj(kv([["disk", str("ssd")]]))],
101
+ [
102
+ "containers",
103
+ arrOfObjects([
104
+ kv([
105
+ ["name", str("app")],
106
+ ["image", str("nginx:1.25")],
107
+ ["command", arrOfStrings(["nginx", "-g", "daemon off;"])],
108
+ [
109
+ "ports",
110
+ arrOfObjects([
111
+ kv([
112
+ ["name", str("http")],
113
+ ["containerPort", str("8080")],
114
+ ["protocol", str("TCP")],
115
+ ]),
116
+ ]),
117
+ ],
118
+ ]),
119
+ ]),
120
+ ],
121
+ [
122
+ "tolerations",
123
+ arrOfObjects([
124
+ kv([
125
+ ["key", str("node.kubernetes.io/not-ready")],
126
+ ["operator", str("Exists")],
127
+ ["effect", str("NoExecute")],
128
+ ]),
129
+ ]),
130
+ ],
131
+ ]),
132
+ ),
133
+ ],
134
+ [
135
+ "status",
136
+ obj(
137
+ kv([
138
+ ["phase", str("Running")],
139
+ ["podIP", str("10.0.0.5")],
140
+ ["hostIP", str("10.0.0.1")],
141
+ ["qosClass", str("Burstable")],
142
+ [
143
+ "conditions",
144
+ arrOfObjects([
145
+ kv([
146
+ ["type", str("Ready")],
147
+ ["status", str("True")],
148
+ ]),
149
+ ]),
150
+ ],
151
+ [
152
+ "containerStatuses",
153
+ arrOfObjects([
154
+ kv([
155
+ ["name", str("app")],
156
+ ["ready", str("true")],
157
+ ["restartCount", str("2")],
158
+ ["image", str("nginx:1.25")],
159
+ ["state", obj(kv([["running", obj(kv([]))]]))],
160
+ ]),
161
+ ]),
162
+ ],
163
+ ]),
164
+ ),
165
+ ],
166
+ ]);
167
+
168
+ const result: ReturnType<typeof parsePodObject> = parsePodObject(pod);
169
+ expect(result).not.toBeNull();
170
+ expect(result!.metadata.name).toBe("web-1");
171
+ expect(result!.metadata.namespace).toBe("prod");
172
+ expect(result!.metadata.labels).toEqual({ app: "web" });
173
+ expect(result!.spec.serviceAccountName).toBe("web-sa");
174
+ expect(result!.spec.nodeName).toBe("node-a");
175
+ expect(result!.spec.nodeSelector).toEqual({ disk: "ssd" });
176
+ expect(result!.spec.containers).toHaveLength(1);
177
+ expect(result!.spec.containers[0]!.name).toBe("app");
178
+ expect(result!.spec.containers[0]!.image).toBe("nginx:1.25");
179
+ expect(result!.spec.containers[0]!.command).toEqual([
180
+ "nginx",
181
+ "-g",
182
+ "daemon off;",
183
+ ]);
184
+ expect(result!.spec.containers[0]!.ports[0]!.containerPort).toBe(8080);
185
+ expect(result!.spec.tolerations[0]!.operator).toBe("Exists");
186
+ expect(result!.status.phase).toBe("Running");
187
+ expect(result!.status.podIP).toBe("10.0.0.5");
188
+ expect(result!.status.conditions[0]!.type).toBe("Ready");
189
+ expect(result!.status.containerStatuses[0]!.ready).toBe(true);
190
+ expect(result!.status.containerStatuses[0]!.restartCount).toBe(2);
191
+ expect(result!.status.containerStatuses[0]!.state).toBe("running");
192
+ });
193
+
194
+ test("extracts a container-status waiting reason (e.g. CrashLoopBackOff)", () => {
195
+ const pod: JSONObject = kv([
196
+ ["metadata", obj(kv([["name", str("crasher")]]))],
197
+ [
198
+ "status",
199
+ obj(
200
+ kv([
201
+ [
202
+ "containerStatuses",
203
+ arrOfObjects([
204
+ kv([
205
+ ["name", str("app")],
206
+ ["ready", str("false")],
207
+ [
208
+ "state",
209
+ obj(
210
+ kv([
211
+ [
212
+ "waiting",
213
+ obj(kv([["reason", str("CrashLoopBackOff")]])),
214
+ ],
215
+ ]),
216
+ ),
217
+ ],
218
+ ]),
219
+ ]),
220
+ ],
221
+ ]),
222
+ ),
223
+ ],
224
+ ]);
225
+
226
+ const result: ReturnType<typeof parsePodObject> = parsePodObject(pod);
227
+ expect(result!.status.containerStatuses[0]!.state).toBe("waiting");
228
+ expect(result!.status.containerStatuses[0]!.reason).toBe(
229
+ "CrashLoopBackOff",
230
+ );
231
+ expect(result!.status.containerStatuses[0]!.ready).toBe(false);
232
+ });
233
+
234
+ test("a metadata-only pod decodes with empty spec/status collections", () => {
235
+ const result: ReturnType<typeof parsePodObject> = parsePodObject(
236
+ kv([["metadata", obj(kv([["name", str("bare")]]))]]),
237
+ );
238
+ expect(result!.metadata.name).toBe("bare");
239
+ expect(result!.spec.containers).toEqual([]);
240
+ expect(result!.spec.nodeSelector).toEqual({});
241
+ expect(result!.status.phase).toBe("");
242
+ expect(result!.status.conditions).toEqual([]);
243
+ });
244
+
245
+ test("resolves an env var sourced from a secret to a redacted marker", () => {
246
+ const pod: JSONObject = kv([
247
+ ["metadata", obj(kv([["name", str("with-secret")]]))],
248
+ [
249
+ "spec",
250
+ obj(
251
+ kv([
252
+ [
253
+ "containers",
254
+ arrOfObjects([
255
+ kv([
256
+ ["name", str("app")],
257
+ [
258
+ "env",
259
+ arrOfObjects([
260
+ kv([
261
+ ["name", str("DB_PASSWORD")],
262
+ [
263
+ "valueFrom",
264
+ obj(
265
+ kv([
266
+ [
267
+ "secretKeyRef",
268
+ obj(
269
+ kv([
270
+ ["name", str("db-creds")],
271
+ ["key", str("password")],
272
+ ]),
273
+ ),
274
+ ],
275
+ ]),
276
+ ),
277
+ ],
278
+ ]),
279
+ ]),
280
+ ],
281
+ ]),
282
+ ]),
283
+ ],
284
+ ]),
285
+ ),
286
+ ],
287
+ ]);
288
+
289
+ const result: ReturnType<typeof parsePodObject> = parsePodObject(pod);
290
+ const env: { name: string; value: string } =
291
+ result!.spec.containers[0]!.env[0]!;
292
+ expect(env.name).toBe("DB_PASSWORD");
293
+ // The real secret value is never on the wire; the marker must not leak one.
294
+ expect(env.value).toBe("<Secret: db-creds/password>");
295
+ });
296
+ });
297
+
298
+ describe("parseNodeObject", () => {
299
+ test("returns null without metadata", () => {
300
+ expect(parseNodeObject(kv([]))).toBeNull();
301
+ });
302
+
303
+ test("decodes node info, capacity, and addresses", () => {
304
+ const node: JSONObject = kv([
305
+ ["metadata", obj(kv([["name", str("node-a")]]))],
306
+ [
307
+ "status",
308
+ obj(
309
+ kv([
310
+ [
311
+ "nodeInfo",
312
+ obj(
313
+ kv([
314
+ ["osImage", str("Ubuntu 22.04")],
315
+ ["kubeletVersion", str("v1.29.0")],
316
+ ["architecture", str("amd64")],
317
+ ]),
318
+ ),
319
+ ],
320
+ [
321
+ "capacity",
322
+ obj(
323
+ kv([
324
+ ["cpu", str("4")],
325
+ ["memory", str("8Gi")],
326
+ ]),
327
+ ),
328
+ ],
329
+ ["allocatable", obj(kv([["cpu", str("3800m")]]))],
330
+ [
331
+ "addresses",
332
+ arrOfObjects([
333
+ kv([
334
+ ["type", str("InternalIP")],
335
+ ["address", str("10.0.0.1")],
336
+ ]),
337
+ kv([
338
+ ["type", str("Hostname")],
339
+ ["address", str("node-a")],
340
+ ]),
341
+ ]),
342
+ ],
343
+ [
344
+ "conditions",
345
+ arrOfObjects([
346
+ kv([
347
+ ["type", str("Ready")],
348
+ ["status", str("True")],
349
+ ]),
350
+ ]),
351
+ ],
352
+ ]),
353
+ ),
354
+ ],
355
+ ]);
356
+
357
+ const result: ReturnType<typeof parseNodeObject> = parseNodeObject(node);
358
+ expect(result!.metadata.name).toBe("node-a");
359
+ expect(result!.status.nodeInfo.osImage).toBe("Ubuntu 22.04");
360
+ expect(result!.status.nodeInfo.kubeletVersion).toBe("v1.29.0");
361
+ expect(result!.status.capacity).toEqual({ cpu: "4", memory: "8Gi" });
362
+ expect(result!.status.allocatable).toEqual({ cpu: "3800m" });
363
+ expect(result!.status.addresses).toHaveLength(2);
364
+ expect(result!.status.addresses[0]).toEqual({
365
+ type: "InternalIP",
366
+ address: "10.0.0.1",
367
+ });
368
+ expect(result!.status.conditions[0]!.status).toBe("True");
369
+ });
370
+ });
371
+
372
+ describe("parseDeploymentObject", () => {
373
+ test("returns null without metadata", () => {
374
+ expect(parseDeploymentObject(kv([]))).toBeNull();
375
+ });
376
+
377
+ test("decodes replicas, strategy, selector, and status counters", () => {
378
+ const dep: JSONObject = kv([
379
+ ["metadata", obj(kv([["name", str("api")]]))],
380
+ [
381
+ "spec",
382
+ obj(
383
+ kv([
384
+ ["replicas", str("3")],
385
+ ["strategy", obj(kv([["type", str("RollingUpdate")]]))],
386
+ [
387
+ "selector",
388
+ obj(kv([["matchLabels", obj(kv([["app", str("api")]]))]])),
389
+ ],
390
+ ]),
391
+ ),
392
+ ],
393
+ [
394
+ "status",
395
+ obj(
396
+ kv([
397
+ ["replicas", str("3")],
398
+ ["readyReplicas", str("2")],
399
+ ["availableReplicas", str("2")],
400
+ ["unavailableReplicas", str("1")],
401
+ ]),
402
+ ),
403
+ ],
404
+ ]);
405
+
406
+ const result: ReturnType<typeof parseDeploymentObject> =
407
+ parseDeploymentObject(dep);
408
+ expect(result!.spec.replicas).toBe(3);
409
+ expect(result!.spec.strategy).toBe("RollingUpdate");
410
+ expect(result!.spec.selector).toEqual({ app: "api" });
411
+ expect(result!.status.readyReplicas).toBe(2);
412
+ expect(result!.status.unavailableReplicas).toBe(1);
413
+ });
414
+
415
+ test("non-numeric replica counts fall back to 0, never NaN", () => {
416
+ const dep: JSONObject = kv([
417
+ ["metadata", obj(kv([["name", str("api")]]))],
418
+ ["spec", obj(kv([["replicas", str("not-a-number")]]))],
419
+ ["status", obj(kv([["readyReplicas", str("")]]))],
420
+ ]);
421
+ const result: ReturnType<typeof parseDeploymentObject> =
422
+ parseDeploymentObject(dep);
423
+ expect(result!.spec.replicas).toBe(0);
424
+ expect(result!.status.readyReplicas).toBe(0);
425
+ expect(Number.isNaN(result!.spec.replicas)).toBe(false);
426
+ });
427
+ });
428
+
429
+ describe("parseJobObject", () => {
430
+ test("decodes spec counters and boolean-free status", () => {
431
+ const job: JSONObject = kv([
432
+ ["metadata", obj(kv([["name", str("backup")]]))],
433
+ [
434
+ "spec",
435
+ obj(
436
+ kv([
437
+ ["completions", str("1")],
438
+ ["parallelism", str("2")],
439
+ ["backoffLimit", str("6")],
440
+ ]),
441
+ ),
442
+ ],
443
+ [
444
+ "status",
445
+ obj(
446
+ kv([
447
+ ["active", str("0")],
448
+ ["succeeded", str("1")],
449
+ ["failed", str("0")],
450
+ ["startTime", str("2026-01-01T00:00:00Z")],
451
+ ]),
452
+ ),
453
+ ],
454
+ ]);
455
+ const result: ReturnType<typeof parseJobObject> = parseJobObject(job);
456
+ expect(result!.spec.completions).toBe(1);
457
+ expect(result!.spec.parallelism).toBe(2);
458
+ expect(result!.spec.backoffLimit).toBe(6);
459
+ expect(result!.status.succeeded).toBe(1);
460
+ expect(result!.status.startTime).toBe("2026-01-01T00:00:00Z");
461
+ });
462
+
463
+ test("a job with no spec/status yields zeroed counters", () => {
464
+ const result: ReturnType<typeof parseJobObject> = parseJobObject(
465
+ kv([["metadata", obj(kv([["name", str("empty")]]))]]),
466
+ );
467
+ expect(result!.spec.completions).toBe(0);
468
+ expect(result!.status.active).toBe(0);
469
+ expect(result!.status.conditions).toEqual([]);
470
+ });
471
+ });
472
+
473
+ describe("parseCronJobObject", () => {
474
+ test("decodes schedule and coerces suspend to a real boolean", () => {
475
+ const cron: JSONObject = kv([
476
+ ["metadata", obj(kv([["name", str("nightly")]]))],
477
+ [
478
+ "spec",
479
+ obj(
480
+ kv([
481
+ ["schedule", str("0 2 * * *")],
482
+ ["suspend", str("true")],
483
+ ["concurrencyPolicy", str("Forbid")],
484
+ ["successfulJobsHistoryLimit", str("3")],
485
+ ]),
486
+ ),
487
+ ],
488
+ ["status", obj(kv([["active", str("1")]]))],
489
+ ]);
490
+ const result: ReturnType<typeof parseCronJobObject> =
491
+ parseCronJobObject(cron);
492
+ expect(result!.spec.schedule).toBe("0 2 * * *");
493
+ expect(result!.spec.suspend).toBe(true);
494
+ expect(result!.spec.concurrencyPolicy).toBe("Forbid");
495
+ expect(result!.spec.successfulJobsHistoryLimit).toBe(3);
496
+ expect(result!.status.activeCount).toBe(1);
497
+ });
498
+
499
+ test('suspend is false for any non-"true" string', () => {
500
+ const cron: JSONObject = kv([
501
+ ["metadata", obj(kv([["name", str("nightly")]]))],
502
+ ["spec", obj(kv([["suspend", str("false")]]))],
503
+ ]);
504
+ expect(parseCronJobObject(cron)!.spec.suspend).toBe(false);
505
+ });
506
+ });
507
+
508
+ describe("parseNamespaceObject", () => {
509
+ test("decodes the phase", () => {
510
+ const ns: JSONObject = kv([
511
+ ["metadata", obj(kv([["name", str("prod")]]))],
512
+ ["status", obj(kv([["phase", str("Active")]]))],
513
+ ]);
514
+ const result: ReturnType<typeof parseNamespaceObject> =
515
+ parseNamespaceObject(ns);
516
+ expect(result!.metadata.name).toBe("prod");
517
+ expect(result!.status.phase).toBe("Active");
518
+ });
519
+
520
+ test("returns null without metadata", () => {
521
+ expect(parseNamespaceObject(kv([["status", obj(kv([]))]]))).toBeNull();
522
+ });
523
+ });
524
+
525
+ describe("parsePVCObject", () => {
526
+ test("returns null when metadata carries no name", () => {
527
+ /*
528
+ * PVC/PV parsers additionally reject a nameless object — a claim with no
529
+ * name cannot be keyed into inventory, so it is dropped rather than stored
530
+ * as a blank-named row.
531
+ */
532
+ const pvc: JSONObject = kv([
533
+ ["metadata", obj(kv([["namespace", str("prod")]]))],
534
+ ["spec", obj(kv([["storageClassName", str("gp3")]]))],
535
+ ]);
536
+ expect(parsePVCObject(pvc)).toBeNull();
537
+ });
538
+
539
+ test("decodes access modes, storage class, and requested/actual storage", () => {
540
+ const pvc: JSONObject = kv([
541
+ ["metadata", obj(kv([["name", str("data-0")]]))],
542
+ [
543
+ "spec",
544
+ obj(
545
+ kv([
546
+ ["storageClassName", str("gp3")],
547
+ ["volumeName", str("pv-abc")],
548
+ ["accessModes", arrOfStrings(["ReadWriteOnce"])],
549
+ [
550
+ "resources",
551
+ obj(kv([["requests", obj(kv([["storage", str("10Gi")]]))]])),
552
+ ],
553
+ ]),
554
+ ),
555
+ ],
556
+ [
557
+ "status",
558
+ obj(
559
+ kv([
560
+ ["phase", str("Bound")],
561
+ ["capacity", obj(kv([["storage", str("10Gi")]]))],
562
+ ]),
563
+ ),
564
+ ],
565
+ ]);
566
+ const result: ReturnType<typeof parsePVCObject> = parsePVCObject(pvc);
567
+ expect(result!.metadata.name).toBe("data-0");
568
+ expect(result!.spec.accessModes).toEqual(["ReadWriteOnce"]);
569
+ expect(result!.spec.storageClassName).toBe("gp3");
570
+ expect(result!.spec.volumeName).toBe("pv-abc");
571
+ expect(result!.spec.resources.requests.storage).toBe("10Gi");
572
+ expect(result!.status.phase).toBe("Bound");
573
+ expect(result!.status.capacity.storage).toBe("10Gi");
574
+ });
575
+ });
576
+
577
+ describe("parseStatefulSetObject", () => {
578
+ test("decodes replicas, service name, and update strategy", () => {
579
+ const sts: JSONObject = kv([
580
+ ["metadata", obj(kv([["name", str("pg")]]))],
581
+ [
582
+ "spec",
583
+ obj(
584
+ kv([
585
+ ["replicas", str("3")],
586
+ ["serviceName", str("pg-headless")],
587
+ ["podManagementPolicy", str("OrderedReady")],
588
+ ["updateStrategy", obj(kv([["type", str("RollingUpdate")]]))],
589
+ ]),
590
+ ),
591
+ ],
592
+ [
593
+ "status",
594
+ obj(
595
+ kv([
596
+ ["replicas", str("3")],
597
+ ["readyReplicas", str("3")],
598
+ ["currentReplicas", str("2")],
599
+ ]),
600
+ ),
601
+ ],
602
+ ]);
603
+ const result: ReturnType<typeof parseStatefulSetObject> =
604
+ parseStatefulSetObject(sts);
605
+ expect(result!.spec.replicas).toBe(3);
606
+ expect(result!.spec.serviceName).toBe("pg-headless");
607
+ expect(result!.spec.updateStrategy).toBe("RollingUpdate");
608
+ expect(result!.status.readyReplicas).toBe(3);
609
+ expect(result!.status.currentReplicas).toBe(2);
610
+ });
611
+
612
+ test("returns null without metadata", () => {
613
+ expect(parseStatefulSetObject(kv([["spec", obj(kv([]))]]))).toBeNull();
614
+ });
615
+ });
616
+
617
+ describe("parseDaemonSetObject", () => {
618
+ test("decodes the scheduling counters", () => {
619
+ const ds: JSONObject = kv([
620
+ ["metadata", obj(kv([["name", str("fluentd")]]))],
621
+ [
622
+ "spec",
623
+ obj(
624
+ kv([["updateStrategy", obj(kv([["type", str("RollingUpdate")]]))]]),
625
+ ),
626
+ ],
627
+ [
628
+ "status",
629
+ obj(
630
+ kv([
631
+ ["desiredNumberScheduled", str("5")],
632
+ ["currentNumberScheduled", str("5")],
633
+ ["numberReady", str("4")],
634
+ ["numberMisscheduled", str("0")],
635
+ ["numberAvailable", str("4")],
636
+ ]),
637
+ ),
638
+ ],
639
+ ]);
640
+ const result: ReturnType<typeof parseDaemonSetObject> =
641
+ parseDaemonSetObject(ds);
642
+ expect(result!.spec.updateStrategy).toBe("RollingUpdate");
643
+ expect(result!.status.desiredNumberScheduled).toBe(5);
644
+ expect(result!.status.numberReady).toBe(4);
645
+ expect(result!.status.numberMisscheduled).toBe(0);
646
+ });
647
+ });
648
+
649
+ describe("parsePVObject", () => {
650
+ test("returns null when metadata carries no name", () => {
651
+ const pv: JSONObject = kv([
652
+ ["metadata", obj(kv([["uid", str("uid-1")]]))],
653
+ ["spec", obj(kv([["storageClassName", str("gp3")]]))],
654
+ ]);
655
+ expect(parsePVObject(pv)).toBeNull();
656
+ });
657
+
658
+ test("decodes capacity, reclaim policy, and claim reference", () => {
659
+ const pv: JSONObject = kv([
660
+ ["metadata", obj(kv([["name", str("pv-abc")]]))],
661
+ [
662
+ "spec",
663
+ obj(
664
+ kv([
665
+ ["capacity", obj(kv([["storage", str("100Gi")]]))],
666
+ ["storageClassName", str("gp3")],
667
+ ["persistentVolumeReclaimPolicy", str("Retain")],
668
+ ["accessModes", arrOfStrings(["ReadWriteOnce", "ReadOnlyMany"])],
669
+ [
670
+ "claimRef",
671
+ obj(
672
+ kv([
673
+ ["name", str("data-0")],
674
+ ["namespace", str("prod")],
675
+ ]),
676
+ ),
677
+ ],
678
+ ]),
679
+ ),
680
+ ],
681
+ ["status", obj(kv([["phase", str("Bound")]]))],
682
+ ]);
683
+ const result: ReturnType<typeof parsePVObject> = parsePVObject(pv);
684
+ expect(result!.spec.capacity.storage).toBe("100Gi");
685
+ expect(result!.spec.persistentVolumeReclaimPolicy).toBe("Retain");
686
+ expect(result!.spec.accessModes).toEqual(["ReadWriteOnce", "ReadOnlyMany"]);
687
+ expect(result!.spec.claimRef.name).toBe("data-0");
688
+ expect(result!.spec.claimRef.namespace).toBe("prod");
689
+ expect(result!.status.phase).toBe("Bound");
690
+ });
691
+ });
692
+
693
+ describe("parseHPAObject", () => {
694
+ test("decodes replica bounds, target ref, and a resource metric", () => {
695
+ const hpa: JSONObject = kv([
696
+ ["metadata", obj(kv([["name", str("api-hpa")]]))],
697
+ [
698
+ "spec",
699
+ obj(
700
+ kv([
701
+ ["minReplicas", str("2")],
702
+ ["maxReplicas", str("10")],
703
+ [
704
+ "scaleTargetRef",
705
+ obj(
706
+ kv([
707
+ ["kind", str("Deployment")],
708
+ ["name", str("api")],
709
+ ]),
710
+ ),
711
+ ],
712
+ [
713
+ "metrics",
714
+ arrOfObjects([
715
+ kv([
716
+ ["type", str("Resource")],
717
+ [
718
+ "resource",
719
+ obj(
720
+ kv([
721
+ ["name", str("cpu")],
722
+ [
723
+ "target",
724
+ obj(
725
+ kv([
726
+ ["type", str("Utilization")],
727
+ ["averageUtilization", str("80")],
728
+ ]),
729
+ ),
730
+ ],
731
+ ]),
732
+ ),
733
+ ],
734
+ ]),
735
+ ]),
736
+ ],
737
+ ]),
738
+ ),
739
+ ],
740
+ [
741
+ "status",
742
+ obj(
743
+ kv([
744
+ ["currentReplicas", str("3")],
745
+ ["desiredReplicas", str("4")],
746
+ ]),
747
+ ),
748
+ ],
749
+ ]);
750
+ const result: ReturnType<typeof parseHPAObject> = parseHPAObject(hpa);
751
+ expect(result!.spec.minReplicas).toBe(2);
752
+ expect(result!.spec.maxReplicas).toBe(10);
753
+ expect(result!.spec.scaleTargetRef).toEqual({
754
+ kind: "Deployment",
755
+ name: "api",
756
+ });
757
+ expect(result!.spec.metrics).toHaveLength(1);
758
+ expect(result!.spec.metrics[0]!.type).toBe("Resource");
759
+ expect(result!.spec.metrics[0]!.resourceName).toBe("cpu");
760
+ expect(result!.spec.metrics[0]!.targetType).toBe("Utilization");
761
+ // averageUtilization wins over the other target-value shapes.
762
+ expect(result!.spec.metrics[0]!.targetValue).toBe("80");
763
+ expect(result!.status.currentReplicas).toBe(3);
764
+ expect(result!.status.desiredReplicas).toBe(4);
765
+ });
766
+
767
+ test("returns null without metadata", () => {
768
+ expect(parseHPAObject(kv([["spec", obj(kv([]))]]))).toBeNull();
769
+ });
770
+ });
771
+
772
+ describe("snake_case (protobufjs) wire encoding", () => {
773
+ test("parsePodObject decodes metadata delivered as string_value", () => {
774
+ /*
775
+ * The protobufjs transport delivers the identical field under
776
+ * string_value. A parser that only read stringValue would silently drop
777
+ * every object from agents on that transport.
778
+ */
779
+ const pod: JSONObject = kv([
780
+ [
781
+ "metadata",
782
+ obj(
783
+ kv([
784
+ ["name", { string_value: "snake-pod" }],
785
+ ["namespace", { string_value: "kube-system" }],
786
+ ]),
787
+ ),
788
+ ],
789
+ ]);
790
+ const result: ReturnType<typeof parsePodObject> = parsePodObject(pod);
791
+ expect(result!.metadata.name).toBe("snake-pod");
792
+ expect(result!.metadata.namespace).toBe("kube-system");
793
+ });
794
+ });