@skyhook-io/k8s-ui 1.14.1 → 1.14.2
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 +1 -1
- package/src/components/issues/IssuesView.tsx +1 -1
- package/src/components/issues/types.ts +2 -0
- package/src/components/resources/ResourcesView.tsx +129 -67
- package/src/components/resources/column-settings-merge.test.ts +104 -0
- package/src/components/resources/istio-gateway-filter.test.ts +5 -3
- package/src/components/resources/istio-policy-accessors.test.ts +193 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +11 -24
- package/src/components/resources/renderers/eso-cells.tsx +0 -5
- package/src/components/resources/renderers/istio-cells.tsx +36 -26
- package/src/components/resources/renderers/kueue-cells.tsx +0 -9
- package/src/components/resources/renderers/kyverno-cells.tsx +1 -1
- package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
- package/src/components/resources/renderers/ray-cells.tsx +12 -4
- package/src/components/resources/renderers/volcano-cells.tsx +0 -9
- package/src/components/resources/resource-utils-cnpg.ts +5 -0
- package/src/components/resources/resource-utils-eso.test.ts +56 -0
- package/src/components/resources/resource-utils-eso.ts +37 -5
- package/src/components/resources/resource-utils-istio.ts +152 -44
- package/src/components/resources/resource-utils-kyverno.ts +3 -6
- package/src/components/resources/resource-utils-prometheus.ts +51 -23
- package/src/components/resources/resource-utils-ray.test.ts +36 -0
- package/src/components/resources/resource-utils-ray.ts +6 -0
- package/src/components/resources/resource-utils.ts +166 -18
- package/src/components/resources/status-accessors.test.ts +363 -0
- package/src/components/resources/status-overclaim.test.ts +158 -0
- package/src/components/timeline/DiffViewer.test.tsx +26 -0
- package/src/components/timeline/DiffViewer.tsx +16 -12
- package/src/components/topology/TopologyGraph.tsx +7 -2
- package/src/components/ui/Collapse.tsx +9 -2
- package/src/components/ui/ConfirmDialog.test.tsx +37 -0
- package/src/components/ui/ConfirmDialog.tsx +3 -1
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +2 -1
|
@@ -15,7 +15,7 @@ import { getPolicyReportStatus as _getPolicyReportStatus, getKyvernoPolicyStatus
|
|
|
15
15
|
import { getResourceClaimStatus as _getResourceClaimStatus, getResourceClaimDeviceClasses as _getResourceClaimDeviceClasses, getResourceClaimTemplateDeviceClasses as _getResourceClaimTemplateDeviceClasses, getResourceClaimAllocation as _getResourceClaimAllocation, getResourceClaimReservedFor as _getResourceClaimReservedFor } from './resource-utils-dra'
|
|
16
16
|
import { getNvidiaClusterPolicyStatus as _getNvidiaClusterPolicyStatus, getNvidiaClusterPolicyEnabledComponents as _getNvidiaClusterPolicyEnabledComponents, getNvidiaDriverStatus as _getNvidiaDriverStatus } from './resource-utils-nvidia'
|
|
17
17
|
import { getClusterQueueStatus as _getClusterQueueStatus, getLocalQueueStatus as _getLocalQueueStatus, getKueueWorkloadStatus as _getKueueWorkloadStatus, getResourceFlavorStatus as _getResourceFlavorStatus, getAdmissionCheckStatus as _getAdmissionCheckStatus, getProvisioningRequestStatus as _getProvisioningRequestStatus } from './resource-utils-kueue'
|
|
18
|
-
import { getRayClusterStatus as _getRayClusterStatus, getRayJobStatus as _getRayJobStatus, getRayServiceStatus as _getRayServiceStatus, getRayCronJobStatus as _getRayCronJobStatus } from './resource-utils-ray'
|
|
18
|
+
import { getRayClusterStatus as _getRayClusterStatus, getRayJobStatus as _getRayJobStatus, getRayServiceStatus as _getRayServiceStatus, getRayCronJobStatus as _getRayCronJobStatus, getRayCronJobTimeZone as _getRayCronJobTimeZone } from './resource-utils-ray'
|
|
19
19
|
import { getLeaderWorkerSetStatus as _getLeaderWorkerSetStatus, getJobSetStatus as _getJobSetStatus } from './resource-utils-jobset-lws'
|
|
20
20
|
import { getInferenceServiceStatus as _getInferenceServiceStatus, getServingRuntimeStatus as _getServingRuntimeStatus, getInferenceGraphStatus as _getInferenceGraphStatus, getTrainedModelStatus as _getTrainedModelStatus, getLLMInferenceServiceStatus as _getLLMInferenceServiceStatus } from './resource-utils-kserve'
|
|
21
21
|
import { getInferencePoolStatus as _getInferencePoolStatus, getInferenceObjectiveStatus as _getInferenceObjectiveStatus } from './resource-utils-inference-gateway'
|
|
@@ -30,8 +30,8 @@ import { getExternalSecretStatus as _getExternalSecretStatus, getClusterExternal
|
|
|
30
30
|
import { getHPATableState, hpaStatusFromState } from './resource-utils-hpa'
|
|
31
31
|
import { getCNPGClusterStatus as _getCNPGClusterStatus, getCNPGBackupStatus as _getCNPGBackupStatus, getCNPGScheduledBackupStatus as _getCNPGScheduledBackupStatus, getCNPGPoolerStatus as _getCNPGPoolerStatus, isApiGroup as _isApiGroup, CNPG_GROUP as _CNPG_GROUP } from './resource-utils-cnpg'
|
|
32
32
|
import { getGenericResourceStatus } from './generic-status'
|
|
33
|
-
import { getIstioGatewayStatus as _getIstioGatewayStatus, getIstioGatewayServerCount as _getIstioGatewayServerCount, getIstioGatewaySelectorString as _getIstioGatewaySelectorString } from './resource-utils-istio'
|
|
34
|
-
import { getCalicoIPPoolAllowedUses, getCalicoIPPoolBlockSize, getCalicoIPPoolEncapsulation, getCalicoPolicyNamespaceSelector, getCalicoPolicyServiceAccountSelector, getCalicoPolicyTypes, isCalicoApiVersion, isCalicoPolicyResource } from './resource-utils-calico'
|
|
33
|
+
import { getIstioGatewayStatus as _getIstioGatewayStatus, getIstioGatewayServerCount as _getIstioGatewayServerCount, getIstioGatewaySelectorString as _getIstioGatewaySelectorString, getAuthorizationPolicySelectorString as _getAuthorizationPolicySelectorString, getDestinationRuleTlsMode as _getDestinationRuleTlsMode } from './resource-utils-istio'
|
|
34
|
+
import { formatKubernetesLabelSelector, getCalicoIPPoolAllowedUses, getCalicoIPPoolBlockSize, getCalicoIPPoolEncapsulation, getCalicoPolicyNamespaceSelector, getCalicoPolicyServiceAccountSelector, getCalicoPolicyTypes, isCalicoApiVersion, isCalicoPolicyResource } from './resource-utils-calico'
|
|
35
35
|
|
|
36
36
|
// ============================================================================
|
|
37
37
|
// STATUS & HEALTH UTILITIES
|
|
@@ -1861,21 +1861,145 @@ export function getGatewayClassDescription(gc: any): string {
|
|
|
1861
1861
|
// GATEWAY API ROUTE UTILITIES (shared by HTTPRoute, GRPCRoute, TCPRoute, TLSRoute)
|
|
1862
1862
|
// ============================================================================
|
|
1863
1863
|
|
|
1864
|
+
const GATEWAY_API_GROUP = 'gateway.networking.k8s.io'
|
|
1865
|
+
|
|
1866
|
+
/**
|
|
1867
|
+
* Whether a status report describes a parent the spec still asks for.
|
|
1868
|
+
*
|
|
1869
|
+
* A controller can leave a report behind after its parentRef is removed, and
|
|
1870
|
+
* the defaults matter: an unset group/kind/namespace on either side means
|
|
1871
|
+
* gateway.networking.k8s.io, Gateway, and the route's own namespace.
|
|
1872
|
+
*/
|
|
1873
|
+
function isReportForParentRef(ref: any, reported: any, routeNamespace: string): boolean {
|
|
1874
|
+
const norm = (r: any) => ({
|
|
1875
|
+
group: r?.group ?? GATEWAY_API_GROUP,
|
|
1876
|
+
kind: r?.kind ?? 'Gateway',
|
|
1877
|
+
namespace: r?.namespace ?? routeNamespace,
|
|
1878
|
+
name: r?.name,
|
|
1879
|
+
sectionName: r?.sectionName,
|
|
1880
|
+
port: r?.port,
|
|
1881
|
+
})
|
|
1882
|
+
const a = norm(ref)
|
|
1883
|
+
const b = norm(reported)
|
|
1884
|
+
return a.group === b.group && a.kind === b.kind && a.namespace === b.namespace &&
|
|
1885
|
+
a.name === b.name && a.sectionName === b.sectionName && a.port === b.port
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1864
1888
|
// All Gateway API route types share the same status/parents/rules/hostnames structure
|
|
1889
|
+
/**
|
|
1890
|
+
* A route's verdict, from BOTH conditions the API guarantees it carries.
|
|
1891
|
+
*
|
|
1892
|
+
* Accepted alone is not health: a backendRef naming a Service that does not
|
|
1893
|
+
* exist is Accepted=True by design, because the gateway still has to answer the
|
|
1894
|
+
* request — with a 5xx. Reading only Accepted therefore reported the "why is my
|
|
1895
|
+
* route 503ing" case as healthy, beside a Backends column naming the Service
|
|
1896
|
+
* that is missing. An ABSENT ResolvedRefs is not healthy either: GEP-1364 has
|
|
1897
|
+
* routes always carry both, so a missing one means nothing has confirmed the
|
|
1898
|
+
* refs yet.
|
|
1899
|
+
*/
|
|
1900
|
+
/**
|
|
1901
|
+
* The reports that describe parents this route is actually attached to, plus
|
|
1902
|
+
* the readers the verdict uses. Shared with getRouteStatusReason so the badge
|
|
1903
|
+
* and its explanation can never disagree about which parents counted.
|
|
1904
|
+
*/
|
|
1905
|
+
function routeParentEvidence(route: any) {
|
|
1906
|
+
const refs = route.spec?.parentRefs || []
|
|
1907
|
+
const reports = route.status?.parents || []
|
|
1908
|
+
const routeNamespace = route.metadata?.namespace || ''
|
|
1909
|
+
const generation = route.metadata?.generation
|
|
1910
|
+
|
|
1911
|
+
// Status entries are identified by parentRef AND controllerName, so one
|
|
1912
|
+
// parent can carry reports from two controllers while one replaces the other.
|
|
1913
|
+
// All of them count: reading a single report would make the verdict depend on
|
|
1914
|
+
// their order in the array.
|
|
1915
|
+
const reportsFor = (ref: any) =>
|
|
1916
|
+
reports.filter((p: any) => isReportForParentRef(ref, p?.parentRef, routeNamespace))
|
|
1917
|
+
const perRef = refs.map(reportsFor)
|
|
1918
|
+
|
|
1919
|
+
// A route can also attach to default Gateways without naming them, in which
|
|
1920
|
+
// case the attachment exists only in status. Those reports are real and their
|
|
1921
|
+
// failures must count. Without that opt-in an unmatched report is a leftover
|
|
1922
|
+
// from a parentRef the spec no longer names, and counting it would resurrect
|
|
1923
|
+
// a verdict for a parent the route has left.
|
|
1924
|
+
const useDefaultGateways = route.spec?.useDefaultGateways
|
|
1925
|
+
const attachedByDefault = useDefaultGateways !== undefined && useDefaultGateways !== 'None'
|
|
1926
|
+
const claimed = new Set(perRef.flat())
|
|
1927
|
+
// Only a Gateway can be a default parent. An unmatched report for anything
|
|
1928
|
+
// else — a mesh Service parent the spec has dropped — is obsolete by
|
|
1929
|
+
// definition, and would otherwise keep voting forever if its controller is
|
|
1930
|
+
// gone.
|
|
1931
|
+
const isDefaultGatewayReport = (p: any) =>
|
|
1932
|
+
(p?.parentRef?.group ?? GATEWAY_API_GROUP) === GATEWAY_API_GROUP &&
|
|
1933
|
+
(p?.parentRef?.kind ?? 'Gateway') === 'Gateway'
|
|
1934
|
+
const considered = attachedByDefault
|
|
1935
|
+
? [...perRef.flat(), ...reports.filter((p: any) => !claimed.has(p) && isDefaultGatewayReport(p))]
|
|
1936
|
+
: perRef.flat()
|
|
1937
|
+
|
|
1938
|
+
// A condition observed against a superseded generation describes a spec that
|
|
1939
|
+
// has since changed, so it cannot confirm the current one. A condition with no
|
|
1940
|
+
// observedGeneration at all is taken at face value.
|
|
1941
|
+
const isCurrent = (c: any) =>
|
|
1942
|
+
c?.observedGeneration === undefined || generation === undefined || c.observedGeneration === generation
|
|
1943
|
+
const conditionOf = (report: any, type: string) =>
|
|
1944
|
+
(report?.conditions || []).filter((c: any) => c?.type === type && isCurrent(c)).pop()
|
|
1945
|
+
const statusOf = (report: any, type: string) => conditionOf(report, type)?.status
|
|
1946
|
+
|
|
1947
|
+
// A report whose conditions all describe a superseded spec says nothing about
|
|
1948
|
+
// the current one, either way — so it neither votes nor confirms the parent it
|
|
1949
|
+
// belongs to. Requiring it to agree would let one leftover from a replaced
|
|
1950
|
+
// controller hold a live healthy parent at Pending; counting it as
|
|
1951
|
+
// confirmation would let the other parents speak for one nothing has
|
|
1952
|
+
// confirmed.
|
|
1953
|
+
const speaksToCurrentSpec = (r: any) =>
|
|
1954
|
+
conditionOf(r, 'Accepted') !== undefined || conditionOf(r, 'ResolvedRefs') !== undefined
|
|
1955
|
+
const current = considered.filter(speaksToCurrentSpec)
|
|
1956
|
+
const everyRefConfirmed = perRef.every((list: any[]) => list.some(speaksToCurrentSpec))
|
|
1957
|
+
|
|
1958
|
+
return { considered, current, everyRefConfirmed, conditionOf, statusOf }
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1865
1961
|
export function getRouteStatus(route: any): StatusBadge {
|
|
1866
|
-
const
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
)
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
)
|
|
1874
|
-
|
|
1875
|
-
|
|
1962
|
+
const { considered, current, everyRefConfirmed, statusOf } = routeParentEvidence(route)
|
|
1963
|
+
|
|
1964
|
+
if (considered.length === 0) return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
1965
|
+
if (current.length === 0) return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
|
|
1966
|
+
const anyFailure = current.some((r: any) =>
|
|
1967
|
+
statusOf(r, 'Accepted') === 'False' || statusOf(r, 'ResolvedRefs') === 'False')
|
|
1968
|
+
|
|
1969
|
+
if (everyRefConfirmed && current.every((r: any) => statusOf(r, 'Accepted') === 'False')) {
|
|
1970
|
+
return { text: 'Not Accepted', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
1971
|
+
}
|
|
1972
|
+
if (anyFailure) {
|
|
1973
|
+
return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
|
|
1974
|
+
}
|
|
1975
|
+
if (everyRefConfirmed && current.every((r: any) =>
|
|
1976
|
+
statusOf(r, 'Accepted') === 'True' && statusOf(r, 'ResolvedRefs') === 'True')) {
|
|
1977
|
+
return { text: 'Accepted', color: healthColors.healthy, level: 'healthy' }
|
|
1978
|
+
}
|
|
1876
1979
|
return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
|
|
1877
1980
|
}
|
|
1878
1981
|
|
|
1982
|
+
/**
|
|
1983
|
+
* Why the badge says what it says: which parent is unhappy, and the controller's
|
|
1984
|
+
* own reason. Without it "Degraded" tells an operator something is wrong and
|
|
1985
|
+
* nothing about what — and the whole point of reading ResolvedRefs is that the
|
|
1986
|
+
* answer is usually "the backend you named does not exist".
|
|
1987
|
+
*/
|
|
1988
|
+
export function getRouteStatusReason(route: any): string {
|
|
1989
|
+
const { current, conditionOf } = routeParentEvidence(route)
|
|
1990
|
+
const parts: string[] = []
|
|
1991
|
+
for (const report of current) {
|
|
1992
|
+
const name = report?.parentRef?.name || 'parent'
|
|
1993
|
+
for (const type of ['Accepted', 'ResolvedRefs']) {
|
|
1994
|
+
const c = conditionOf(report, type)
|
|
1995
|
+
if (c?.status !== 'False') continue
|
|
1996
|
+
const detail = [c.reason, c.message].filter(Boolean).join(': ')
|
|
1997
|
+
parts.push(detail ? `${name}: ${detail}` : `${name}: ${type} is false`)
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return parts.join(' · ')
|
|
2001
|
+
}
|
|
2002
|
+
|
|
1879
2003
|
export function getRouteParents(route: any): string {
|
|
1880
2004
|
const refs = route.spec?.parentRefs || []
|
|
1881
2005
|
return refs.map((r: any) => r.name).join(', ') || '-'
|
|
@@ -1954,10 +2078,24 @@ export function getNetworkPolicyRuleCount(np: any): { ingress: number; egress: n
|
|
|
1954
2078
|
}
|
|
1955
2079
|
}
|
|
1956
2080
|
|
|
2081
|
+
/**
|
|
2082
|
+
* Who the policy applies to.
|
|
2083
|
+
*
|
|
2084
|
+
* podSelector is a LabelSelector, so it can target a subset through
|
|
2085
|
+
* matchExpressions alone. Reading only matchLabels reported those policies as
|
|
2086
|
+
* covering the whole namespace — a gap rendered as coverage, on the surface
|
|
2087
|
+
* where that reads as "this namespace is protected, look elsewhere".
|
|
2088
|
+
*/
|
|
1957
2089
|
export function getNetworkPolicySelector(np: any): string {
|
|
1958
|
-
const
|
|
1959
|
-
|
|
1960
|
-
|
|
2090
|
+
const selector = np.spec?.podSelector
|
|
2091
|
+
const hasLabels = Object.keys(selector?.matchLabels ?? {}).length > 0
|
|
2092
|
+
// Count only what the formatter will actually render: it skips malformed
|
|
2093
|
+
// entries, and an expression list of nothing but those would otherwise reach
|
|
2094
|
+
// its empty-case string and describe the same state in different words.
|
|
2095
|
+
const hasExpressions = (Array.isArray(selector?.matchExpressions) ? selector.matchExpressions : [])
|
|
2096
|
+
.some((e: any) => e && typeof e === 'object')
|
|
2097
|
+
if (!hasLabels && !hasExpressions) return 'All pods'
|
|
2098
|
+
return formatKubernetesLabelSelector(selector)
|
|
1961
2099
|
}
|
|
1962
2100
|
|
|
1963
2101
|
// ============================================================================
|
|
@@ -2232,7 +2370,6 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
|
|
|
2232
2370
|
if (kindLower === 'clusterqueues') return _getClusterQueueStatus(resource).text
|
|
2233
2371
|
if (kindLower === 'localqueues') return _getLocalQueueStatus(resource).text
|
|
2234
2372
|
if (kindLower === 'workloads') return _getKueueWorkloadStatus(resource).text
|
|
2235
|
-
if (kindLower === 'resourceflavors') return _getResourceFlavorStatus(resource).text
|
|
2236
2373
|
if (kindLower === 'admissionchecks') return _getAdmissionCheckStatus(resource).text
|
|
2237
2374
|
if (kindLower === 'provisioningrequests') return _getProvisioningRequestStatus(resource).text
|
|
2238
2375
|
if (kindLower === 'rayclusters') return _getRayClusterStatus(resource).text
|
|
@@ -2252,7 +2389,6 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
|
|
|
2252
2389
|
if (kindLower === 'volcanoqueues') return _getVolcanoQueueStatus(resource).text
|
|
2253
2390
|
if (kindLower === 'volcanopodgroups') return _getVolcanoPodGroupStatus(resource).text
|
|
2254
2391
|
if (kindLower === 'jobflows') return _getJobFlowStatus(resource).text
|
|
2255
|
-
if (kindLower === 'jobtemplates') return _getJobTemplateStatus(resource).text
|
|
2256
2392
|
if (kindLower === 'kaiqueues') return _getKaiQueueStatus(resource).text
|
|
2257
2393
|
if (kindLower === 'kaipodgroups') return _getKaiPodGroupStatus(resource).text
|
|
2258
2394
|
if (kindLower === 'kaitoworkspaces') return _getKaitoWorkspaceStatus(resource).text
|
|
@@ -2320,6 +2456,13 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
|
|
|
2320
2456
|
return ''
|
|
2321
2457
|
case 'selector':
|
|
2322
2458
|
if (kindLower === 'istiogateways') return _getIstioGatewaySelectorString(resource)
|
|
2459
|
+
if (kindLower === 'authorizationpolicies') return _getAuthorizationPolicySelectorString(resource)
|
|
2460
|
+
return ''
|
|
2461
|
+
case 'tlsMode':
|
|
2462
|
+
// Distinct from Traefik's boolean 'tls' key, which SKIP_FILTER_COLUMNS
|
|
2463
|
+
// excludes — sharing it would have made this reader unreachable. The
|
|
2464
|
+
// generic fallback cannot reach spec.trafficPolicy.tls.mode.
|
|
2465
|
+
if (kindLower === 'destinationrules') return _getDestinationRuleTlsMode(resource)
|
|
2323
2466
|
return ''
|
|
2324
2467
|
case 'state':
|
|
2325
2468
|
if (kindLower === 'orders') return getOrderState(resource).text
|
|
@@ -2451,6 +2594,11 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
|
|
|
2451
2594
|
case 'mig':
|
|
2452
2595
|
if (kindLower === 'nvidiaclusterpolicies') return resource.spec?.mig?.strategy || ''
|
|
2453
2596
|
break
|
|
2597
|
+
// The fallback would read spec.timeZone and yield '' for an undeclared
|
|
2598
|
+
// zone, so those rows would drop out of a filter the cell shows a value for.
|
|
2599
|
+
case 'timeZone':
|
|
2600
|
+
if (kindLower === 'raycronjobs') return _getRayCronJobTimeZone(resource)
|
|
2601
|
+
break
|
|
2454
2602
|
}
|
|
2455
2603
|
|
|
2456
2604
|
// Fallback: try common paths
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getNetworkPolicySelector, getRouteStatus, getRouteStatusReason } from './resource-utils'
|
|
3
|
+
import { getIstioGatewayStatus } from './resource-utils-istio'
|
|
4
|
+
|
|
5
|
+
// Each case names the cluster state behind the verdict, so a future change that
|
|
6
|
+
// re-greens one of them fails here with the reason attached.
|
|
7
|
+
|
|
8
|
+
describe('getNetworkPolicySelector', () => {
|
|
9
|
+
it('reports a matchExpressions-only policy as targeting a subset, not everything', () => {
|
|
10
|
+
// podSelector is a LabelSelector: a policy can target a fraction of the
|
|
11
|
+
// namespace through expressions alone, and must not claim to cover all of it.
|
|
12
|
+
const np = {
|
|
13
|
+
spec: { podSelector: { matchExpressions: [{ key: 'tier', operator: 'In', values: ['web', 'api'] }] } },
|
|
14
|
+
}
|
|
15
|
+
expect(getNetworkPolicySelector(np)).toBe('tier In (web,api)')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('preserves every value rather than collapsing a multi-value NotIn', () => {
|
|
19
|
+
const np = {
|
|
20
|
+
spec: { podSelector: { matchExpressions: [{ key: 'env', operator: 'NotIn', values: ['prod', 'staging'] }] } },
|
|
21
|
+
}
|
|
22
|
+
expect(getNetworkPolicySelector(np)).toBe('env NotIn (prod,staging)')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders valueless operators', () => {
|
|
26
|
+
const np = { spec: { podSelector: { matchExpressions: [{ key: 'role', operator: 'Exists' }] } } }
|
|
27
|
+
expect(getNetworkPolicySelector(np)).toBe('role Exists')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('combines matchLabels with matchExpressions', () => {
|
|
31
|
+
const np = {
|
|
32
|
+
spec: {
|
|
33
|
+
podSelector: {
|
|
34
|
+
matchLabels: { app: 'api' },
|
|
35
|
+
matchExpressions: [{ key: 'tier', operator: 'In', values: ['web'] }],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
expect(getNetworkPolicySelector(np)).toBe('app=api, tier In (web)')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not switch vocabulary when every expression entry is malformed', () => {
|
|
43
|
+
// The shared formatter skips entries it cannot read and has its own
|
|
44
|
+
// empty-case wording; reaching it would describe one state in two ways.
|
|
45
|
+
expect(getNetworkPolicySelector({ spec: { podSelector: { matchExpressions: [null] } } })).toBe('All pods')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('still says All pods when the selector is genuinely empty', () => {
|
|
49
|
+
expect(getNetworkPolicySelector({ spec: { podSelector: {} } })).toBe('All pods')
|
|
50
|
+
expect(getNetworkPolicySelector({ spec: {} })).toBe('All pods')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('getRouteStatus', () => {
|
|
55
|
+
const route = (opts: {
|
|
56
|
+
refs?: any[]
|
|
57
|
+
parents?: any[]
|
|
58
|
+
generation?: number
|
|
59
|
+
namespace?: string
|
|
60
|
+
}) => ({
|
|
61
|
+
metadata: { namespace: opts.namespace ?? 'prod', generation: opts.generation },
|
|
62
|
+
spec: { parentRefs: opts.refs ?? [{ name: 'gw' }] },
|
|
63
|
+
status: { parents: opts.parents ?? [] },
|
|
64
|
+
})
|
|
65
|
+
const cond = (type: string, status: string, observedGeneration?: number) =>
|
|
66
|
+
({ type, status, ...(observedGeneration === undefined ? {} : { observedGeneration }) })
|
|
67
|
+
|
|
68
|
+
it('is not healthy when a backendRef names a Service that does not exist', () => {
|
|
69
|
+
// Accepted=True + ResolvedRefs=False is the documented shape for a missing
|
|
70
|
+
// backend — the gateway still routes, and answers 5xx.
|
|
71
|
+
const r = route({
|
|
72
|
+
parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'False')] }],
|
|
73
|
+
})
|
|
74
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('is healthy only when both conditions are true', () => {
|
|
78
|
+
const r = route({
|
|
79
|
+
parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }],
|
|
80
|
+
})
|
|
81
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('does not call an absent ResolvedRefs healthy', () => {
|
|
85
|
+
const r = route({ parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True')] }] })
|
|
86
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Pending', level: 'degraded' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('reports rejection when every requested parent rejects', () => {
|
|
90
|
+
const r = route({
|
|
91
|
+
parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'False'), cond('ResolvedRefs', 'True')] }],
|
|
92
|
+
})
|
|
93
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Not Accepted', level: 'unhealthy' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('degrades rather than rejects when only one of several parents rejects', () => {
|
|
97
|
+
const r = route({
|
|
98
|
+
refs: [{ name: 'gw-a' }, { name: 'gw-b' }],
|
|
99
|
+
parents: [
|
|
100
|
+
{ parentRef: { name: 'gw-a' }, conditions: [cond('Accepted', 'False'), cond('ResolvedRefs', 'True')] },
|
|
101
|
+
{ parentRef: { name: 'gw-b' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] },
|
|
102
|
+
],
|
|
103
|
+
})
|
|
104
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('is Pending while a requested parent has not reported yet', () => {
|
|
108
|
+
const r = route({
|
|
109
|
+
refs: [{ name: 'gw-a' }, { name: 'gw-b' }],
|
|
110
|
+
parents: [
|
|
111
|
+
{ parentRef: { name: 'gw-a' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] },
|
|
112
|
+
],
|
|
113
|
+
})
|
|
114
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Pending', level: 'degraded' })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('ignores a report left behind for a parentRef the spec no longer names', () => {
|
|
118
|
+
const r = route({
|
|
119
|
+
refs: [{ name: 'gw-new' }],
|
|
120
|
+
parents: [{ parentRef: { name: 'gw-old' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }],
|
|
121
|
+
})
|
|
122
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Unknown', level: 'unknown' })
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('does not accept conditions observed against a superseded generation', () => {
|
|
126
|
+
const r = route({
|
|
127
|
+
generation: 3,
|
|
128
|
+
parents: [{
|
|
129
|
+
parentRef: { name: 'gw' },
|
|
130
|
+
conditions: [cond('Accepted', 'True', 2), cond('ResolvedRefs', 'True', 2)],
|
|
131
|
+
}],
|
|
132
|
+
})
|
|
133
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Pending', level: 'degraded' })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('does not let a stale controller report outvote a live rejection, in either order', () => {
|
|
137
|
+
// Status entries are keyed by parentRef AND controllerName, so one parent
|
|
138
|
+
// can carry two controllers' reports while one replaces the other.
|
|
139
|
+
const old = { parentRef: { name: 'gw' }, controllerName: 'old/ctrl', conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }
|
|
140
|
+
const live = { parentRef: { name: 'gw' }, controllerName: 'new/ctrl', conditions: [cond('Accepted', 'False'), cond('ResolvedRefs', 'False')] }
|
|
141
|
+
// Neither order may be green, and both must agree. Degraded rather than
|
|
142
|
+
// unhealthy is deliberate: nothing here establishes which controller is
|
|
143
|
+
// authoritative, so the verdict says "something is wrong" without claiming
|
|
144
|
+
// the route is rejected outright.
|
|
145
|
+
const forward = getRouteStatus(route({ parents: [old, live] }))
|
|
146
|
+
const reversed = getRouteStatus(route({ parents: [live, old] }))
|
|
147
|
+
expect(forward).toEqual(reversed)
|
|
148
|
+
expect(forward.level).not.toBe('healthy')
|
|
149
|
+
expect(forward).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('counts a default-Gateway attachment the spec never names', () => {
|
|
153
|
+
// With useDefaultGateways the attachment exists only in status, so a
|
|
154
|
+
// failure there is the only evidence there is.
|
|
155
|
+
const r = {
|
|
156
|
+
metadata: { namespace: 'prod' },
|
|
157
|
+
spec: { useDefaultGateways: 'All' },
|
|
158
|
+
status: { parents: [{ parentRef: { name: 'default-gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'False')] }] },
|
|
159
|
+
}
|
|
160
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('accepts a default-only route whose default parent is healthy', () => {
|
|
164
|
+
const r = {
|
|
165
|
+
metadata: { namespace: 'prod' },
|
|
166
|
+
spec: { useDefaultGateways: 'All' },
|
|
167
|
+
status: { parents: [{ parentRef: { name: 'default-gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }] },
|
|
168
|
+
}
|
|
169
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('does not let a healthy explicit parent mask a failing default one', () => {
|
|
173
|
+
const r = {
|
|
174
|
+
metadata: { namespace: 'prod' },
|
|
175
|
+
spec: { parentRefs: [{ name: 'gw' }], useDefaultGateways: 'All' },
|
|
176
|
+
status: {
|
|
177
|
+
parents: [
|
|
178
|
+
{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] },
|
|
179
|
+
{ parentRef: { name: 'default-gw' }, conditions: [cond('Accepted', 'False'), cond('ResolvedRefs', 'True')] },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('ignores a dropped mesh Service parent when weighing default Gateways', () => {
|
|
187
|
+
// Services cannot be default Gateways, so an unmatched Service report is
|
|
188
|
+
// obsolete by definition — and would otherwise outvote the live one
|
|
189
|
+
// indefinitely once its controller is gone.
|
|
190
|
+
const r = {
|
|
191
|
+
metadata: { namespace: 'prod', generation: 3 },
|
|
192
|
+
spec: { useDefaultGateways: 'All' },
|
|
193
|
+
status: {
|
|
194
|
+
parents: [
|
|
195
|
+
{ parentRef: { group: '', kind: 'Service', name: 'legacy-mesh' }, conditions: [cond('Accepted', 'True', 2)] },
|
|
196
|
+
{ parentRef: { name: 'default-gw' }, conditions: [cond('Accepted', 'True', 3), cond('ResolvedRefs', 'True', 3)] },
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('still reports a rejecting default Gateway as rejected despite a dropped Service parent', () => {
|
|
204
|
+
const r = {
|
|
205
|
+
metadata: { namespace: 'prod', generation: 3 },
|
|
206
|
+
spec: { useDefaultGateways: 'All' },
|
|
207
|
+
status: {
|
|
208
|
+
parents: [
|
|
209
|
+
{ parentRef: { group: '', kind: 'Service', name: 'legacy-mesh' }, conditions: [cond('Accepted', 'True', 2)] },
|
|
210
|
+
{ parentRef: { name: 'default-gw' }, conditions: [cond('Accepted', 'False', 3)] },
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Not Accepted', level: 'unhealthy' })
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('takes a condition with no observedGeneration at face value', () => {
|
|
218
|
+
// Missing is not stale: a controller that never sets it must not strand the
|
|
219
|
+
// route as Pending forever.
|
|
220
|
+
const r = route({
|
|
221
|
+
generation: 7,
|
|
222
|
+
parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }],
|
|
223
|
+
})
|
|
224
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('lets a live healthy parent win over a superseded report for the same parent', () => {
|
|
228
|
+
// A report describing a spec that has since changed says nothing about the
|
|
229
|
+
// current one, so it must not hold a confirmed parent at Pending.
|
|
230
|
+
const r = route({
|
|
231
|
+
generation: 3,
|
|
232
|
+
parents: [
|
|
233
|
+
{ parentRef: { name: 'gw' }, controllerName: 'old/ctrl', conditions: [cond('Accepted', 'True', 2), cond('ResolvedRefs', 'True', 2)] },
|
|
234
|
+
{ parentRef: { name: 'gw' }, controllerName: 'new/ctrl', conditions: [cond('Accepted', 'True', 3), cond('ResolvedRefs', 'True', 3)] },
|
|
235
|
+
],
|
|
236
|
+
})
|
|
237
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('does not let confirmed parents speak for one that only has a stale report', () => {
|
|
241
|
+
// gw-a's sole report describes a superseded spec, so nothing has confirmed
|
|
242
|
+
// it. A healthy gw-b must not carry the verdict to Accepted on its own.
|
|
243
|
+
const r = route({
|
|
244
|
+
generation: 4,
|
|
245
|
+
refs: [{ name: 'gw-a' }, { name: 'gw-b' }],
|
|
246
|
+
parents: [
|
|
247
|
+
{ parentRef: { name: 'gw-a' }, conditions: [cond('Accepted', 'True', 2), cond('ResolvedRefs', 'True', 2)] },
|
|
248
|
+
{ parentRef: { name: 'gw-b' }, conditions: [cond('Accepted', 'True', 4), cond('ResolvedRefs', 'True', 4)] },
|
|
249
|
+
],
|
|
250
|
+
})
|
|
251
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Pending', level: 'degraded' })
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('does not claim full rejection while a parent is unconfirmed', () => {
|
|
255
|
+
const r = route({
|
|
256
|
+
generation: 4,
|
|
257
|
+
refs: [{ name: 'gw-a' }, { name: 'gw-b' }],
|
|
258
|
+
parents: [
|
|
259
|
+
{ parentRef: { name: 'gw-a' }, conditions: [cond('Accepted', 'False', 2)] },
|
|
260
|
+
{ parentRef: { name: 'gw-b' }, conditions: [cond('Accepted', 'False', 4)] },
|
|
261
|
+
],
|
|
262
|
+
})
|
|
263
|
+
// gw-a is unconfirmed against the current spec, so "every parent rejects"
|
|
264
|
+
// is not established — the live rejection still shows, as Degraded.
|
|
265
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('matches a parent whose namespace and kind are defaulted on one side only', () => {
|
|
269
|
+
const r = route({
|
|
270
|
+
namespace: 'prod',
|
|
271
|
+
refs: [{ name: 'gw' }],
|
|
272
|
+
parents: [{
|
|
273
|
+
parentRef: { name: 'gw', kind: 'Gateway', namespace: 'prod', group: 'gateway.networking.k8s.io' },
|
|
274
|
+
conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')],
|
|
275
|
+
}],
|
|
276
|
+
})
|
|
277
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('does not match a report for a different sectionName', () => {
|
|
281
|
+
const r = route({
|
|
282
|
+
refs: [{ name: 'gw', sectionName: 'https' }],
|
|
283
|
+
parents: [{ parentRef: { name: 'gw', sectionName: 'http' }, conditions: [cond('Accepted', 'True')] }],
|
|
284
|
+
})
|
|
285
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Unknown', level: 'unknown' })
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
describe('getRouteStatusReason', () => {
|
|
290
|
+
const cond = (type: string, status: string, extra: any = {}) => ({ type, status, ...extra })
|
|
291
|
+
|
|
292
|
+
it('names the parent and the controller reason behind a Degraded badge', () => {
|
|
293
|
+
const r = {
|
|
294
|
+
metadata: { namespace: 'prod' },
|
|
295
|
+
spec: { parentRefs: [{ name: 'gw' }] },
|
|
296
|
+
status: {
|
|
297
|
+
parents: [{
|
|
298
|
+
parentRef: { name: 'gw' },
|
|
299
|
+
conditions: [
|
|
300
|
+
cond('Accepted', 'True'),
|
|
301
|
+
cond('ResolvedRefs', 'False', { reason: 'BackendNotFound', message: 'Service prod/api not found' }),
|
|
302
|
+
],
|
|
303
|
+
}],
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
expect(getRouteStatusReason(r)).toBe('gw: BackendNotFound: Service prod/api not found')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('is empty when nothing is failing, so a healthy badge carries no hover', () => {
|
|
310
|
+
const r = {
|
|
311
|
+
metadata: { namespace: 'prod' },
|
|
312
|
+
spec: { parentRefs: [{ name: 'gw' }] },
|
|
313
|
+
status: { parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }] },
|
|
314
|
+
}
|
|
315
|
+
expect(getRouteStatusReason(r)).toBe('')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('reports every failing parent, not just the first', () => {
|
|
319
|
+
const r = {
|
|
320
|
+
metadata: { namespace: 'prod' },
|
|
321
|
+
spec: { parentRefs: [{ name: 'gw-a' }, { name: 'gw-b' }] },
|
|
322
|
+
status: {
|
|
323
|
+
parents: [
|
|
324
|
+
{ parentRef: { name: 'gw-a' }, conditions: [cond('Accepted', 'False', { reason: 'NotAllowedByListeners' })] },
|
|
325
|
+
{ parentRef: { name: 'gw-b' }, conditions: [cond('ResolvedRefs', 'False', { reason: 'BackendNotFound' })] },
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
expect(getRouteStatusReason(r)).toBe('gw-a: NotAllowedByListeners · gw-b: BackendNotFound')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('describes the same parents the verdict counted', () => {
|
|
333
|
+
// A leftover report for a dropped parentRef must not explain a badge that
|
|
334
|
+
// never considered it.
|
|
335
|
+
const r = {
|
|
336
|
+
metadata: { namespace: 'prod' },
|
|
337
|
+
spec: { parentRefs: [{ name: 'gw-new' }] },
|
|
338
|
+
status: { parents: [{ parentRef: { name: 'gw-old' }, conditions: [cond('ResolvedRefs', 'False', { reason: 'BackendNotFound' })] }] },
|
|
339
|
+
}
|
|
340
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Unknown' })
|
|
341
|
+
expect(getRouteStatusReason(r)).toBe('')
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
describe('getIstioGatewayStatus', () => {
|
|
346
|
+
it('does not claim health it cannot observe from spec alone', () => {
|
|
347
|
+
// A Gateway whose selector matches no running ingressgateway pod has
|
|
348
|
+
// servers like any other, so servers alone cannot assert liveness.
|
|
349
|
+
const gw = { spec: { servers: [{ port: { number: 80 } }] } }
|
|
350
|
+
expect(getIstioGatewayStatus(gw)).toMatchObject({ text: 'Defined', level: 'neutral' })
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('does not treat TLS as a verdict', () => {
|
|
354
|
+
const gw = { spec: { servers: [{ tls: { mode: 'SIMPLE' } }] } }
|
|
355
|
+
expect(getIstioGatewayStatus(gw)).toMatchObject({ text: 'Defined', level: 'neutral' })
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('still flags a gateway with no servers', () => {
|
|
359
|
+
expect(getIstioGatewayStatus({ spec: { servers: [] } })).toMatchObject({
|
|
360
|
+
text: 'No Servers', level: 'unhealthy',
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
})
|