@skyhook-io/k8s-ui 1.13.4 → 1.14.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.
Files changed (26) hide show
  1. package/package.json +4 -4
  2. package/src/components/resources/ResourcesView.tsx +2 -2
  3. package/src/components/resources/kyverno-legacy-action.test.ts +100 -1
  4. package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
  5. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
  6. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
  7. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
  8. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
  9. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
  10. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
  11. package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
  12. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
  13. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
  14. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
  15. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
  16. package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
  17. package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
  18. package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
  19. package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
  20. package/src/components/resources/resource-utils-cnpg.ts +132 -7
  21. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +14 -0
  22. package/src/components/resources/resource-utils-kueue.ts +4 -2
  23. package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
  24. package/src/components/resources/resource-utils-kyverno.ts +51 -2
  25. package/src/components/resources/resource-utils-velero.ts +32 -0
  26. package/src/components/ui/SelectMenu.tsx +10 -1
@@ -145,6 +145,100 @@ export function classifyCNPGClusterPhase(phase: string): CNPGPhaseBucket {
145
145
  return 'unknown'
146
146
  }
147
147
 
148
+ // Debounce window for calling a was-up cluster "down". A single instance
149
+ // restarting flips Ready=False for a minute or two while the pod comes back;
150
+ // escalating instantly would fire on routine rollouts. Kept in step with
151
+ // cnpgDownGrace in internal/issues/source_cnpg.go — the two must not drift.
152
+ export const CNPG_DOWN_GRACE_MS = 5 * 60 * 1000
153
+
154
+ /**
155
+ * Hibernation is a deliberate scale-to-zero: the operator removes the pods and
156
+ * the cluster reports nothing ready by design. Signalled by an annotation, not
157
+ * by any status field, so it has to be read before a zero-ready count is called
158
+ * an outage.
159
+ */
160
+ export function isCNPGClusterHibernated(resource: any): boolean {
161
+ return resource?.metadata?.annotations?.['cnpg.io/hibernation'] === 'on'
162
+ }
163
+
164
+ /**
165
+ * Fencing stops PostgreSQL on the named instances while leaving the pods up. The
166
+ * annotation is a JSON array of instance names; `"*"` means every instance is
167
+ * fenced, so nothing serves — intentionally, not as a fault. A malformed value
168
+ * is treated as "not fenced" rather than throwing.
169
+ */
170
+ export function isCNPGClusterFencedAll(resource: any): boolean {
171
+ const raw = resource?.metadata?.annotations?.['cnpg.io/fencedInstances']
172
+ if (typeof raw !== 'string' || raw === '') return false
173
+ try {
174
+ const parsed = JSON.parse(raw)
175
+ return Array.isArray(parsed) && parsed.includes('*')
176
+ } catch {
177
+ return false
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Whether a zero-ready cluster is actually unavailable, and why.
183
+ *
184
+ * The load-bearing distinction the badge and the Go detector both turn on:
185
+ * CNPG omits `status.readyInstances` when it is 0 (omitempty int), so "no
186
+ * status yet" and "0 ready" are byte-identical on the wire. The field's
187
+ * presence therefore cannot distinguish an outage from an unreported cluster;
188
+ * any other status the operator has written (phase, primary, a condition) is
189
+ * what marks the count as a real 0 rather than "not reported".
190
+ *
191
+ * `status.currentPrimary` is the version-robust "was up" signal: CNPG sets it
192
+ * the first time a primary is elected and never clears it (present on 1.27 and
193
+ * 1.28). So a zero-ready cluster that HAS a currentPrimary was serving before —
194
+ * a regression — while one that never had a primary is still bootstrapping.
195
+ *
196
+ * - null — not down: still bootstrapping, or a real ready count exists
197
+ * - 'hibernated' / 'fenced' — deliberately not serving, no alarm
198
+ * - 'down' — was up, now zero ready past the grace window
199
+ *
200
+ * Mirrored by getCNPGClusterAvailability's Go counterpart in
201
+ * internal/issues/source_cnpg.go (the allDown verdict) — same signals, same
202
+ * grace, or a badge and its issue disagree.
203
+ */
204
+ export function getCNPGClusterAvailability(
205
+ resource: any,
206
+ ): 'hibernated' | 'fenced' | 'down' | null {
207
+ const status = resource.status || {}
208
+ const desired = resource.spec?.instances ?? 0
209
+ const ready = typeof status.readyInstances === 'number' ? status.readyInstances : 0
210
+ // Any status the operator has written proves it has reconciled at least once,
211
+ // which is what lets an absent readyInstances be read as a real 0 rather than
212
+ // "not reported". Absent EVERYTHING is the truly-statusless case, still unknown.
213
+ const reported = !!(status.phase || status.currentPrimary || status.conditions?.length)
214
+ if (desired <= 0 || ready > 0 || !reported) return null
215
+ // Deliberate not-serving states outrank the down verdict — checked before the
216
+ // was-up gate so a hibernated cluster reads neutral even if it kept a primary.
217
+ if (isCNPGClusterHibernated(resource)) return 'hibernated'
218
+ if (isCNPGClusterFencedAll(resource)) return 'fenced'
219
+ // Never elected a primary → first bootstrap, not a regression.
220
+ if (!status.currentPrimary) return null
221
+ const conditions = status.conditions || []
222
+ const readyCond = conditions.find((c: any) => c.type === 'Ready')
223
+ // A was-up cluster whose Ready only just went False is likely a single
224
+ // instance bouncing; wait out the grace. No Ready=False at all (or no
225
+ // timestamp) means nothing is debouncing, so escalate immediately rather than
226
+ // inventing a start time.
227
+ if (
228
+ readyCond?.status === 'False' &&
229
+ readyCond.lastTransitionTime &&
230
+ Date.now() - Date.parse(readyCond.lastTransitionTime) < CNPG_DOWN_GRACE_MS
231
+ ) {
232
+ return null
233
+ }
234
+ // A cluster woken from hibernation or lifted from a full fence transiently
235
+ // shows this same shape — was-up, zero ready, Ready=False from long before —
236
+ // until its first instance is Ready. CNPG drops the hibernation marker at that
237
+ // point, so from the Cluster status alone this is indistinguishable from a real
238
+ // outage, and it reads down for that short window.
239
+ return 'down'
240
+ }
241
+
148
242
  export function getCNPGClusterStatus(resource: any): StatusBadge {
149
243
  const status = resource.status || {}
150
244
  const phase = status.phase || ''
@@ -165,10 +259,19 @@ export function getCNPGClusterStatus(resource: any): StatusBadge {
165
259
  if (bucket === 'terminal') {
166
260
  return { text: getCNPGClusterDisplayState(phase), color: healthColors.unhealthy, level: 'unhealthy' }
167
261
  }
168
- // Zero ready instances is a hard down that no phase excuses — including a
169
- // failover, where the phase alone would otherwise downgrade a total outage to
170
- // orange.
171
- if (countsKnown && readyInstances === 0) {
262
+ // Availability outranks every phase branch except terminal. A zero-ready
263
+ // cluster CNPG reports by omitting readyInstances would otherwise be invisible
264
+ // here (countsKnown is false without the field) and fall through to the
265
+ // transient phase, painting amber on a total outage. Hibernation and all-fenced
266
+ // are deliberate not-serving states, so they read neutral rather than red.
267
+ const avail = getCNPGClusterAvailability(resource)
268
+ if (avail === 'hibernated') {
269
+ return { text: 'Hibernated', color: healthColors.neutral, level: 'neutral' }
270
+ }
271
+ if (avail === 'fenced') {
272
+ return { text: 'Fenced', color: healthColors.neutral, level: 'neutral' }
273
+ }
274
+ if (avail === 'down') {
172
275
  return { text: 'Not Ready', color: healthColors.unhealthy, level: 'unhealthy' }
173
276
  }
174
277
  if (bucket === 'failing') {
@@ -241,7 +344,20 @@ export function getCNPGClusterStatus(resource: any): StatusBadge {
241
344
  const countOrDash = (n: unknown): string => (typeof n === 'number' ? String(n) : '-')
242
345
 
243
346
  export function getCNPGClusterInstances(resource: any): string {
244
- return `${countOrDash(resource.status?.readyInstances)}/${countOrDash(resource.spec?.instances)}`
347
+ const status = resource.status || {}
348
+ // Absent readyInstances resolves to 0 only for a was-up cluster (one that
349
+ // elected a primary): there the omitted field genuinely means zero ready, and
350
+ // "0/3" agrees with the "Not Ready" badge. Before a primary exists the count
351
+ // is truly unknown, so it stays a dash — the guard against fabricating an
352
+ // outage the badge denies. currentPrimary is the same was-up signal the badge
353
+ // gates its down verdict on, so the cell can never read 0 beside a green badge.
354
+ const ready =
355
+ typeof status.readyInstances === 'number'
356
+ ? status.readyInstances
357
+ : status.currentPrimary
358
+ ? 0
359
+ : undefined
360
+ return `${countOrDash(ready)}/${countOrDash(resource.spec?.instances)}`
245
361
  }
246
362
 
247
363
  export function getCNPGClusterPrimary(resource: any): string {
@@ -554,12 +670,21 @@ export function getCNPGClusterMonitoring(resource: any): {
554
670
  }
555
671
  }
556
672
 
673
+ // CNPG carries replica-cluster state in `spec.replica`. Presence is not the
674
+ // signal: promoting a replica to primary mutates the stanza's fields but leaves
675
+ // it in place. This mirrors the operator's own Cluster.IsReplica() — `enabled`
676
+ // decides when set; otherwise the cluster is a replica only while it is not the
677
+ // designated primary of the distributed topology ((self || name) != primary).
557
678
  export function getCNPGClusterIsReplica(resource: any): boolean {
558
- return !!resource.spec?.replicaCluster
679
+ const replica = resource.spec?.replica
680
+ if (!replica) return false
681
+ if (replica.enabled != null) return replica.enabled === true
682
+ const self = replica.self || resource.metadata?.name
683
+ return self !== replica.primary
559
684
  }
560
685
 
561
686
  export function getCNPGClusterReplicaSource(resource: any): string {
562
- const replica = resource.spec?.replicaCluster
687
+ const replica = resource.spec?.replica
563
688
  if (!replica) return '-'
564
689
  return replica.source || replica.primary || '-'
565
690
  }
@@ -37,6 +37,20 @@ describe('GPU ecosystem API contracts', () => {
37
37
  expect(getKueueWorkloadStatus(workload).text).toBe('Evicted')
38
38
  })
39
39
 
40
+ it('distinguishes Kueue terminal failures from successful workloads', () => {
41
+ for (const reason of ['Failed', 'FailedToStart', 'OutOfSync', 'OwnerNotFound']) {
42
+ expect(getKueueWorkloadStatus({
43
+ status: { conditions: [{ type: 'Finished', status: 'True', reason }] },
44
+ })).toMatchObject({ text: reason, level: 'unhealthy' })
45
+ }
46
+
47
+ for (const reason of ['Succeeded', 'WorkloadSliceReplaced']) {
48
+ expect(getKueueWorkloadStatus({
49
+ status: { conditions: [{ type: 'Finished', status: 'True', reason }] },
50
+ })).toMatchObject({ text: 'Finished', level: 'neutral' })
51
+ }
52
+ })
53
+
40
54
  it('lets KubeRay conditions outrank deprecated state fields', () => {
41
55
  expect(getRayClusterStatus({
42
56
  status: {
@@ -3,6 +3,8 @@
3
3
  import type { StatusBadge } from './resource-utils'
4
4
  import { healthColors } from './resource-utils'
5
5
 
6
+ const failedFinishedReasons = new Set(['Failed', 'FailedToStart', 'OutOfSync', 'OwnerNotFound'])
7
+
6
8
  // ============================================================================
7
9
  // SHARED HELPERS
8
10
  // ============================================================================
@@ -84,8 +86,8 @@ export function getLocalQueueAdmittedWorkloads(resource: any): string {
84
86
  export function getKueueWorkloadStatus(resource: any): StatusBadge {
85
87
  const finished = findCondition(resource, 'Finished')
86
88
  if (finished?.status === 'True') {
87
- if (finished.reason === 'Failed') {
88
- return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
89
+ if (failedFinishedReasons.has(finished.reason)) {
90
+ return { text: finished.reason, color: healthColors.unhealthy, level: 'unhealthy' }
89
91
  }
90
92
  return { text: 'Finished', color: healthColors.neutral, level: 'neutral' }
91
93
  }
@@ -472,7 +472,9 @@ export function getKyvernoMutations(resource: any): KyvernoMutation[] {
472
472
  export function getKyvernoGenerations(resource: any): string[] {
473
473
  const generate = resource?.spec?.generate
474
474
  if (!Array.isArray(generate)) return []
475
- return generate.map((g: any) => g?.expression || '').filter(Boolean)
475
+ // Each entry declares its target as exactly one of a CEL `expression` or a
476
+ // YAML `template.value`; both are first-class ways to say what gets generated.
477
+ return generate.map((g: any) => g?.expression || g?.template?.value || '').filter(Boolean)
476
478
  }
477
479
 
478
480
  /**
@@ -115,6 +115,16 @@ export function getKyvernoPolicyStatus(resource: any): StatusBadge {
115
115
  return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
116
116
  }
117
117
 
118
+ /**
119
+ * Kyverno's ValidationFailureAction enum accepts the deprecated lowercase
120
+ * `enforce` alongside `Enforce`, and the admission controller blocks on the
121
+ * lowercase spelling exactly as on the capitalized one. Match case-insensitively
122
+ * so a legacy policy carrying the lowercase value is not read as non-blocking.
123
+ */
124
+ export function isKyvernoEnforceAction(action: unknown): boolean {
125
+ return String(action ?? '').toLowerCase() === 'enforce'
126
+ }
127
+
118
128
  /** The rule blocks whose failure an admission request can be rejected for. */
119
129
  function rejectingActions(rule: any): string[] {
120
130
  if (!rule) return []
@@ -148,14 +158,14 @@ function rejectingActions(rule: any): string[] {
148
158
  export function getKyvernoPolicyAction(resource: any): string {
149
159
  const specAction = resource.spec?.validationFailureAction
150
160
  const rules = resource.spec?.rules || []
151
- if (rules.length === 0) return specAction || 'Audit'
161
+ if (rules.length === 0) return isKyvernoEnforceAction(specAction) ? 'Enforce' : 'Audit'
152
162
  const rejecting = rules.filter((rule: any) => rule?.validate || rule?.verifyImages)
153
163
  // Rules, but none that can reject: the spec-level action governs nothing here.
154
164
  if (rejecting.length === 0) return 'Audit'
155
165
  for (const rule of rejecting) {
156
166
  const overrides = rejectingActions(rule)
157
167
  const effective = overrides.length > 0 ? overrides : [specAction]
158
- if (effective.includes('Enforce')) return 'Enforce'
168
+ if (effective.some(isKyvernoEnforceAction)) return 'Enforce'
159
169
  }
160
170
  return 'Audit'
161
171
  }
@@ -204,6 +214,45 @@ export function getKyvernoPolicyBackground(resource: any): boolean {
204
214
  return resource.spec?.background !== false
205
215
  }
206
216
 
217
+ /**
218
+ * `spec.admission`, defaulting to true — the same reading as Kyverno's own
219
+ * AdmissionProcessingEnabled(). When false the policy is never registered with
220
+ * the admission webhook, so it rejects nothing at admission whatever its
221
+ * failureAction says; it can only be seen by background scans.
222
+ */
223
+ export function getKyvernoPolicyAdmission(resource: any): boolean {
224
+ return resource.spec?.admission !== false
225
+ }
226
+
227
+ export interface KyvernoEnforcement {
228
+ /** Badge label. */
229
+ label: string
230
+ /** True only when a violating request is actually rejected at admission. */
231
+ blocks: boolean
232
+ /** Declared to reject, but admission is off — it enforces nothing at admission. */
233
+ discrepancy: boolean
234
+ }
235
+
236
+ /**
237
+ * What a legacy policy does at admission, not just what its failureAction field
238
+ * reads. An Enforce policy with admission disabled blocks nothing — presenting
239
+ * it as a blocking "Enforce" would tell an operator the cluster is guarded when
240
+ * it is not. Labels match the modern CEL family's vocabulary for the same
241
+ * posture: "Background only" when background scans still report violations,
242
+ * "Inactive" when background is disabled too and the policy does nothing.
243
+ */
244
+ export function getKyvernoEnforcement(resource: any): KyvernoEnforcement {
245
+ const action = getKyvernoPolicyAction(resource)
246
+ if (action !== 'Enforce' || getKyvernoPolicyAdmission(resource)) {
247
+ return { label: action, blocks: action === 'Enforce', discrepancy: false }
248
+ }
249
+ return {
250
+ label: getKyvernoPolicyBackground(resource) ? 'Background only' : 'Inactive',
251
+ blocks: false,
252
+ discrepancy: true,
253
+ }
254
+ }
255
+
207
256
  export function getKyvernoPolicyRuleCountByType(resource: any): {
208
257
  validate: number
209
258
  mutate: number
@@ -306,6 +306,30 @@ export function getRestoreExcludedResources(resource: any): string[] {
306
306
  return resource.spec?.excludedResources || []
307
307
  }
308
308
 
309
+ export function getRestoreNamespaceMapping(resource: any): Record<string, string> {
310
+ return resource.spec?.namespaceMapping || {}
311
+ }
312
+
313
+ export function getRestoreLabelSelector(resource: any): any {
314
+ return resource.spec?.labelSelector
315
+ }
316
+
317
+ export function getRestoreOrLabelSelectors(resource: any): any[] {
318
+ return resource.spec?.orLabelSelectors || []
319
+ }
320
+
321
+ /**
322
+ * A label selector only narrows a restore when it carries at least one term. An
323
+ * empty selector matches everything, so it must not be presented as scope: doing
324
+ * so would tell an operator a restore is filtered when it is not.
325
+ */
326
+ export function hasLabelSelectorTerms(selector: any): boolean {
327
+ if (!selector || typeof selector !== 'object') return false
328
+ const hasLabels = selector.matchLabels && Object.keys(selector.matchLabels).length > 0
329
+ const hasExpressions = Array.isArray(selector.matchExpressions) && selector.matchExpressions.length > 0
330
+ return Boolean(hasLabels || hasExpressions)
331
+ }
332
+
309
333
  export function getRestoreDuration(resource: any): string {
310
334
  const start = resource.status?.startTimestamp
311
335
  const end = resource.status?.completionTimestamp
@@ -486,6 +510,14 @@ export function getBSLLastSynced(resource: any): string {
486
510
  return formatAge(lastSynced)
487
511
  }
488
512
 
513
+ // Why the location is unavailable, in the validation controller's own words.
514
+ // A BackupStorageLocation carries no conditions, so status.message is the only
515
+ // place the reason exists — without it the phase is the whole story a reader
516
+ // gets.
517
+ export function getBSLMessage(resource: any): string {
518
+ return resource.status?.message || ''
519
+ }
520
+
489
521
  // ============================================================================
490
522
  // VOLUME SNAPSHOT LOCATION UTILITIES
491
523
  // ============================================================================
@@ -12,15 +12,21 @@ export function SelectMenu({
12
12
  options,
13
13
  onChange,
14
14
  ariaLabel,
15
+ ariaDescribedBy,
15
16
  className,
16
17
  searchPlaceholder,
18
+ disabled = false,
19
+ id,
17
20
  }: {
18
21
  value: string
19
22
  options: SelectMenuOption[]
20
23
  onChange: (value: string) => void
21
24
  ariaLabel: string
25
+ ariaDescribedBy?: string
22
26
  className?: string
23
27
  searchPlaceholder?: string
28
+ disabled?: boolean
29
+ id?: string
24
30
  }) {
25
31
  const [open, setOpen] = useState(false)
26
32
  const [query, setQuery] = useState('')
@@ -109,8 +115,10 @@ export function SelectMenu({
109
115
  >
110
116
  <button
111
117
  ref={triggerRef}
118
+ id={id}
112
119
  type="button"
113
120
  aria-label={ariaLabel}
121
+ aria-describedby={ariaDescribedBy}
114
122
  aria-haspopup="listbox"
115
123
  aria-expanded={open}
116
124
  aria-controls={open ? listboxId : undefined}
@@ -125,7 +133,8 @@ export function SelectMenu({
125
133
  }
126
134
  setOpen(!open)
127
135
  }}
128
- className="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover"
136
+ disabled={disabled}
137
+ className="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-theme-elevated"
129
138
  >
130
139
  <span className="truncate">{selected?.label}</span>
131
140
  <ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />