@skyhook-io/k8s-ui 1.11.0 → 1.13.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/package.json +4 -4
- package/src/components/gitops/GitOpsStatusBadge.tsx +40 -0
- package/src/components/gitops/GitOpsTableView.tsx +22 -2
- package/src/components/gitops/ReconcilingIndicator.test.tsx +31 -0
- package/src/components/gitops/detail-helpers.ts +4 -1
- package/src/components/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
- package/src/components/resources/ResourcesView.tsx +412 -5
- package/src/components/resources/index.ts +27 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
- package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +71 -4
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +11 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
- package/src/components/resources/renderers/CNPGShared.tsx +27 -0
- package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
- package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
- package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
- package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
- package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
- package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +159 -0
- package/src/components/resources/renderers/PolicySection.tsx +197 -0
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
- package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
- package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
- package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
- package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -1
- package/src/components/resources/renderers/calico-cells.tsx +177 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
- package/src/components/resources/renderers/index.ts +16 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +90 -0
- package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
- package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -0
- package/src/components/resources/renderers/velero-cells.tsx +29 -7
- package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
- package/src/components/resources/resource-utils-calico.test.ts +215 -0
- package/src/components/resources/resource-utils-calico.ts +261 -0
- package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +117 -0
- package/src/components/resources/resource-utils-cnpg.ts +324 -3
- package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
- package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
- package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
- package/src/components/resources/resource-utils-kyverno.ts +43 -7
- package/src/components/resources/resource-utils-velero.test.ts +26 -0
- package/src/components/resources/resource-utils-velero.ts +68 -0
- package/src/components/resources/resource-utils.ts +98 -0
- package/src/components/shared/ResourceActionsBar.tsx +101 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
- package/src/components/shared/index.ts +1 -1
- package/src/components/shared/revision-role-badges.test.ts +120 -0
- package/src/components/topology/K8sResourceNode.test.ts +10 -0
- package/src/components/topology/K8sResourceNode.tsx +22 -0
- package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +6 -6
- package/src/components/topology/TopologySearch.tsx +7 -0
- package/src/components/topology/layout.ts +3 -0
- package/src/components/trace/ReachabilityView.tsx +1 -0
- package/src/components/trace/TracePanel.tsx +12 -2
- package/src/components/trace/reachFixtures.ts +26 -1
- package/src/components/trace/reachGraphModel.test.ts +20 -0
- package/src/components/trace/reachGraphModel.ts +8 -1
- package/src/components/trace/reachOrigins.test.ts +31 -0
- package/src/components/trace/reachOrigins.ts +11 -1
- package/src/components/ui/Badge.tsx +10 -0
- package/src/components/ui/ScheduleValue.test.tsx +80 -0
- package/src/components/ui/ScheduleValue.tsx +85 -0
- package/src/components/ui/drawer-components.test.tsx +17 -1
- package/src/components/ui/drawer-components.tsx +9 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +3 -0
- package/src/types/core.ts +29 -0
- package/src/types/gitops-flux-status.test.ts +163 -0
- package/src/types/gitops.ts +80 -24
- package/src/types/index.ts +1 -0
- package/src/types/policy.ts +193 -0
- package/src/utils/navigation.test.ts +5 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/resource-icons.test.ts +49 -2
- package/src/utils/resource-icons.ts +29 -2
|
@@ -252,12 +252,19 @@ export function getCNPGClusterPhase(resource: any): string {
|
|
|
252
252
|
return resource.status?.phase || '-'
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
// A cluster pinned to an ImageCatalog carries no `spec.imageName` at all — the
|
|
256
|
+
// operator resolves the image from the catalog and records it in `status.image`.
|
|
257
|
+
// Reading spec alone leaves those clusters showing a dash where an image runs.
|
|
258
|
+
function cnpgImage(resource: any): string {
|
|
259
|
+
return resource.spec?.imageName || resource.status?.image || ''
|
|
260
|
+
}
|
|
261
|
+
|
|
255
262
|
export function getCNPGClusterImage(resource: any): string {
|
|
256
|
-
return resource
|
|
263
|
+
return cnpgImage(resource) || '-'
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
export function getCNPGClusterImageTag(resource: any): string {
|
|
260
|
-
const image = resource
|
|
267
|
+
const image = cnpgImage(resource)
|
|
261
268
|
if (!image) return '-'
|
|
262
269
|
const parts = image.split(':')
|
|
263
270
|
if (parts.length > 1) return parts[parts.length - 1]
|
|
@@ -293,6 +300,12 @@ export function getCNPGClusterUpdateStrategy(resource: any): string {
|
|
|
293
300
|
|
|
294
301
|
export const CNPG_BARMAN_PLUGIN_NAME = 'barman-cloud.cloudnative-pg.io'
|
|
295
302
|
|
|
303
|
+
/** The API group ObjectStore is served under. `objectstores` is a generic
|
|
304
|
+
* plural several operators ship, so every link to one has to be qualified —
|
|
305
|
+
* an unqualified link resolves by plural alone and can land on a foreign CRD's
|
|
306
|
+
* object of the same name. */
|
|
307
|
+
export const CNPG_BARMAN_OBJECTSTORE_GROUP = 'barmancloud.cnpg.io'
|
|
308
|
+
|
|
296
309
|
export interface CNPGBarmanPlugin {
|
|
297
310
|
name: string
|
|
298
311
|
/** ObjectStore CR (barmancloud.cnpg.io/v1) holding the real destination + recovery window. */
|
|
@@ -354,6 +367,180 @@ export function getCNPGClusterBackupConfig(resource: any): {
|
|
|
354
367
|
}
|
|
355
368
|
}
|
|
356
369
|
|
|
370
|
+
// ============================================================================
|
|
371
|
+
// DECLARATIVE OBJECTS — Database, Publication, Subscription
|
|
372
|
+
// ============================================================================
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The three declarative kinds share one status shape: `applied` plus a
|
|
376
|
+
* `message`. The field has three meanings, not two — true, false, and absent —
|
|
377
|
+
* and absent is "the operator has not reconciled this yet", which is a
|
|
378
|
+
* different thing from failed. Collapsing them would report a freshly created
|
|
379
|
+
* object as broken.
|
|
380
|
+
*/
|
|
381
|
+
export function getCNPGDeclarativeStatus(resource: any): StatusBadge {
|
|
382
|
+
const applied = resource?.status?.applied
|
|
383
|
+
if (applied === true) return { text: 'Applied', color: healthColors.healthy, level: 'healthy' }
|
|
384
|
+
if (applied === false) return { text: 'Not Applied', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
385
|
+
return { text: 'Pending', color: healthColors.unknown, level: 'unknown' }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The operator's own explanation for a failure. Never paraphrased. */
|
|
389
|
+
export function getCNPGDeclarativeMessage(resource: any): string | undefined {
|
|
390
|
+
const m = resource?.status?.message
|
|
391
|
+
return typeof m === 'string' && m !== '' ? m : undefined
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* What happens in PostgreSQL when this CR is deleted.
|
|
396
|
+
*
|
|
397
|
+
* `delete` drops the real database, publication or subscription; `retain`
|
|
398
|
+
* leaves it. This is the difference between removing a manifest and losing
|
|
399
|
+
* data, so it is surfaced rather than left in the YAML. CNPG defaults to
|
|
400
|
+
* `retain` when unset.
|
|
401
|
+
*/
|
|
402
|
+
export function getCNPGReclaimPolicy(resource: any): { value: string; destructive: boolean } {
|
|
403
|
+
const spec = resource?.spec ?? {}
|
|
404
|
+
const raw =
|
|
405
|
+
spec.databaseReclaimPolicy ?? spec.publicationReclaimPolicy ?? spec.subscriptionReclaimPolicy ?? 'retain'
|
|
406
|
+
return { value: raw, destructive: String(raw).toLowerCase() === 'delete' }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The Cluster this object is declared against. */
|
|
410
|
+
export function getCNPGDeclarativeCluster(resource: any): string | undefined {
|
|
411
|
+
const n = resource?.spec?.cluster?.name
|
|
412
|
+
return typeof n === 'string' && n !== '' ? n : undefined
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export interface CNPGImageCatalogEntry {
|
|
416
|
+
major: number
|
|
417
|
+
image: string
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Major-version pins, sorted so the list does not reshuffle between reads. */
|
|
421
|
+
export function getCNPGImageCatalogEntries(resource: any): CNPGImageCatalogEntry[] {
|
|
422
|
+
const raw = resource?.spec?.images
|
|
423
|
+
if (!Array.isArray(raw)) return []
|
|
424
|
+
return raw
|
|
425
|
+
.filter((e: any) => typeof e?.image === 'string')
|
|
426
|
+
.map((e: any) => ({ major: Number(e.major), image: e.image as string }))
|
|
427
|
+
.sort((a, b) => a.major - b.major)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ============================================================================
|
|
431
|
+
// OBJECTSTORE (barmancloud.cnpg.io/v1) — the plugin-path backup destination
|
|
432
|
+
// ============================================================================
|
|
433
|
+
|
|
434
|
+
export const CNPG_BARMAN_GROUP = 'barmancloud.cnpg.io'
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The plugin that took (or will take) a Backup / ScheduledBackup, from
|
|
438
|
+
* `spec.pluginConfiguration`. Present only when `spec.method` is `plugin`; the
|
|
439
|
+
* in-tree path leaves it unset, so absence is meaningful rather than missing.
|
|
440
|
+
*/
|
|
441
|
+
export function getCNPGBackupPlugin(resource: any): { name: string; parameters?: Record<string, any> } | null {
|
|
442
|
+
if (resource?.spec?.method !== 'plugin') return null
|
|
443
|
+
const cfg = resource?.spec?.pluginConfiguration
|
|
444
|
+
if (!cfg?.name) return null
|
|
445
|
+
return { name: cfg.name, parameters: cfg.parameters }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** True for a barman-cloud ObjectStore. The `objectstores` plural is generic
|
|
449
|
+
* enough to belong to another operator, so kind alone is not enough. */
|
|
450
|
+
export function isCNPGObjectStore(resource: any): boolean {
|
|
451
|
+
return typeof resource?.apiVersion === 'string' && resource.apiVersion.startsWith(`${CNPG_BARMAN_GROUP}/`)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Which cloud the credentials point at. Only the provider is reported — the
|
|
455
|
+
* values are Secret references and never belong on screen. */
|
|
456
|
+
export function getCNPGObjectStoreProvider(resource: any): string | undefined {
|
|
457
|
+
const c = resource?.spec?.configuration
|
|
458
|
+
if (!c) return undefined
|
|
459
|
+
if (c.s3Credentials) return 'S3'
|
|
460
|
+
if (c.azureCredentials) return 'Azure Blob Storage'
|
|
461
|
+
if (c.googleCredentials) return 'Google Cloud Storage'
|
|
462
|
+
return undefined
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The Secret holding the destination's credentials, by name only.
|
|
467
|
+
*
|
|
468
|
+
* Named rather than read: the values never belong on screen, but "which Secret"
|
|
469
|
+
* is the first question when a backup fails on authentication, and a page that
|
|
470
|
+
* says only "S3" leaves nowhere to go.
|
|
471
|
+
*/
|
|
472
|
+
export function getCNPGObjectStoreCredentialSecret(resource: any): string | undefined {
|
|
473
|
+
const c = resource?.spec?.configuration
|
|
474
|
+
const ref =
|
|
475
|
+
c?.s3Credentials?.accessKeyId ??
|
|
476
|
+
c?.azureCredentials?.storageAccount ??
|
|
477
|
+
c?.azureCredentials?.connectionString ??
|
|
478
|
+
c?.googleCredentials?.applicationCredentials
|
|
479
|
+
return typeof ref?.name === 'string' && ref.name !== '' ? ref.name : undefined
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export interface CNPGObjectStoreRecoveryWindow {
|
|
483
|
+
server: string
|
|
484
|
+
firstRecoverabilityPoint?: string
|
|
485
|
+
lastSuccessfulBackupTime?: string
|
|
486
|
+
lastFailedBackupTime?: string
|
|
487
|
+
/** A failure newer than the last success — the window has stopped advancing. */
|
|
488
|
+
failingSinceLastSuccess: boolean
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The recovery window per PostgreSQL server, from
|
|
493
|
+
* `status.serverRecoveryWindow` — a map keyed by server name.
|
|
494
|
+
*
|
|
495
|
+
* This is the payload the Cluster page promises and cannot show: a
|
|
496
|
+
* plugin-backed Cluster publishes no recovery-point fields of its own, so this
|
|
497
|
+
* is the only place the restorable range exists.
|
|
498
|
+
*/
|
|
499
|
+
export function getCNPGObjectStoreRecoveryWindows(resource: any): CNPGObjectStoreRecoveryWindow[] {
|
|
500
|
+
const raw = resource?.status?.serverRecoveryWindow
|
|
501
|
+
if (!raw || typeof raw !== 'object') return []
|
|
502
|
+
return Object.entries(raw)
|
|
503
|
+
.map(([server, w]: [string, any]) => {
|
|
504
|
+
const lastSuccess = w?.lastSuccessfulBackupTime
|
|
505
|
+
const lastFailed = w?.lastFailedBackupTime
|
|
506
|
+
return {
|
|
507
|
+
server,
|
|
508
|
+
firstRecoverabilityPoint: w?.firstRecoverabilityPoint,
|
|
509
|
+
lastSuccessfulBackupTime: lastSuccess,
|
|
510
|
+
lastFailedBackupTime: lastFailed,
|
|
511
|
+
failingSinceLastSuccess:
|
|
512
|
+
!!lastFailed && (!lastSuccess || new Date(lastFailed) > new Date(lastSuccess)),
|
|
513
|
+
}
|
|
514
|
+
})
|
|
515
|
+
.sort((a, b) => a.server.localeCompare(b.server))
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Health for the list badge.
|
|
520
|
+
*
|
|
521
|
+
* `unknown` rather than healthy when no server has reported: an ObjectStore
|
|
522
|
+
* with an empty status is configured but holds nothing restorable, and green
|
|
523
|
+
* there would assert a recovery point that does not exist.
|
|
524
|
+
*/
|
|
525
|
+
export function getCNPGObjectStoreStatus(resource: any): StatusBadge {
|
|
526
|
+
const windows = getCNPGObjectStoreRecoveryWindows(resource)
|
|
527
|
+
if (windows.length === 0) {
|
|
528
|
+
return { text: 'No backups yet', color: healthColors.unknown, level: 'unknown' }
|
|
529
|
+
}
|
|
530
|
+
if (windows.some((w) => w.failingSinceLastSuccess)) {
|
|
531
|
+
return { text: 'Backups Failing', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
532
|
+
}
|
|
533
|
+
return { text: 'Recoverable', color: healthColors.healthy, level: 'healthy' }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function getCNPGObjectStoreDestination(resource: any): string {
|
|
537
|
+
return resource?.spec?.configuration?.destinationPath || '-'
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function getCNPGObjectStoreRetention(resource: any): string | undefined {
|
|
541
|
+
return resource?.spec?.retentionPolicy
|
|
542
|
+
}
|
|
543
|
+
|
|
357
544
|
export function getCNPGClusterMonitoring(resource: any): {
|
|
358
545
|
podMonitorEnabled: boolean
|
|
359
546
|
customQueriesConfigMap?: string[]
|
|
@@ -576,6 +763,33 @@ export function getCNPGBackupTarget(resource: any): string {
|
|
|
576
763
|
// CNPG SCHEDULED BACKUP UTILITIES
|
|
577
764
|
// ============================================================================
|
|
578
765
|
|
|
766
|
+
/**
|
|
767
|
+
* Reconcile latency between a backup coming due and the operator starting it.
|
|
768
|
+
* Not a backup policy — nothing here decides how often backups should run.
|
|
769
|
+
* Kept in step with the detector that raises the matching issue.
|
|
770
|
+
*/
|
|
771
|
+
const CNPG_SCHEDULED_BACKUP_GRACE_MS = 10 * 60 * 1000
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* How far past its own next-run time this schedule is, or null when it is not
|
|
775
|
+
* meaningfully overdue.
|
|
776
|
+
*
|
|
777
|
+
* The expectation belongs to the operator: CNPG publishes `nextScheduleTime`
|
|
778
|
+
* and updates it whenever it runs a backup. A value still in the past therefore
|
|
779
|
+
* means no backup happened. Suspended schedules are excluded because the
|
|
780
|
+
* operator stops maintaining the field, so the stale value would read as missed
|
|
781
|
+
* on every deliberately paused schedule.
|
|
782
|
+
*/
|
|
783
|
+
export function getCNPGScheduledBackupOverdueMs(resource: any): number | null {
|
|
784
|
+
if (resource?.spec?.suspend === true) return null
|
|
785
|
+
const next = resource?.status?.nextScheduleTime
|
|
786
|
+
if (!next) return null
|
|
787
|
+
const dueAt = new Date(next).getTime()
|
|
788
|
+
if (Number.isNaN(dueAt)) return null
|
|
789
|
+
const overdue = Date.now() - dueAt
|
|
790
|
+
return overdue > CNPG_SCHEDULED_BACKUP_GRACE_MS ? overdue : null
|
|
791
|
+
}
|
|
792
|
+
|
|
579
793
|
export function getCNPGScheduledBackupStatus(resource: any): StatusBadge {
|
|
580
794
|
const isSuspended = resource.spec?.suspend === true
|
|
581
795
|
|
|
@@ -583,6 +797,14 @@ export function getCNPGScheduledBackupStatus(resource: any): StatusBadge {
|
|
|
583
797
|
return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
|
|
584
798
|
}
|
|
585
799
|
|
|
800
|
+
// The operator said when the next backup was due and it has not happened.
|
|
801
|
+
// Checked before lastScheduleTime: a schedule that ran once and then stopped
|
|
802
|
+
// has both, and reporting it healthy contradicted the next-run figure sitting
|
|
803
|
+
// on the same row.
|
|
804
|
+
if (getCNPGScheduledBackupOverdueMs(resource) !== null) {
|
|
805
|
+
return { text: 'Overdue', color: healthColors.degraded, level: 'degraded' }
|
|
806
|
+
}
|
|
807
|
+
|
|
586
808
|
// If we have a last schedule time, it's active
|
|
587
809
|
if (resource.status?.lastScheduleTime) {
|
|
588
810
|
return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
|
|
@@ -615,10 +837,20 @@ export function getCNPGScheduledBackupLastSchedule(resource: any): string {
|
|
|
615
837
|
export function getCNPGScheduledBackupNextSchedule(resource: any): string {
|
|
616
838
|
const next = resource.status?.nextScheduleTime
|
|
617
839
|
if (!next) return '-'
|
|
840
|
+
// A suspended schedule keeps whatever next-run time it had when it was
|
|
841
|
+
// paused, and the operator stops maintaining it. Counting down to it, or
|
|
842
|
+
// calling it due, would both describe a run that is not coming.
|
|
843
|
+
if (resource.spec?.suspend === true) return 'suspended'
|
|
618
844
|
const nextDate = new Date(next)
|
|
619
845
|
const now = new Date()
|
|
620
846
|
const diffMs = nextDate.getTime() - now.getTime()
|
|
621
|
-
|
|
847
|
+
// Past due, but inside the window where the operator has simply not picked it
|
|
848
|
+
// up yet. Calling that "overdue" while the badge still reads healthy is the
|
|
849
|
+
// contradiction this pair is meant to avoid.
|
|
850
|
+
if (diffMs <= 0) {
|
|
851
|
+
const overdue = getCNPGScheduledBackupOverdueMs(resource)
|
|
852
|
+
return overdue === null ? 'due now' : `overdue by ${formatDuration(overdue)}`
|
|
853
|
+
}
|
|
622
854
|
return `in ${formatDuration(diffMs)}`
|
|
623
855
|
}
|
|
624
856
|
|
|
@@ -666,6 +898,12 @@ export function getCNPGPoolerStatus(resource: any): StatusBadge {
|
|
|
666
898
|
return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
|
|
667
899
|
}
|
|
668
900
|
|
|
901
|
+
// Faults outrank intent: a Pooler that is both paused and unscheduled has a
|
|
902
|
+
// problem worth fixing before the pause is worth mentioning.
|
|
903
|
+
if (isCNPGPoolerPaused(resource)) {
|
|
904
|
+
return { text: 'Paused', color: healthColors.degraded, level: 'degraded' }
|
|
905
|
+
}
|
|
906
|
+
|
|
669
907
|
if (desired > 0 && scheduled >= desired) {
|
|
670
908
|
return { text: 'Scheduled', color: healthColors.healthy, level: 'healthy' }
|
|
671
909
|
}
|
|
@@ -673,6 +911,16 @@ export function getCNPGPoolerStatus(resource: any): StatusBadge {
|
|
|
673
911
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
674
912
|
}
|
|
675
913
|
|
|
914
|
+
/**
|
|
915
|
+
* PgBouncer holds client connections instead of serving them while paused, and
|
|
916
|
+
* every pod stays scheduled and Ready throughout - the one Pooler state that
|
|
917
|
+
* pod counts cannot express. CRD-defaulted to false, so only an explicit true
|
|
918
|
+
* pauses.
|
|
919
|
+
*/
|
|
920
|
+
export function isCNPGPoolerPaused(resource: any): boolean {
|
|
921
|
+
return resource.spec?.pgbouncer?.paused === true
|
|
922
|
+
}
|
|
923
|
+
|
|
676
924
|
/** Name of the Deployment CNPG generates for this Pooler — where real readiness lives. */
|
|
677
925
|
export function getCNPGPoolerDeploymentName(resource: any): string {
|
|
678
926
|
return resource.metadata?.name || ''
|
|
@@ -707,3 +955,76 @@ export function getCNPGPoolerAuthQuerySecret(resource: any): { name: string } |
|
|
|
707
955
|
if (!secret?.name) return undefined
|
|
708
956
|
return { name: secret.name }
|
|
709
957
|
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Volume health for a CNPG cluster, read from the arrays the operator publishes
|
|
961
|
+
* on status. Radar previously showed only the REQUESTED size and storage class,
|
|
962
|
+
* which are spec-side intentions — a cluster whose volumes had failed rendered
|
|
963
|
+
* a cheerful "20Gi" with nothing to suggest otherwise.
|
|
964
|
+
*
|
|
965
|
+
* `unusablePVC` is the sharp one. Upstream defines it as a volume that cannot be
|
|
966
|
+
* used because a paired volume is missing, which is why a database instance can
|
|
967
|
+
* be permanently unable to start while every other field looks ordinary.
|
|
968
|
+
*
|
|
969
|
+
* Returns null when the operator has published nothing, so the section can stay
|
|
970
|
+
* silent rather than claim zero problems it never checked for.
|
|
971
|
+
*/
|
|
972
|
+
export interface CNPGVolumeHealth {
|
|
973
|
+
total?: number
|
|
974
|
+
healthy: string[]
|
|
975
|
+
unusable: string[]
|
|
976
|
+
dangling: string[]
|
|
977
|
+
resizing: string[]
|
|
978
|
+
initializing: string[]
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export function getCNPGVolumeHealth(resource: any): CNPGVolumeHealth | null {
|
|
982
|
+
const s = resource?.status
|
|
983
|
+
if (!s) return null
|
|
984
|
+
const list = (v: any): string[] => (Array.isArray(v) ? v.filter((x: any) => typeof x === 'string') : [])
|
|
985
|
+
const health: CNPGVolumeHealth = {
|
|
986
|
+
total: typeof s.pvcCount === 'number' ? s.pvcCount : undefined,
|
|
987
|
+
healthy: list(s.healthyPVC),
|
|
988
|
+
unusable: list(s.unusablePVC),
|
|
989
|
+
dangling: list(s.danglingPVC),
|
|
990
|
+
resizing: list(s.resizingPVC),
|
|
991
|
+
initializing: list(s.initializingPVC),
|
|
992
|
+
}
|
|
993
|
+
const reportedAnything =
|
|
994
|
+
health.total !== undefined ||
|
|
995
|
+
health.healthy.length > 0 ||
|
|
996
|
+
health.unusable.length > 0 ||
|
|
997
|
+
health.dangling.length > 0 ||
|
|
998
|
+
health.resizing.length > 0 ||
|
|
999
|
+
health.initializing.length > 0
|
|
1000
|
+
return reportedAnything ? health : null
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Splits declarative objects by what the operator has actually said about them.
|
|
1005
|
+
*
|
|
1006
|
+
* Three buckets, not two. `applied: true` is live. `applied: false` is a failure
|
|
1007
|
+
* the operator can explain. ABSENT is not yet reconciled — and folding that in
|
|
1008
|
+
* with the failures tells the reader an object "exists in Kubernetes and not in
|
|
1009
|
+
* PostgreSQL" during the seconds before anything has looked at it.
|
|
1010
|
+
*/
|
|
1011
|
+
export function splitCNPGDeclarativeByApplied<T>(objects: T[]): {
|
|
1012
|
+
applied: T[]
|
|
1013
|
+
notApplied: T[]
|
|
1014
|
+
pending: T[]
|
|
1015
|
+
} {
|
|
1016
|
+
const out = { applied: [] as T[], notApplied: [] as T[], pending: [] as T[] }
|
|
1017
|
+
for (const o of objects) {
|
|
1018
|
+
switch (getCNPGDeclarativeStatus(o).level) {
|
|
1019
|
+
case 'healthy':
|
|
1020
|
+
out.applied.push(o)
|
|
1021
|
+
break
|
|
1022
|
+
case 'unhealthy':
|
|
1023
|
+
out.notApplied.push(o)
|
|
1024
|
+
break
|
|
1025
|
+
default:
|
|
1026
|
+
out.pending.push(o)
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return out
|
|
1030
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getProviderConfigStatus,
|
|
10
10
|
getProviderConfigRef,
|
|
11
11
|
getCrossplaneResourceRefs,
|
|
12
|
+
getBoundXRRef,
|
|
12
13
|
getExternalName,
|
|
13
14
|
getComposingXRRef,
|
|
14
15
|
isCrossplanePaused,
|
|
@@ -512,6 +513,73 @@ describe('getCrossplaneResourceRefs', () => {
|
|
|
512
513
|
})
|
|
513
514
|
})
|
|
514
515
|
|
|
516
|
+
describe('getBoundXRRef', () => {
|
|
517
|
+
it('returns the singular spec.resourceRef of a v1 claim', () => {
|
|
518
|
+
const claim = {
|
|
519
|
+
spec: {
|
|
520
|
+
compositionRef: { name: 'database' },
|
|
521
|
+
resourceRef: {
|
|
522
|
+
apiVersion: 'platform.example.org/v1alpha1',
|
|
523
|
+
kind: 'Database',
|
|
524
|
+
name: 'example-database-x7k2m',
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
} as const
|
|
528
|
+
const ref = getBoundXRRef(claim)
|
|
529
|
+
expect(ref?.kind).toBe('Database')
|
|
530
|
+
expect(ref?.name).toBe('example-database-x7k2m')
|
|
531
|
+
expect(ref?.apiVersion).toBe('platform.example.org/v1alpha1')
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('returns null when resourceRef is absent or malformed', () => {
|
|
535
|
+
expect(getBoundXRRef({ spec: {} } as const)).toBeNull()
|
|
536
|
+
expect(getBoundXRRef({ spec: { resourceRef: { name: 'no-kind' } } } as const)).toBeNull()
|
|
537
|
+
expect(getBoundXRRef({ spec: { resourceRef: { kind: 'X' } } } as const)).toBeNull()
|
|
538
|
+
expect(getBoundXRRef({} as const)).toBeNull()
|
|
539
|
+
})
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
describe('claim vs XR composed-refs split', () => {
|
|
543
|
+
// A v1 claim carries NO resourceRefs of its own — only a singular resourceRef
|
|
544
|
+
// to its bound XR. The composed refs live on that XR. The claim panel must
|
|
545
|
+
// follow the ref rather than read the claim, or it shows "No composed
|
|
546
|
+
// resources" for a claim that has composed plenty.
|
|
547
|
+
const claim = {
|
|
548
|
+
spec: {
|
|
549
|
+
compositionRef: { name: 'database' },
|
|
550
|
+
resourceRef: {
|
|
551
|
+
apiVersion: 'platform.example.org/v1alpha1',
|
|
552
|
+
kind: 'Database',
|
|
553
|
+
name: 'example-database-x7k2m',
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
} as const
|
|
557
|
+
const boundXR = {
|
|
558
|
+
spec: {
|
|
559
|
+
resourceRefs: [
|
|
560
|
+
{ apiVersion: 'database.example.org/v1beta1', kind: 'Instance', name: 'example-database-x7k2m' },
|
|
561
|
+
{ apiVersion: 'identity.example.org/v1beta1', kind: 'Role', name: 'example-database-access' },
|
|
562
|
+
{ apiVersion: 'kubernetes.crossplane.io/v1alpha2', kind: 'Object', name: 'example-database-connection' },
|
|
563
|
+
],
|
|
564
|
+
},
|
|
565
|
+
} as const
|
|
566
|
+
|
|
567
|
+
it('reads nothing off the claim itself', () => {
|
|
568
|
+
expect(getCrossplaneResourceRefs(claim)).toEqual([])
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('is detected as a claim with a bound XR', () => {
|
|
572
|
+
expect(isClaim(claim)).toBe(true)
|
|
573
|
+
expect(getBoundXRRef(claim)?.name).toBe('example-database-x7k2m')
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('reads the composed refs off the bound XR', () => {
|
|
577
|
+
const refs = getCrossplaneResourceRefs(boundXR)
|
|
578
|
+
expect(refs).toHaveLength(3)
|
|
579
|
+
expect(refs.map(r => r.kind)).toEqual(['Instance', 'Role', 'Object'])
|
|
580
|
+
})
|
|
581
|
+
})
|
|
582
|
+
|
|
515
583
|
describe('getExternalName', () => {
|
|
516
584
|
it('reads the crossplane.io/external-name annotation', () => {
|
|
517
585
|
const resource = {
|
|
@@ -143,6 +143,30 @@ export function getKyvernoFailurePolicy(resource: any): string {
|
|
|
143
143
|
return resource?.spec?.failurePolicy || ''
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* `spec.failurePolicy` with the CRD's own default applied.
|
|
148
|
+
*
|
|
149
|
+
* An ABSENT failurePolicy means Fail — "Allowed values are Ignore or Fail.
|
|
150
|
+
* Defaults to Fail", per the published schema, and most policies never set it.
|
|
151
|
+
* Reading absent as anything else inverts the consequence of an engine error:
|
|
152
|
+
* the screen would say those admissions are "allowed through unchecked" when the
|
|
153
|
+
* cluster is in fact rejecting them. Same defaulting trap as validationActions
|
|
154
|
+
* above, in the other direction.
|
|
155
|
+
*
|
|
156
|
+
* Kept separate from the raw reader so the Evaluation section can still show
|
|
157
|
+
* only what the policy actually declares.
|
|
158
|
+
*/
|
|
159
|
+
export function getKyvernoEffectiveFailurePolicy(resource: any): string {
|
|
160
|
+
return (
|
|
161
|
+
resource?.spec?.failurePolicy ||
|
|
162
|
+
// Where the legacy family keeps it since 1.13. Reading only the modern
|
|
163
|
+
// location would default a policy that explicitly set Ignore to Fail, and
|
|
164
|
+
// announce a rejection the cluster is not making.
|
|
165
|
+
resource?.spec?.webhookConfiguration?.failurePolicy ||
|
|
166
|
+
'Fail'
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
146
170
|
/**
|
|
147
171
|
* Computes what the policy actually does, rather than echoing a single field.
|
|
148
172
|
*
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
getKyvernoRequestType,
|
|
4
|
+
getKyvernoRequestTriggers,
|
|
5
|
+
getKyvernoRequestGenerated,
|
|
6
|
+
getKyvernoRequestState,
|
|
7
|
+
getKyvernoReportSubject,
|
|
8
|
+
getKyvernoReportSummary,
|
|
9
|
+
getKyvernoReportResults,
|
|
10
|
+
getKyvernoReportStatus,
|
|
11
|
+
kyvernoReportTimestamp,
|
|
12
|
+
} from './resource-utils-kyverno-queue'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The fixtures below are trimmed copies of objects captured with a watch on a
|
|
16
|
+
* live Kyverno 1.18.2 while a generateExisting rule and a mutate-existing rule
|
|
17
|
+
* ran: 251 UpdateRequests and 3322 EphemeralReports. Every assertion here
|
|
18
|
+
* exists because the obvious reading of the CRD produces a blank screen.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const GENERATE_REQUEST = {
|
|
22
|
+
metadata: { name: 'ur-j8qjv', namespace: 'kyverno', labels: { 'generate.kyverno.io/policy-name': 'probe-generate-existing' } },
|
|
23
|
+
spec: {
|
|
24
|
+
policy: 'probe-generate-existing',
|
|
25
|
+
requestType: 'generate',
|
|
26
|
+
deleteDownstream: false,
|
|
27
|
+
// Both empty on every generate request observed.
|
|
28
|
+
resource: {},
|
|
29
|
+
rule: '',
|
|
30
|
+
ruleContext: [
|
|
31
|
+
{ rule: 'companion-for-existing', deleteDownstream: false, trigger: { apiVersion: 'v1', kind: 'ConfigMap', name: 'bulk-001', namespace: 'policy-demo', uid: 'a' } },
|
|
32
|
+
{ rule: 'companion-for-existing', deleteDownstream: false, trigger: { apiVersion: 'v1', kind: 'ConfigMap', name: 'bulk-002', namespace: 'policy-demo', uid: 'b' } },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
status: {
|
|
36
|
+
state: 'Completed',
|
|
37
|
+
generatedResources: [{ apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy', name: 'bulk-250-probe', namespace: 'policy-demo' }],
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MUTATE_REQUEST = {
|
|
42
|
+
metadata: { name: 'ur-2mhmq', namespace: 'kyverno' },
|
|
43
|
+
spec: {
|
|
44
|
+
policy: 'probe-mutate-existing',
|
|
45
|
+
requestType: 'mutate',
|
|
46
|
+
rule: 'label-existing-configmaps',
|
|
47
|
+
deleteDownstream: false,
|
|
48
|
+
resource: { apiVersion: 'v1', kind: 'ConfigMap', name: 'bulk-001', namespace: 'policy-demo', uid: 'a' },
|
|
49
|
+
},
|
|
50
|
+
status: { state: 'Completed' },
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('UpdateRequest', () => {
|
|
54
|
+
it('reads triggers off ruleContext for a generate request', () => {
|
|
55
|
+
// spec.resource is {} here — reading it alone renders an empty page.
|
|
56
|
+
const t = getKyvernoRequestTriggers(GENERATE_REQUEST)
|
|
57
|
+
expect(t).toHaveLength(2)
|
|
58
|
+
expect(t[0].name).toBe('bulk-001')
|
|
59
|
+
expect(t[0].kind).toBe('ConfigMap')
|
|
60
|
+
expect(t[0].rule).toBe('companion-for-existing')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('reads the single trigger off spec.resource for a mutate request', () => {
|
|
64
|
+
const t = getKyvernoRequestTriggers(MUTATE_REQUEST)
|
|
65
|
+
expect(t).toHaveLength(1)
|
|
66
|
+
expect(t[0].name).toBe('bulk-001')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('reports no triggers rather than a blank row when neither is present', () => {
|
|
70
|
+
expect(getKyvernoRequestTriggers({ spec: { requestType: 'generate', resource: {}, ruleContext: [] } })).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('distinguishes the two request types', () => {
|
|
74
|
+
expect(getKyvernoRequestType(GENERATE_REQUEST)).toBe('generate')
|
|
75
|
+
expect(getKyvernoRequestType(MUTATE_REQUEST)).toBe('mutate')
|
|
76
|
+
expect(getKyvernoRequestType({ spec: {} })).toBe('unknown')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('lists what was created, and only on the request that creates', () => {
|
|
80
|
+
expect(getKyvernoRequestGenerated(GENERATE_REQUEST)).toHaveLength(1)
|
|
81
|
+
expect(getKyvernoRequestGenerated(MUTATE_REQUEST)).toEqual([])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Pending is the state worth surfacing: work queued and never done.
|
|
85
|
+
it('separates queued from done from failed', () => {
|
|
86
|
+
expect(getKyvernoRequestState({ status: { state: 'Pending' } }).level).toBe('degraded')
|
|
87
|
+
expect(getKyvernoRequestState({ status: { state: 'Completed' } }).level).toBe('healthy')
|
|
88
|
+
expect(getKyvernoRequestState({ status: { state: 'Failed' } }).level).toBe('unhealthy')
|
|
89
|
+
expect(getKyvernoRequestState({}).level).toBe('unknown')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const REPORT = {
|
|
94
|
+
metadata: {
|
|
95
|
+
name: 'eae46cfc-2733-466c-b20a-f1a354b6f73b-7b47h',
|
|
96
|
+
namespace: 'kube-system',
|
|
97
|
+
labels: {
|
|
98
|
+
'audit.kyverno.io/resource.kind': 'Pod',
|
|
99
|
+
'audit.kyverno.io/resource.group': '',
|
|
100
|
+
'audit.kyverno.io/resource.version': 'v1',
|
|
101
|
+
'audit.kyverno.io/resource.uid': 'eae46cfc-2733-466c-b20a-f1a354b6f73b',
|
|
102
|
+
'audit.kyverno.io/source': 'background-scan',
|
|
103
|
+
},
|
|
104
|
+
ownerReferences: [{ apiVersion: 'v1', kind: 'Pod', name: 'coredns-7d764666f9-dl4p6', uid: 'eae46cfc' }],
|
|
105
|
+
},
|
|
106
|
+
spec: {
|
|
107
|
+
// Present and blank on every report observed.
|
|
108
|
+
owner: { apiVersion: '', kind: '', name: '', uid: '' },
|
|
109
|
+
summary: { error: 0, fail: 1, pass: 2, skip: 0, warn: 0 },
|
|
110
|
+
results: [
|
|
111
|
+
{ message: 'mutation is not applied', policy: 'add-default-labels', result: 'fail', source: 'KyvernoMutatingPolicy', timestamp: { seconds: 1786622539, nanos: 0 } },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe('EphemeralReport', () => {
|
|
117
|
+
// spec.owner is the documented field and is useless; the owner reference is
|
|
118
|
+
// where the subject actually is.
|
|
119
|
+
it('takes the subject from the owner reference when spec.owner is blank', () => {
|
|
120
|
+
const s = getKyvernoReportSubject(REPORT)
|
|
121
|
+
expect(s?.kind).toBe('Pod')
|
|
122
|
+
expect(s?.name).toBe('coredns-7d764666f9-dl4p6')
|
|
123
|
+
expect(s?.namespace).toBe('kube-system')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('falls back to the labels when the subject is already gone', () => {
|
|
127
|
+
const { ownerReferences, ...meta } = REPORT.metadata as any
|
|
128
|
+
const s = getKyvernoReportSubject({ ...REPORT, metadata: meta })
|
|
129
|
+
expect(s?.kind).toBe('Pod')
|
|
130
|
+
expect(s?.name).toBeUndefined()
|
|
131
|
+
expect(s?.apiVersion).toBe('v1')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('prefers spec.owner when a future version starts filling it in', () => {
|
|
135
|
+
const s = getKyvernoReportSubject({
|
|
136
|
+
...REPORT,
|
|
137
|
+
spec: { ...REPORT.spec, owner: { apiVersion: 'apps/v1', kind: 'Deployment', name: 'web', uid: 'x' } },
|
|
138
|
+
})
|
|
139
|
+
expect(s?.kind).toBe('Deployment')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// The findings live in spec, not status, which is the reverse of every other
|
|
143
|
+
// report-shaped object here.
|
|
144
|
+
it('reads results and summary out of spec', () => {
|
|
145
|
+
expect(getKyvernoReportResults(REPORT)).toHaveLength(1)
|
|
146
|
+
expect(getKyvernoReportResults({ status: { results: [{}] } })).toEqual([])
|
|
147
|
+
const s = getKyvernoReportSummary(REPORT)
|
|
148
|
+
expect(s).toMatchObject({ fail: 1, pass: 2, total: 3 })
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('reports the worst outcome for the list badge', () => {
|
|
152
|
+
expect(getKyvernoReportStatus(REPORT).level).toBe('unhealthy')
|
|
153
|
+
expect(getKyvernoReportStatus({ spec: { summary: { pass: 4 } } }).text).toBe('4 pass')
|
|
154
|
+
expect(getKyvernoReportStatus({ spec: {} }).text).toBe('No results')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// `new Date({seconds, nanos})` is Invalid Date, not an error.
|
|
158
|
+
it('converts the seconds/nanos timestamp rather than passing it to Date', () => {
|
|
159
|
+
expect(kyvernoReportTimestamp({ seconds: 1786622539, nanos: 0 })).toBe('2026-08-13T12:02:19.000Z')
|
|
160
|
+
expect(kyvernoReportTimestamp({ seconds: 0 })).toBeUndefined()
|
|
161
|
+
expect(kyvernoReportTimestamp(undefined)).toBeUndefined()
|
|
162
|
+
expect(kyvernoReportTimestamp('2026-08-13T09:22:19Z')).toBe('2026-08-13T09:22:19Z')
|
|
163
|
+
})
|
|
164
|
+
})
|