@skyhook-io/k8s-ui 1.18.2 → 1.20.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 +9 -2
- package/src/components/applications/ApplicationDetail.tsx +8 -2
- package/src/components/charts/AreaChart.test.tsx +39 -1
- package/src/components/charts/AreaChart.tsx +32 -13
- package/src/components/charts/axis.test.ts +17 -1
- package/src/components/charts/axis.ts +13 -0
- package/src/components/charts/format.test.ts +159 -2
- package/src/components/charts/format.ts +118 -0
- package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
- package/src/components/compare/ResourceCompareView.test.tsx +54 -0
- package/src/components/compare/ResourceCompareView.tsx +35 -12
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +26 -7
- package/src/components/gitops/RollbackDialog.test.tsx +80 -0
- package/src/components/gitops/RollbackDialog.tsx +25 -6
- package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
- package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
- package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
- package/src/components/gitops/applicationset-row.test.ts +12 -0
- package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
- package/src/components/gitops/detail-helpers.test.ts +8 -2
- package/src/components/gitops/detail-helpers.ts +9 -11
- package/src/components/gitops/index.ts +1 -1
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
- package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
- package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
- package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
- package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +65 -0
- package/src/components/gitops/insights/insights-helpers.ts +17 -3
- package/src/components/issues/IssueGuidance.test.tsx +190 -0
- package/src/components/issues/IssueGuidance.tsx +48 -0
- package/src/components/issues/IssuesView.tsx +15 -3
- package/src/components/issues/ResourceIssuesSection.tsx +3 -1
- package/src/components/issues/diagnostic.ts +14 -1
- package/src/components/issues/index.ts +1 -0
- package/src/components/issues/severity.ts +15 -0
- package/src/components/issues/types.ts +10 -0
- package/src/components/kueue/AdmissionPath.tsx +223 -0
- package/src/components/kueue/QueueEvidence.tsx +148 -0
- package/src/components/kueue/constants.ts +1 -0
- package/src/components/kueue/eligibility.ts +43 -0
- package/src/components/kueue/flavors-tried.ts +79 -0
- package/src/components/kueue/helpers.test.ts +105 -0
- package/src/components/kueue/policy.ts +100 -0
- package/src/components/kueue/queue-detail.test.tsx +317 -0
- package/src/components/kueue/queue-status.ts +42 -0
- package/src/components/kueue/related-read.ts +48 -0
- package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
- package/src/components/logs/StructuredLogLine.test.tsx +22 -0
- package/src/components/logs/StructuredLogLine.tsx +8 -3
- package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
- package/src/components/resources/KueueAdmissionSection.tsx +51 -12
- package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
- package/src/components/resources/KueuePendingPosition.tsx +91 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
- package/src/components/resources/ResourcesSidebar.tsx +3 -2
- package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
- package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
- package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
- package/src/components/resources/ResourcesView.tsx +50 -3
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +7 -0
- package/src/components/resources/get-pod-phase-display.test.ts +70 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
- package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
- package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
- package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/JobRenderer.tsx +7 -2
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
- package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
- package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
- package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
- package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
- package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
- package/src/components/resources/renderers/crd-cells.tsx +55 -0
- package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
- package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
- package/src/components/resources/renderers/index.ts +6 -0
- package/src/components/resources/renderers/kueue-cells.tsx +5 -0
- package/src/components/resources/resource-utils-argo.test.ts +94 -1
- package/src/components/resources/resource-utils-argo.ts +84 -5
- package/src/components/resources/resource-utils-crd.test.ts +32 -0
- package/src/components/resources/resource-utils-crd.ts +26 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
- package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
- package/src/components/resources/resource-utils-kueue.test.ts +15 -0
- package/src/components/resources/resource-utils-kueue.ts +137 -4
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
- package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
- package/src/components/resources/resource-utils-ray.test.ts +42 -1
- package/src/components/resources/resource-utils-ray.ts +38 -0
- package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
- package/src/components/resources/resource-utils-strimzi.ts +35 -0
- package/src/components/resources/resource-utils.ts +45 -1
- package/src/components/shared/ResourceActionsBar.tsx +83 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
- package/src/components/topology/K8sResourceNode.test.ts +14 -0
- package/src/components/topology/K8sResourceNode.tsx +18 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +96 -94
- package/src/components/topology/layout.ts +3 -1
- package/src/components/topology/pod-group-expansion.test.ts +38 -0
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/RadarUpgradeNote.tsx +8 -0
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/PodList.tsx +35 -9
- package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
- package/src/components/workload/WorkloadView.tsx +61 -8
- package/src/components/workload/workload-logs-availability.test.ts +7 -0
- package/src/types/core.ts +41 -0
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +2 -2
- package/src/types/kueue-queues.ts +84 -0
- package/src/types/read-coverage.ts +2 -1
- package/src/types/scheduling.ts +72 -0
- package/src/utils/animation.test.ts +38 -0
- package/src/utils/animation.ts +34 -0
- package/src/utils/api-resources-crd.test.ts +11 -0
- package/src/utils/api-resources.ts +5 -1
- package/src/utils/applications.test.ts +15 -1
- package/src/utils/applications.ts +26 -1
- package/src/utils/crd-schema.test.ts +107 -0
- package/src/utils/crd-schema.ts +201 -0
- package/src/utils/format.test.ts +37 -1
- package/src/utils/format.ts +28 -80
- package/src/utils/gitops-owner.test.ts +28 -2
- package/src/utils/gitops-owner.ts +19 -0
- package/src/utils/kueue-queue-workloads.ts +19 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +90 -1
- package/src/utils/topology-neighborhood.ts +55 -3
- package/src/utils/workload-history-notes.test.ts +76 -0
- package/src/utils/workload-history-notes.ts +93 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Route } from 'lucide-react'
|
|
3
|
+
import { ResourceLink, Section, ownerNote } from '../ui/drawer-components'
|
|
4
|
+
import { gapReasonText } from '../../utils/read-actions'
|
|
5
|
+
import { FactSource, FactValue } from '../facts'
|
|
6
|
+
import type { Relationships } from '../../types'
|
|
7
|
+
import { kindToPluralWithGroup } from '../../utils/navigation'
|
|
8
|
+
import { isKueueConditionStale } from '../resources/resource-utils-kueue'
|
|
9
|
+
import { localQueueStatusWithParent } from './queue-status'
|
|
10
|
+
import { isFlavorAssignmentMessage, parseFlavorAttempts, type FlavorAttempt, type FlavorAttemptKind } from './flavors-tried'
|
|
11
|
+
import { ClusterQueueReadSummary, ReadState, StatusPill, namespaceEligibilityFact, type Navigate } from './QueueEvidence'
|
|
12
|
+
import { KUEUE_GROUP } from './constants'
|
|
13
|
+
import type { RelatedRead } from './related-read'
|
|
14
|
+
|
|
15
|
+
/** Objects on a Workload's admission path that a host read as the viewer. */
|
|
16
|
+
export interface KueueWorkloadReads {
|
|
17
|
+
localQueue?: RelatedRead
|
|
18
|
+
/** Keyed by ClusterQueue name: the LocalQueue's target and, when different, the one holding the reservation. */
|
|
19
|
+
clusterQueues?: Record<string, RelatedRead>
|
|
20
|
+
namespace?: RelatedRead
|
|
21
|
+
/** Keyed by ResourceFlavor name: the flavors the reservation assigned. */
|
|
22
|
+
flavors?: Record<string, RelatedRead>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Step({ label, children }: { label: string; children: ReactNode }) {
|
|
26
|
+
return (
|
|
27
|
+
<li className="relative grid grid-cols-[7.5rem_minmax(0,1fr)] gap-3 py-2.5 text-sm first:pt-0 last:pb-0">
|
|
28
|
+
<span className="text-theme-text-tertiary">{label}</span>
|
|
29
|
+
<div className="min-w-0 space-y-1">{children}</div>
|
|
30
|
+
</li>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const muted = 'text-xs text-theme-text-secondary'
|
|
35
|
+
|
|
36
|
+
function OwnerStep({ data, relationships, onNavigate }: { data: any; relationships?: Relationships; onNavigate?: Navigate }) {
|
|
37
|
+
const coverage = relationships?.ownership
|
|
38
|
+
// Without the ownership answer (a Radar that predates it), `owner` may come
|
|
39
|
+
// from display edges that check neither group nor UID, so it isn't linked;
|
|
40
|
+
// the Workload's own controller reference is named instead.
|
|
41
|
+
const controllerRef = (data.metadata?.ownerReferences ?? []).find((ref: any) => ref?.controller)
|
|
42
|
+
const owner = coverage ? relationships?.owner : undefined
|
|
43
|
+
const clusterName: string | undefined = data.status?.clusterName
|
|
44
|
+
let body: ReactNode
|
|
45
|
+
if (owner) {
|
|
46
|
+
body = <div className="flex flex-wrap items-center gap-2 break-all [&_button]:text-left">
|
|
47
|
+
<span className="text-xs text-theme-text-tertiary">{owner.kind}</span>
|
|
48
|
+
<ResourceLink name={owner.name} namespace={owner.namespace} kind={kindToPluralWithGroup(owner.kind, owner.group ?? '')} group={owner.group} onNavigate={onNavigate} />
|
|
49
|
+
</div>
|
|
50
|
+
} else if (!relationships) {
|
|
51
|
+
body = <p className={muted}>Owner not read here.</p>
|
|
52
|
+
} else if (!coverage && controllerRef) {
|
|
53
|
+
body = <p className={muted}>Controller reference: {controllerRef.kind} {controllerRef.name} (this Radar doesn't resolve owners).</p>
|
|
54
|
+
} else if (coverage?.primaryOwnerState === 'several') {
|
|
55
|
+
body = <p className={muted}>Several owner references; none is the controller.</p>
|
|
56
|
+
} else {
|
|
57
|
+
const reason = coverage?.unavailableReason
|
|
58
|
+
const unanswered = !reason ? undefined
|
|
59
|
+
: reason === 'user_forbidden' ? "The owner isn't shown: your access lets you open this Workload, but not list its kind here."
|
|
60
|
+
: `The owner couldn't be read: ${gapReasonText(reason)}.`
|
|
61
|
+
const note = ownerNote(coverage)
|
|
62
|
+
body = <p className={muted}>{unanswered ?? note?.text ?? (data.metadata?.ownerReferences?.length ? 'Owner not resolved.' : 'No owner reference: this Workload was created directly.')}</p>
|
|
63
|
+
}
|
|
64
|
+
return <Step label="Owner">
|
|
65
|
+
{body}
|
|
66
|
+
{clusterName && <p className={muted}>Kueue reports cluster <code>{clusterName}</code> for this Workload (status.clusterName). Radar shows this cluster only and doesn't follow that name elsewhere.</p>}
|
|
67
|
+
</Step>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function AdmissionPath({ data, reads, relationships, onNavigate }: { data: any; reads?: KueueWorkloadReads; relationships?: Relationships; onNavigate?: Navigate }) {
|
|
71
|
+
const namespace: string = data.metadata?.namespace ?? ''
|
|
72
|
+
const queueName: string | undefined = data.spec?.queueName
|
|
73
|
+
const localQueue = reads?.localQueue
|
|
74
|
+
const configured: string | undefined = localQueue?.state === 'found' ? localQueue.object?.spec?.clusterQueue : undefined
|
|
75
|
+
const reserved: string | undefined = data.status?.admission?.clusterQueue
|
|
76
|
+
const finished = (data.status?.conditions ?? []).some((condition: any) => condition?.type === 'Finished' && condition.status === 'True')
|
|
77
|
+
const admitted = (data.status?.conditions ?? []).some((condition: any) => condition?.type === 'Admitted' && condition.status === 'True')
|
|
78
|
+
const configuredRead = configured ? reads?.clusterQueues?.[configured] : undefined
|
|
79
|
+
const eligibility = configured && reads ? namespaceEligibilityFact({ clusterQueue: configured, parent: configuredRead, namespaceRead: reads.namespace }) : undefined
|
|
80
|
+
const assignments: any[] = Array.isArray(data.status?.admission?.podSetAssignments) ? data.status.admission.podSetAssignments : []
|
|
81
|
+
// Kueue's own word on why quota isn't reserved, verbatim. Flavor-assignment
|
|
82
|
+
// messages are left to "Reasons reported per flavor".
|
|
83
|
+
const quota = (data.status?.conditions ?? []).find((condition: any) => condition?.type === 'QuotaReserved')
|
|
84
|
+
const pendingMessage: string | undefined = quota?.status === 'False' && quota.message && !isFlavorAssignmentMessage(quota.message) ? quota.message : undefined
|
|
85
|
+
const pendingStale = isKueueConditionStale(quota, data.metadata?.generation)
|
|
86
|
+
return (
|
|
87
|
+
<Section variant="card" title="Admission Path" icon={Route}>
|
|
88
|
+
<ol className="divide-y divide-theme-border">
|
|
89
|
+
<OwnerStep data={data} relationships={relationships} onNavigate={onNavigate} />
|
|
90
|
+
<Step label="LocalQueue">
|
|
91
|
+
{!queueName ? <p className={muted}>No queue name: spec.queueName is empty, so the Workload names no LocalQueue.</p> : <>
|
|
92
|
+
<div className="flex flex-wrap items-center gap-2 break-all [&_button]:text-left">
|
|
93
|
+
<ResourceLink name={queueName} kind="localqueues" namespace={namespace} group={KUEUE_GROUP} onNavigate={onNavigate} />
|
|
94
|
+
<ReadState read={localQueue} found={localQueue?.state === 'found' ? <StatusPill status={localQueueStatusWithParent(localQueue.object, configuredRead)} /> : undefined} />
|
|
95
|
+
</div>
|
|
96
|
+
{localQueue?.state === 'not_found' && <p className={muted}>{reserved
|
|
97
|
+
? `No LocalQueue ${queueName} exists in namespace ${namespace} now; the reservation below was made through it earlier.`
|
|
98
|
+
: finished || data.spec?.active === false
|
|
99
|
+
? `No LocalQueue ${queueName} exists in namespace ${namespace}.`
|
|
100
|
+
: `No LocalQueue ${queueName} exists in namespace ${namespace}. Kueue can't admit this Workload until it does.`}</p>}
|
|
101
|
+
</>}
|
|
102
|
+
</Step>
|
|
103
|
+
{queueName && reads && (
|
|
104
|
+
<Step label="ClusterQueue">
|
|
105
|
+
{configured ? <>
|
|
106
|
+
<ClusterQueueReadSummary name={configured} read={configuredRead} onNavigate={onNavigate} />
|
|
107
|
+
<p className="text-xs text-theme-text-tertiary">Configured by LocalQueue {queueName}.</p>
|
|
108
|
+
{eligibility && !reserved && <div className="text-xs"><span className="text-theme-text-tertiary">Namespace {namespace}: </span><FactValue fact={eligibility} /><FactSource fact={eligibility} /></div>}
|
|
109
|
+
</> : <p className={muted}>
|
|
110
|
+
{localQueue?.state === 'found' ? 'The LocalQueue names no ClusterQueue.'
|
|
111
|
+
: localQueue?.state === 'not_found' ? `None: LocalQueue ${queueName} doesn't exist.`
|
|
112
|
+
: localQueue?.state === 'unconfirmed' ? `Unknown: this Radar can't confirm LocalQueue ${queueName} exists.`
|
|
113
|
+
: localQueue?.state === 'loading' ? 'Checking…' : 'Unknown until the LocalQueue is read.'}
|
|
114
|
+
</p>}
|
|
115
|
+
</Step>
|
|
116
|
+
)}
|
|
117
|
+
<Step label="Reservation">
|
|
118
|
+
{reserved ? <>
|
|
119
|
+
<p className="text-xs text-theme-text-tertiary">{finished ? 'Reservation recorded in (the Workload finished, so its quota was released)' : 'Quota reserved in'}</p>
|
|
120
|
+
{reads && reserved !== configured
|
|
121
|
+
? <ClusterQueueReadSummary name={reserved} read={reads.clusterQueues?.[reserved]} onNavigate={onNavigate} />
|
|
122
|
+
: <div className="break-all [&_button]:text-left"><ResourceLink name={reserved} kind="clusterqueues" group={KUEUE_GROUP} onNavigate={onNavigate} /></div>}
|
|
123
|
+
{configured && configured !== reserved && (finished || admitted
|
|
124
|
+
? <p className="text-xs text-theme-text-tertiary">Reserved in {reserved}; LocalQueue {queueName}, as it exists now, names {configured}.</p>
|
|
125
|
+
: <p className="text-xs text-warning-text">Reserved in {reserved}; LocalQueue {queueName}, as it exists now, names {configured}.</p>)}
|
|
126
|
+
</> : <>
|
|
127
|
+
<p className={muted}>No reservation reported.</p>
|
|
128
|
+
{pendingMessage && <p className="whitespace-pre-wrap break-words text-xs text-theme-text-secondary"><span className="text-theme-text-tertiary">{pendingStale ? `Controller-reported on QuotaReserved for generation ${quota.observedGeneration} (now ${data.metadata?.generation}): ` : 'Controller-reported on QuotaReserved: '}</span>{pendingMessage}</p>}
|
|
129
|
+
</>}
|
|
130
|
+
</Step>
|
|
131
|
+
{assignments.length > 0 && (
|
|
132
|
+
<Step label="Flavors">
|
|
133
|
+
<div className="space-y-2">
|
|
134
|
+
{assignments.map((assignment, index) => (
|
|
135
|
+
<div key={assignment?.name ?? index} className="space-y-0.5">
|
|
136
|
+
{assignments.length > 1 && <p className="text-xs font-medium text-theme-text-secondary">{assignment?.name ?? `PodSet ${index + 1}`}</p>}
|
|
137
|
+
{Object.entries(assignment?.flavors ?? {}).length === 0
|
|
138
|
+
? <p className={muted}>No flavors reported.</p>
|
|
139
|
+
: Object.entries(assignment.flavors).map(([resource, flavor]) => (
|
|
140
|
+
<div key={resource} className="flex flex-wrap items-center gap-1.5 break-all text-xs [&_button]:text-left">
|
|
141
|
+
<span className="text-theme-text-secondary">{resource}</span>
|
|
142
|
+
<span className="text-theme-text-tertiary">→</span>
|
|
143
|
+
<ResourceLink name={String(flavor)} kind="resourceflavors" group={KUEUE_GROUP} onNavigate={onNavigate} />
|
|
144
|
+
{finished && reads?.flavors?.[String(flavor)]?.state === 'not_found'
|
|
145
|
+
? <span className="text-xs text-theme-text-tertiary">No longer exists</span>
|
|
146
|
+
: reads?.flavors?.[String(flavor)]?.state !== 'found' && <ReadState read={reads?.flavors?.[String(flavor)]} />}
|
|
147
|
+
</div>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
</Step>
|
|
153
|
+
)}
|
|
154
|
+
</ol>
|
|
155
|
+
</Section>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Reasons grouped under the flavor they name, in the order flavors first appear in the message. */
|
|
160
|
+
function groupByFlavor(attempts: FlavorAttempt[]): [string | undefined, FlavorAttempt[]][] {
|
|
161
|
+
const groups = new Map<string | undefined, FlavorAttempt[]>()
|
|
162
|
+
for (const attempt of attempts) groups.set(attempt.flavor, [...(groups.get(attempt.flavor) ?? []), attempt])
|
|
163
|
+
return [...groups]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const attemptLabel: Record<FlavorAttemptKind, string> = {
|
|
167
|
+
insufficient_quota: 'Over maximum capacity',
|
|
168
|
+
insufficient_unused_quota: 'Not enough unused quota',
|
|
169
|
+
untolerated_taint: 'Untolerated taint',
|
|
170
|
+
node_affinity: 'Node affinity mismatch',
|
|
171
|
+
flavor_not_found: 'Flavor not found',
|
|
172
|
+
resource_unavailable: 'Resource not in ClusterQueue',
|
|
173
|
+
skipped_by_annotation: 'Skipped by annotation',
|
|
174
|
+
pinned_by_original: 'Held by original Workload',
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The reasons Kueue gave per flavor in the Workload's pending QuotaReserved
|
|
179
|
+
* message, parsed when every part is recognised and verbatim otherwise. It is
|
|
180
|
+
* one controller report, not an attempt history.
|
|
181
|
+
*/
|
|
182
|
+
export function FlavorReasons({ data, onNavigate }: { data: any; onNavigate?: Navigate }) {
|
|
183
|
+
const conditions: any[] = data.status?.conditions ?? []
|
|
184
|
+
const quota = conditions.find(condition => condition?.type === 'QuotaReserved')
|
|
185
|
+
if (quota?.status !== 'False' || !isFlavorAssignmentMessage(quota.message)) return null
|
|
186
|
+
const stale = isKueueConditionStale(quota, data.metadata?.generation)
|
|
187
|
+
const parsed = parseFlavorAttempts(quota.message)
|
|
188
|
+
return (
|
|
189
|
+
<Section variant="card" title="Reasons Reported per Flavor">
|
|
190
|
+
{stale && <p className="mb-2 text-xs text-warning-text">Reported for generation {quota.observedGeneration} (now {data.metadata?.generation}); the Workload has changed since.</p>}
|
|
191
|
+
<p className="mb-3 text-xs text-theme-text-tertiary">From the controller-reported message on QuotaReserved. Kueue lists the reasons alphabetically, not in the order it tried flavors, and may not report every flavor.</p>
|
|
192
|
+
{parsed ? (
|
|
193
|
+
<div className="space-y-3">
|
|
194
|
+
{parsed.map(podSet => (
|
|
195
|
+
<div key={podSet.podSet} className="space-y-2">
|
|
196
|
+
<p className="text-xs font-medium text-theme-text-secondary">PodSet {podSet.podSet}</p>
|
|
197
|
+
<ul className="divide-y divide-theme-border">
|
|
198
|
+
{groupByFlavor(podSet.attempts).map(([flavor, attempts]) => (
|
|
199
|
+
<li key={flavor ?? ''} className="space-y-1 py-2 text-sm first:pt-0 last:pb-0">
|
|
200
|
+
<div className="break-all [&_button]:text-left">
|
|
201
|
+
{flavor ? <ResourceLink name={flavor} kind="resourceflavors" group={KUEUE_GROUP} onNavigate={onNavigate} /> : <span className="text-xs text-theme-text-tertiary">No flavor named</span>}
|
|
202
|
+
</div>
|
|
203
|
+
{attempts.map((attempt, index) => (
|
|
204
|
+
<div key={index} className="space-y-0.5 pl-3">
|
|
205
|
+
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
206
|
+
{attempt.resource && <code className="text-theme-text-secondary">{attempt.resource}</code>}
|
|
207
|
+
<span className="text-theme-text-tertiary">{attemptLabel[attempt.kind]}</span>
|
|
208
|
+
</div>
|
|
209
|
+
<p className="whitespace-pre-wrap break-words text-xs text-theme-text-secondary">{attempt.text}</p>
|
|
210
|
+
</div>
|
|
211
|
+
))}
|
|
212
|
+
</li>
|
|
213
|
+
))}
|
|
214
|
+
</ul>
|
|
215
|
+
</div>
|
|
216
|
+
))}
|
|
217
|
+
</div>
|
|
218
|
+
) : (
|
|
219
|
+
<p className="whitespace-pre-wrap break-words text-sm text-theme-text-secondary">{quota.message}</p>
|
|
220
|
+
)}
|
|
221
|
+
</Section>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Badge } from '../ui/Badge'
|
|
3
|
+
import { ResourceLink } from '../ui/drawer-components'
|
|
4
|
+
import { TimeValue } from '../ui/ScheduleValue'
|
|
5
|
+
import { FactGrid, FactRow, FactValue, type Fact } from '../facts'
|
|
6
|
+
import type { StatusBadge } from '../resources/resource-utils'
|
|
7
|
+
import { getAdmissionCheckStatus, getClusterQueueStatus, getKueueQueueCondition, isKueueConditionStale } from '../resources/resource-utils-kueue'
|
|
8
|
+
import { describeKueuePolicy } from './policy'
|
|
9
|
+
import { isDenied, unreadReason, type RelatedRead } from './related-read'
|
|
10
|
+
import { namespaceEligibility } from './eligibility'
|
|
11
|
+
import { KUEUE_GROUP } from './constants'
|
|
12
|
+
|
|
13
|
+
export type Navigate = (ref: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
14
|
+
|
|
15
|
+
/** One related object's name with what Radar's read of it found. Without a read, the name alone. */
|
|
16
|
+
export function ReadState({ read, found }: { read?: RelatedRead; found?: ReactNode }) {
|
|
17
|
+
if (!read) return null
|
|
18
|
+
if (read.state === 'found') return <>{found ?? <Badge severity="neutral" size="sm">Found</Badge>}</>
|
|
19
|
+
if (read.state === 'not_found') return <Badge severity="error" size="sm">Not found</Badge>
|
|
20
|
+
if (read.state === 'unconfirmed') return <span className="text-xs text-theme-text-tertiary">This Radar can’t confirm whether it exists</span>
|
|
21
|
+
return <span className={isDenied(read) ? 'text-xs text-theme-text-secondary' : 'text-xs text-theme-text-tertiary'}>{read.state === 'loading' ? 'Checking…' : `Not read: ${unreadReason(read)}`}</span>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function StatusPill({ status }: { status: StatusBadge }) {
|
|
25
|
+
return <Badge colorClass={status.color} size="sm">{status.text}</Badge>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ReportedActive({ data, status, children }: { data: any; status: StatusBadge; children?: ReactNode }) {
|
|
29
|
+
const condition = getKueueQueueCondition(data)
|
|
30
|
+
const generation = data?.metadata?.generation
|
|
31
|
+
if (!condition) {
|
|
32
|
+
return <div className="space-y-2 text-sm">
|
|
33
|
+
<div className="flex flex-wrap items-center gap-2"><StatusPill status={status} /></div>
|
|
34
|
+
<p className="text-theme-text-secondary">No Active condition reported.</p>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
}
|
|
38
|
+
return <div className="space-y-2 text-sm">
|
|
39
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
40
|
+
<StatusPill status={status} />
|
|
41
|
+
{condition.reason && !status.text.startsWith(condition.reason) && <code className="break-all text-xs text-theme-text-secondary">{condition.reason}</code>}
|
|
42
|
+
</div>
|
|
43
|
+
{condition.message && <p className="whitespace-pre-wrap break-words text-theme-text-primary">{condition.message}</p>}
|
|
44
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
45
|
+
Active={condition.status}
|
|
46
|
+
{condition.lastTransitionTime && <> since <TimeValue timestamp={condition.lastTransitionTime} /></>}
|
|
47
|
+
{' · '}
|
|
48
|
+
{condition.observedGeneration ? `observed generation ${condition.observedGeneration} of ${generation ?? '?'}` : 'observed generation not reported'}
|
|
49
|
+
</p>
|
|
50
|
+
{children}
|
|
51
|
+
</div>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function count(value: unknown, source: string): Fact {
|
|
55
|
+
return typeof value === 'number'
|
|
56
|
+
? { text: String(value), tone: 'neutral', source }
|
|
57
|
+
: { text: 'Not reported', tone: 'unknown', source }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Controller-reported Workload counts. Reserving includes admitted, so they never add up. */
|
|
61
|
+
export function QueueCounts({ data, kind }: { data: any; kind: 'ClusterQueue' | 'LocalQueue' }) {
|
|
62
|
+
const source = `Reported in the ${kind}'s status`
|
|
63
|
+
return <div className="space-y-2">
|
|
64
|
+
<FactGrid>
|
|
65
|
+
<FactRow label="Pending"><FactValue fact={count(data?.status?.pendingWorkloads, source)} /></FactRow>
|
|
66
|
+
<FactRow label="Reserving quota"><FactValue fact={count(data?.status?.reservingWorkloads, source)} /></FactRow>
|
|
67
|
+
<FactRow label="Admitted"><FactValue fact={count(data?.status?.admittedWorkloads, source)} /></FactRow>
|
|
68
|
+
</FactGrid>
|
|
69
|
+
<p className="text-xs text-theme-text-tertiary">Reserving includes admitted Workloads, so these counts are not additive. Admission does not establish that Pods are running.</p>
|
|
70
|
+
</div>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function ConfiguredStop({ data }: { data: any }) {
|
|
74
|
+
const raw = data?.spec?.stopPolicy
|
|
75
|
+
const policy = describeKueuePolicy(data?.apiVersion, 'stopPolicy', raw ?? 'None')
|
|
76
|
+
return <div className="space-y-1 text-sm">
|
|
77
|
+
<div className="flex flex-wrap items-baseline gap-2">
|
|
78
|
+
<span className="text-theme-text-tertiary">Stop policy</span>
|
|
79
|
+
<code className="text-theme-text-primary">{policy.raw}</code>
|
|
80
|
+
{raw == null && <span className="text-xs text-theme-text-tertiary">(not set)</span>}
|
|
81
|
+
</div>
|
|
82
|
+
<p className="text-theme-text-secondary">{policy.meaning ?? `Radar has no description of this value for ${data?.apiVersion ?? 'this API version'}.`}</p>
|
|
83
|
+
</div>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ReportedMessage({ condition, generation }: { condition: any; generation?: number }) {
|
|
87
|
+
return <p className="whitespace-pre-wrap break-words text-xs text-theme-text-secondary">
|
|
88
|
+
{isKueueConditionStale(condition, generation) && <span className="text-theme-text-tertiary">Reported for generation {condition.observedGeneration} (now {generation}): </span>}
|
|
89
|
+
{condition.message}
|
|
90
|
+
</p>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function ClusterQueueReadSummary({ name, read, onNavigate }: { name: string; read?: RelatedRead; onNavigate?: Navigate }) {
|
|
94
|
+
const parent = read?.state === 'found' ? read.object : undefined
|
|
95
|
+
const condition = parent ? getKueueQueueCondition(parent) : undefined
|
|
96
|
+
return <div className="min-w-0 space-y-1 text-sm">
|
|
97
|
+
<div className="flex flex-wrap items-center gap-2 break-all [&_button]:text-left">
|
|
98
|
+
<ResourceLink name={name} kind="clusterqueues" group={KUEUE_GROUP} onNavigate={onNavigate} />
|
|
99
|
+
<ReadState read={read} found={parent ? <StatusPill status={getClusterQueueStatus(parent)} /> : undefined} />
|
|
100
|
+
</div>
|
|
101
|
+
{parent && <p className="text-xs text-theme-text-secondary">
|
|
102
|
+
Read from the ClusterQueue{condition?.reason && condition.status !== 'True' ? `: ${condition.reason}${isKueueConditionStale(condition, parent.metadata?.generation) ? ' (stale)' : ''}` : ''}{parent.spec?.stopPolicy && parent.spec.stopPolicy !== 'None' ? ` · stop policy ${parent.spec.stopPolicy}` : ''}
|
|
103
|
+
</p>}
|
|
104
|
+
{parent && condition?.message && condition.status !== 'True' && <ReportedMessage condition={condition} generation={parent.metadata?.generation} />}
|
|
105
|
+
</div>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether a namespace matches a ClusterQueue's namespaceSelector. The
|
|
110
|
+
* selector is only known from a ClusterQueue that was read; the namespace's
|
|
111
|
+
* labels are needed only when the selector has terms.
|
|
112
|
+
*/
|
|
113
|
+
export function namespaceEligibilityFact({ clusterQueue, parent, namespaceRead }: { clusterQueue: string; parent?: RelatedRead; namespaceRead?: RelatedRead }): Fact {
|
|
114
|
+
const source = `ClusterQueue ${clusterQueue} namespaceSelector`
|
|
115
|
+
if (parent?.state !== 'found') {
|
|
116
|
+
const why = !parent ? 'Unknown: the ClusterQueue was not read'
|
|
117
|
+
: parent.state === 'loading' ? 'Checking the ClusterQueue…'
|
|
118
|
+
: parent.state === 'not_found' ? 'Unknown: the ClusterQueue was not found'
|
|
119
|
+
: `Unknown: ClusterQueue not read (${unreadReason(parent)})`
|
|
120
|
+
return { text: why, tone: 'unknown', source }
|
|
121
|
+
}
|
|
122
|
+
const selector = parent.object?.spec?.namespaceSelector
|
|
123
|
+
const labels = namespaceRead?.state === 'found' ? (namespaceRead.object?.metadata?.labels ?? {}) : undefined
|
|
124
|
+
const result = namespaceEligibility(selector, labels)
|
|
125
|
+
switch (result.state) {
|
|
126
|
+
case 'eligible':
|
|
127
|
+
return { text: result.basis === 'all' ? 'Eligible: the selector is empty ({}), so every namespace matches' : 'Eligible: its labels match the selector', tone: 'healthy', source }
|
|
128
|
+
case 'not_eligible':
|
|
129
|
+
return { text: result.basis === 'none' ? 'Not eligible: the ClusterQueue sets no namespaceSelector, so no namespace matches' : 'Not eligible: its labels don’t match the selector', tone: 'unhealthy', source }
|
|
130
|
+
default:
|
|
131
|
+
if (result.why === 'unsupported_operator') return { text: `Unknown: the selector uses operator ${result.operator || '(none)'}, which Radar doesn't evaluate`, tone: 'unknown', source }
|
|
132
|
+
if (namespaceRead?.state === 'loading') return { text: 'Checking the namespace…', tone: 'unknown', source }
|
|
133
|
+
return { text: namespaceRead?.state === 'not_found' ? 'Unknown: the Namespace was not found' : `Unknown: its labels weren’t read (${unreadReason(namespaceRead)})`, tone: 'unknown', source }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function AdmissionCheckRow({ name, onFlavors, read, onNavigate }: { name: string; onFlavors?: string[]; read?: RelatedRead; onNavigate?: Navigate }) {
|
|
138
|
+
const check = read?.state === 'found' ? read.object : undefined
|
|
139
|
+
const condition = check ? getKueueQueueCondition(check) : undefined
|
|
140
|
+
return <div className="min-w-0 space-y-1 text-sm">
|
|
141
|
+
<div className="flex flex-wrap items-center gap-2 break-all [&_button]:text-left">
|
|
142
|
+
<ResourceLink name={name} kind="admissionchecks" group={KUEUE_GROUP} onNavigate={onNavigate} />
|
|
143
|
+
<ReadState read={read} found={check ? <StatusPill status={getAdmissionCheckStatus(check)} /> : undefined} />
|
|
144
|
+
</div>
|
|
145
|
+
<p className="break-words text-xs text-theme-text-secondary">{onFlavors?.length ? `Applies to flavors: ${onFlavors.join(', ')}` : 'Applies to all flavors'}</p>
|
|
146
|
+
{condition?.message && condition.status !== 'True' && <ReportedMessage condition={condition} generation={check.metadata?.generation} />}
|
|
147
|
+
</div>
|
|
148
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const KUEUE_GROUP = 'kueue.x-k8s.io'
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Whether a namespace may submit Workloads to a ClusterQueue, from the
|
|
2
|
+
// ClusterQueue's namespaceSelector. Kueue reads a null selector as "no
|
|
3
|
+
// namespace" and an empty one ({}) as "every namespace", so only a selector
|
|
4
|
+
// with terms needs the namespace's labels.
|
|
5
|
+
|
|
6
|
+
export type NamespaceEligibility =
|
|
7
|
+
| { state: 'eligible'; basis: 'all' | 'labels' }
|
|
8
|
+
| { state: 'not_eligible'; basis: 'none' | 'labels' }
|
|
9
|
+
/** The labels are needed and weren't read, or the selector can't be evaluated. */
|
|
10
|
+
| { state: 'unknown'; why: 'labels_unread' | 'unsupported_operator'; operator?: string }
|
|
11
|
+
|
|
12
|
+
interface Requirement { key?: string; operator?: string; values?: string[] }
|
|
13
|
+
export interface LabelSelector { matchLabels?: Record<string, string>; matchExpressions?: Requirement[] }
|
|
14
|
+
|
|
15
|
+
function selectorHasTerms(selector: LabelSelector | null | undefined): boolean {
|
|
16
|
+
return !!selector && (Object.keys(selector.matchLabels ?? {}).length > 0 || (selector.matchExpressions?.length ?? 0) > 0)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Evaluating the selector would use the namespace's labels (it has terms, all with supported operators). */
|
|
20
|
+
export function needsNamespaceLabels(selector: LabelSelector | null | undefined): boolean {
|
|
21
|
+
const result = namespaceEligibility(selector, undefined)
|
|
22
|
+
return result.state === 'unknown' && result.why === 'labels_unread'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** labels is undefined when the namespace's labels weren't read. */
|
|
26
|
+
export function namespaceEligibility(selector: LabelSelector | null | undefined, labels: Record<string, string> | undefined): NamespaceEligibility {
|
|
27
|
+
if (selector == null) return { state: 'not_eligible', basis: 'none' }
|
|
28
|
+
if (!selectorHasTerms(selector)) return { state: 'eligible', basis: 'all' }
|
|
29
|
+
const unsupported = (selector.matchExpressions ?? []).find(term => !['In', 'NotIn', 'Exists', 'DoesNotExist'].includes(term?.operator ?? ''))
|
|
30
|
+
if (unsupported) return { state: 'unknown', why: 'unsupported_operator', operator: String(unsupported?.operator ?? '') }
|
|
31
|
+
if (!labels) return { state: 'unknown', why: 'labels_unread' }
|
|
32
|
+
const has = (key: string) => Object.prototype.hasOwnProperty.call(labels, key)
|
|
33
|
+
const matches = Object.entries(selector.matchLabels ?? {}).every(([key, value]) => has(key) && labels[key] === value)
|
|
34
|
+
&& (selector.matchExpressions ?? []).every(({ key = '', operator, values = [] }) => {
|
|
35
|
+
switch (operator) {
|
|
36
|
+
case 'In': return has(key) && values.includes(labels[key])
|
|
37
|
+
case 'NotIn': return !has(key) || !values.includes(labels[key])
|
|
38
|
+
case 'Exists': return has(key)
|
|
39
|
+
default: return !has(key)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
return matches ? { state: 'eligible', basis: 'labels' } : { state: 'not_eligible', basis: 'labels' }
|
|
43
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Parses the flavor attempts out of a pending Workload's QuotaReserved
|
|
2
|
+
// message. Kueue builds it as "couldn't assign flavors to pod set <ps>: <reasons>"
|
|
3
|
+
// per failing pod set, joined by "; ", with each pod set's reasons sorted
|
|
4
|
+
// alphabetically and joined by ", " — so the order of the reasons is not the
|
|
5
|
+
// order flavors were tried. A message with any part this parser doesn't
|
|
6
|
+
// recognise yields null and is shown verbatim instead.
|
|
7
|
+
|
|
8
|
+
export type FlavorAttemptKind =
|
|
9
|
+
| 'insufficient_quota'
|
|
10
|
+
| 'insufficient_unused_quota'
|
|
11
|
+
| 'untolerated_taint'
|
|
12
|
+
| 'node_affinity'
|
|
13
|
+
| 'flavor_not_found'
|
|
14
|
+
| 'resource_unavailable'
|
|
15
|
+
| 'skipped_by_annotation'
|
|
16
|
+
| 'pinned_by_original'
|
|
17
|
+
|
|
18
|
+
export interface FlavorAttempt {
|
|
19
|
+
kind: FlavorAttemptKind
|
|
20
|
+
flavor?: string
|
|
21
|
+
resource?: string
|
|
22
|
+
/** The controller's own words for this reason. */
|
|
23
|
+
text: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PodSetAttempts {
|
|
27
|
+
podSet: string
|
|
28
|
+
attempts: FlavorAttempt[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const podSetPrefix = "couldn't assign flavors to pod set "
|
|
32
|
+
|
|
33
|
+
/** The message is Kueue's per-flavor account of why quota wasn't assigned (parsed or not). Its error form isn't. */
|
|
34
|
+
export function isFlavorAssignmentMessage(message: string | undefined): boolean {
|
|
35
|
+
return !!message && message.startsWith(podSetPrefix)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const patterns: { kind: FlavorAttemptKind; re: RegExp; flavor?: number; resource?: number }[] = [
|
|
39
|
+
{ kind: 'insufficient_unused_quota', re: /^insufficient unused quota for (\S+) in flavor (\S+), \S+ more needed$/, resource: 1, flavor: 2 },
|
|
40
|
+
{ kind: 'insufficient_quota', re: /^insufficient quota for (\S+) in flavor (\S+), (?:previously considered podsets requests \(\S+\) \+ current podset request \(\S+\) > maximum capacity \(\S+\)|request > maximum capacity \(\S+ > \S+\))$/, resource: 1, flavor: 2 },
|
|
41
|
+
{ kind: 'untolerated_taint', re: /^untolerated taint .+ in flavor (\S+)$/, flavor: 1 },
|
|
42
|
+
{ kind: 'node_affinity', re: /^flavor (\S+) doesn't match node affinity$/, flavor: 1 },
|
|
43
|
+
{ kind: 'flavor_not_found', re: /^flavor (\S+) not found$/, flavor: 1 },
|
|
44
|
+
{ kind: 'resource_unavailable', re: /^resource (\S+) unavailable in ClusterQueue$/, resource: 1 },
|
|
45
|
+
{ kind: 'skipped_by_annotation', re: /^skipping flavor (\S+) due to WorkloadAllowedResourceFlavorAnnotation annotation$/, flavor: 1 },
|
|
46
|
+
// Names the candidate flavor, then the flavor the original Workload holds.
|
|
47
|
+
{ kind: 'pinned_by_original', re: /^could not assign (\S+) flavor since the original workload is assigned: \S+$/, flavor: 1 },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
// A reason begins only at one of these; ", " inside a reason ("…, 2 more needed")
|
|
51
|
+
// is not a boundary.
|
|
52
|
+
const reasonStart = /, (?=insufficient (?:unused )?quota for |untolerated taint |flavor \S+ (?:doesn't match node affinity|not found)|resource \S+ unavailable in ClusterQueue|skipping flavor |could not assign )/
|
|
53
|
+
|
|
54
|
+
function parseReason(text: string): FlavorAttempt | null {
|
|
55
|
+
for (const { kind, re, flavor, resource } of patterns) {
|
|
56
|
+
const match = re.exec(text)
|
|
57
|
+
if (match) return { kind, text, ...(flavor ? { flavor: match[flavor] } : {}), ...(resource ? { resource: match[resource] } : {}) }
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseFlavorAttempts(message: string | undefined): PodSetAttempts[] | null {
|
|
63
|
+
if (!message?.startsWith(podSetPrefix)) return null
|
|
64
|
+
const result: PodSetAttempts[] = []
|
|
65
|
+
for (const part of message.slice(podSetPrefix.length).split(`; ${podSetPrefix}`)) {
|
|
66
|
+
const separator = part.indexOf(': ')
|
|
67
|
+
if (separator <= 0) return null
|
|
68
|
+
const podSet = part.slice(0, separator)
|
|
69
|
+
const reasons = part.slice(separator + 2).split(reasonStart)
|
|
70
|
+
const attempts: FlavorAttempt[] = []
|
|
71
|
+
for (const reason of reasons) {
|
|
72
|
+
const attempt = parseReason(reason)
|
|
73
|
+
if (!attempt) return null
|
|
74
|
+
attempts.push(attempt)
|
|
75
|
+
}
|
|
76
|
+
result.push({ podSet, attempts })
|
|
77
|
+
}
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { parseFlavorAttempts } from './flavors-tried'
|
|
3
|
+
import { namespaceEligibility } from './eligibility'
|
|
4
|
+
import { describeKueuePolicy } from './policy'
|
|
5
|
+
import { isDenied, unreadReason } from './related-read'
|
|
6
|
+
|
|
7
|
+
describe('parseFlavorAttempts', () => {
|
|
8
|
+
it('parses the Kueue v0.18 message seen on a live 3-CPU Workload into both attempts', () => {
|
|
9
|
+
const message = "couldn't assign flavors to pod set main: insufficient quota for cpu in flavor gpu-night-813-small, previously considered podsets requests (0) + current podset request (3) > maximum capacity (1), insufficient quota for cpu in flavor gpu-night-813-spot, previously considered podsets requests (0) + current podset request (3) > maximum capacity (1)"
|
|
10
|
+
expect(parseFlavorAttempts(message)).toEqual([{ podSet: 'main', attempts: [
|
|
11
|
+
{ kind: 'insufficient_quota', resource: 'cpu', flavor: 'gpu-night-813-small', text: 'insufficient quota for cpu in flavor gpu-night-813-small, previously considered podsets requests (0) + current podset request (3) > maximum capacity (1)' },
|
|
12
|
+
{ kind: 'insufficient_quota', resource: 'cpu', flavor: 'gpu-night-813-spot', text: 'insufficient quota for cpu in flavor gpu-night-813-spot, previously considered podsets requests (0) + current podset request (3) > maximum capacity (1)' },
|
|
13
|
+
] }])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('splits pod sets and every reason format, keeping commas inside a reason', () => {
|
|
17
|
+
const message = "couldn't assign flavors to pod set driver: flavor a doesn't match node affinity, insufficient unused quota for nvidia.com/gpu in flavor b, 2 more needed, untolerated taint gpu=true:NoSchedule in flavor c; couldn't assign flavors to pod set workers: flavor z not found, resource example.com/fpga unavailable in ClusterQueue, skipping flavor y due to WorkloadAllowedResourceFlavorAnnotation annotation, could not assign x flavor since the original workload is assigned: w"
|
|
18
|
+
const parsed = parseFlavorAttempts(message)
|
|
19
|
+
expect(parsed?.map(p => p.podSet)).toEqual(['driver', 'workers'])
|
|
20
|
+
expect(parsed?.[0].attempts.map(a => [a.kind, a.flavor, a.resource])).toEqual([
|
|
21
|
+
['node_affinity', 'a', undefined], ['insufficient_unused_quota', 'b', 'nvidia.com/gpu'], ['untolerated_taint', 'c', undefined],
|
|
22
|
+
])
|
|
23
|
+
expect(parsed?.[1].attempts.map(a => [a.kind, a.flavor, a.resource])).toEqual([
|
|
24
|
+
['flavor_not_found', 'z', undefined], ['resource_unavailable', undefined, 'example.com/fpga'], ['skipped_by_annotation', 'y', undefined], ['pinned_by_original', 'x', undefined],
|
|
25
|
+
])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns null for anything it cannot account for, so the caller shows the message verbatim', () => {
|
|
29
|
+
for (const message of [
|
|
30
|
+
undefined,
|
|
31
|
+
'',
|
|
32
|
+
"LocalQueue no-such-localqueue doesn't exist",
|
|
33
|
+
"Workload namespace doesn't match ClusterQueue selector",
|
|
34
|
+
'failed to assign flavors to pod set main: boom',
|
|
35
|
+
"couldn't assign flavors to pod set main: topology \"rack\" doesn't allow to fit any of 3 pod(s)",
|
|
36
|
+
"couldn't assign flavors to pod set main: insufficient quota for cpu in flavor a, request > maximum capacity (24 > 8), something new",
|
|
37
|
+
"couldn't assign flavors to pod set main",
|
|
38
|
+
]) expect(parseFlavorAttempts(message)).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('accepts an older quota detail shape as long as the reason prefix is known', () => {
|
|
42
|
+
expect(parseFlavorAttempts("couldn't assign flavors to pod set main: insufficient quota for cpu in flavor default, request > maximum capacity (24 > 8)")?.[0].attempts[0].flavor).toBe('default')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('namespaceEligibility', () => {
|
|
47
|
+
it('reads null as no namespace and {} as every namespace without needing labels', () => {
|
|
48
|
+
expect(namespaceEligibility(null, undefined)).toEqual({ state: 'not_eligible', basis: 'none' })
|
|
49
|
+
expect(namespaceEligibility(undefined, undefined)).toEqual({ state: 'not_eligible', basis: 'none' })
|
|
50
|
+
expect(namespaceEligibility({}, undefined)).toEqual({ state: 'eligible', basis: 'all' })
|
|
51
|
+
expect(namespaceEligibility({ matchLabels: {}, matchExpressions: [] }, undefined)).toEqual({ state: 'eligible', basis: 'all' })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('needs labels for a selector with terms, and says so when they were not read', () => {
|
|
55
|
+
expect(namespaceEligibility({ matchLabels: { team: 'a' } }, undefined)).toEqual({ state: 'unknown', why: 'labels_unread' })
|
|
56
|
+
expect(namespaceEligibility({ matchLabels: { team: 'a' } }, { team: 'a' }).state).toBe('eligible')
|
|
57
|
+
expect(namespaceEligibility({ matchLabels: { team: 'a' } }, { team: 'b' }).state).toBe('not_eligible')
|
|
58
|
+
expect(namespaceEligibility({ matchLabels: { team: 'a' } }, {}).state).toBe('not_eligible')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('evaluates every selector operator as Kubernetes does', () => {
|
|
62
|
+
const sel = (operator: string, values?: string[]) => ({ matchExpressions: [{ key: 'tier', operator, values }] })
|
|
63
|
+
expect(namespaceEligibility(sel('In', ['gpu']), { tier: 'gpu' }).state).toBe('eligible')
|
|
64
|
+
expect(namespaceEligibility(sel('In', ['gpu']), {}).state).toBe('not_eligible')
|
|
65
|
+
expect(namespaceEligibility(sel('NotIn', ['gpu']), {}).state).toBe('eligible')
|
|
66
|
+
expect(namespaceEligibility(sel('NotIn', ['gpu']), { tier: 'gpu' }).state).toBe('not_eligible')
|
|
67
|
+
expect(namespaceEligibility(sel('Exists'), { tier: '' }).state).toBe('eligible')
|
|
68
|
+
expect(namespaceEligibility(sel('DoesNotExist'), { tier: '' }).state).toBe('not_eligible')
|
|
69
|
+
expect(namespaceEligibility(sel('Gt', ['1']), { tier: '2' })).toEqual({ state: 'unknown', why: 'unsupported_operator', operator: 'Gt' })
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('describeKueuePolicy', () => {
|
|
74
|
+
it('keeps a dictionary per API version and shows unknown values raw', () => {
|
|
75
|
+
expect(describeKueuePolicy('kueue.x-k8s.io/v1beta1', 'flavorFungibility.whenCanBorrow', 'Borrow').meaning).toContain('Equivalent to MayStopSearch in newer Kueue')
|
|
76
|
+
expect(describeKueuePolicy('kueue.x-k8s.io/v1beta2', 'flavorFungibility.whenCanBorrow', 'Borrow')).toEqual({ raw: 'Borrow' })
|
|
77
|
+
expect(describeKueuePolicy('kueue.x-k8s.io/v1beta2', 'admissionScope.admissionMode', 'NoAdmissionFairSharing').meaning).toBeTruthy()
|
|
78
|
+
expect(describeKueuePolicy('kueue.x-k8s.io/v1', 'stopPolicy', 'Hold')).toEqual({ raw: 'Hold' })
|
|
79
|
+
expect(describeKueuePolicy('kueue.x-k8s.io/v1beta2', 'stopPolicy', 'Hold').meaning).toContain('admitted Workloads run to completion')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('related reads', () => {
|
|
84
|
+
it('tells a denial apart from a read that failed for another reason', () => {
|
|
85
|
+
expect(isDenied({ state: 'unavailable', code: 'user_forbidden' })).toBe(true)
|
|
86
|
+
expect(isDenied({ state: 'unavailable', code: 'kind_sync_timed_out' })).toBe(false)
|
|
87
|
+
expect(isDenied({ state: 'not_found' })).toBe(false)
|
|
88
|
+
expect(unreadReason({ state: 'unavailable', code: 'user_forbidden' })).toBe('you can’t read it')
|
|
89
|
+
expect(unreadReason({ state: 'unavailable', code: 'radar_forbidden' })).toBe('Radar can’t read it')
|
|
90
|
+
expect(unreadReason({ state: 'unavailable', code: 'rbac_denied' })).toBe('you can’t read it')
|
|
91
|
+
expect(unreadReason({ state: 'loading' })).toBe('checking…')
|
|
92
|
+
expect(unreadReason({ state: 'unavailable' })).toBe('the read failed')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('policy dictionaries against the served CRDs', () => {
|
|
97
|
+
it('describe every enum value Kueue v0.18 serves, per version', async () => {
|
|
98
|
+
const { enums } = (await import('./testdata/kueue-v0.18-policy-enums.json')).default as { enums: Record<string, Record<string, string[]>> }
|
|
99
|
+
for (const [version, fields] of Object.entries(enums)) {
|
|
100
|
+
for (const [field, values] of Object.entries(fields)) {
|
|
101
|
+
for (const value of values) expect(describeKueuePolicy(version, field as any, value).meaning, `${version} ${field}=${value}`).toBeTruthy()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
})
|