diff --git a/kmesh/src/index.tsx b/kmesh/src/index.tsx index 2b807f3d0f..a94d531602 100644 --- a/kmesh/src/index.tsx +++ b/kmesh/src/index.tsx @@ -14,6 +14,7 @@ import KmeshNodeInfoDetail from './components/nodeinfo/Detail'; import KmeshNodeInfoList from './components/nodeinfo/List'; import WaypointDetail from './components/waypoints/Detail'; import WaypointList from './components/waypoints/List'; +import { registerKmeshMapExtensions } from './mapRegistration'; import { kmeshRouteNames, kmeshRoutePaths } from './utils/kmeshRoutes'; /** @@ -190,3 +191,6 @@ registerRoute({ // Register Namespace Enrollment section in the details view registerDetailsViewSection(NamespaceEnrollment); + +// Traffic Map: Namespace -> Waypoint -> Service, into Headlamp's shared Map view +registerKmeshMapExtensions(); diff --git a/kmesh/src/map/mapUtils.test.ts b/kmesh/src/map/mapUtils.test.ts new file mode 100644 index 0000000000..dfdf26c598 --- /dev/null +++ b/kmesh/src/map/mapUtils.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + getWaypointNodeStatus, + isWaypointEnrolled, + resolveEffectiveWaypointName, + USE_WAYPOINT_LABEL, +} from './mapUtils'; + +describe('getWaypointNodeStatus', () => { + it('returns success for Programmed', () => { + expect(getWaypointNodeStatus('Programmed')).toBe('success'); + }); + + it('returns success for Accepted', () => { + expect(getWaypointNodeStatus('Accepted')).toBe('success'); + }); + + it('returns error for Not Programmed', () => { + expect(getWaypointNodeStatus('Not Programmed')).toBe('error'); + }); + + it('returns error for Not Accepted', () => { + expect(getWaypointNodeStatus('Not Accepted')).toBe('error'); + }); + + it('returns warning for Unknown', () => { + expect(getWaypointNodeStatus('Unknown')).toBe('warning'); + }); +}); + +describe('resolveEffectiveWaypointName', () => { + it('prefers the resource label over the namespace label', () => { + expect( + resolveEffectiveWaypointName( + { [USE_WAYPOINT_LABEL]: 'service-waypoint' }, + { [USE_WAYPOINT_LABEL]: 'namespace-waypoint' } + ) + ).toBe('service-waypoint'); + }); + + it('falls back to the namespace label', () => { + expect( + resolveEffectiveWaypointName(undefined, { [USE_WAYPOINT_LABEL]: 'namespace-waypoint' }) + ).toBe('namespace-waypoint'); + }); + + it('returns undefined when neither is set', () => { + expect(resolveEffectiveWaypointName({}, {})).toBeUndefined(); + expect(resolveEffectiveWaypointName(undefined, undefined)).toBeUndefined(); + }); +}); + +describe('isWaypointEnrolled', () => { + it('is true when a waypoint name resolves', () => { + expect(isWaypointEnrolled({ [USE_WAYPOINT_LABEL]: 'wp' }, undefined)).toBe(true); + }); + + it('is false when no waypoint name resolves', () => { + expect(isWaypointEnrolled(undefined, undefined)).toBe(false); + }); +}); diff --git a/kmesh/src/map/mapUtils.ts b/kmesh/src/map/mapUtils.ts new file mode 100644 index 0000000000..bb19d7bbe5 --- /dev/null +++ b/kmesh/src/map/mapUtils.ts @@ -0,0 +1,63 @@ +/** + * Pure helper functions for the KMesh traffic Map view. Kept separate from + * mapView.tsx (which depends on React/KubeObject runtime) so this logic is + * unit-testable in isolation, matching the convention used by waypointUtils.ts. + */ + +/** Label set on a Namespace or Service to route it through a specific waypoint. */ +export const USE_WAYPOINT_LABEL = 'istio.io/use-waypoint'; + +/** Label set on a Waypoint's own Gateway describing what traffic it handles. */ +export const WAYPOINT_FOR_LABEL = 'istio.io/waypoint-for'; + +export type MapNodeStatus = 'error' | 'success' | 'warning' | undefined; + +/** + * Maps a Waypoint's human-readable Gateway API condition status + * (see waypointUtils.getWaypointCurrentStatus) to a Map node status badge. + * + * @param currentStatus - Result of getWaypointCurrentStatus(). + * @returns The Map node status, or undefined for an indeterminate state. + */ +export function getWaypointNodeStatus(currentStatus: string): MapNodeStatus { + switch (currentStatus) { + case 'Programmed': + case 'Accepted': + return 'success'; + case 'Not Programmed': + case 'Not Accepted': + return 'error'; + default: + return 'warning'; + } +} + +/** + * Resolves the name of the waypoint a resource is routed through, given its + * own labels and (as a fallback) its namespace's labels — namespace-level + * enrollment cascades to every Service in that namespace unless overridden. + * + * @param resourceLabels - Labels on the Service/workload itself. + * @param namespaceLabels - Labels on the owning Namespace. + * @returns The waypoint name, or undefined if neither is enrolled. + */ +export function resolveEffectiveWaypointName( + resourceLabels: Record | undefined, + namespaceLabels: Record | undefined +): string | undefined { + return resourceLabels?.[USE_WAYPOINT_LABEL] ?? namespaceLabels?.[USE_WAYPOINT_LABEL]; +} + +/** + * Whether a Namespace or Service is enrolled with a waypoint, directly or via + * its namespace. + * + * @param resourceLabels - Labels on the Service/workload itself. + * @param namespaceLabels - Labels on the owning Namespace. + */ +export function isWaypointEnrolled( + resourceLabels: Record | undefined, + namespaceLabels: Record | undefined +): boolean { + return resolveEffectiveWaypointName(resourceLabels, namespaceLabels) !== undefined; +} diff --git a/kmesh/src/mapRegistration.tsx b/kmesh/src/mapRegistration.tsx new file mode 100644 index 0000000000..1e8f0b4bd5 --- /dev/null +++ b/kmesh/src/mapRegistration.tsx @@ -0,0 +1,12 @@ +import { registerMapSource } from '@kinvolk/headlamp-plugin/lib'; +import { kmeshMapSource } from './mapView'; + +/** + * Registers KMesh's traffic topology into Headlamp's shared Map view: + * Namespace -> Waypoint -> Service, linked via the `istio.io/use-waypoint` + * convention rather than ownerReferences (KMesh/Istio ambient resources + * aren't owned by their waypoint, they're routed to it by label). + */ +export function registerKmeshMapExtensions() { + registerMapSource(kmeshMapSource); +} diff --git a/kmesh/src/mapView.tsx b/kmesh/src/mapView.tsx new file mode 100644 index 0000000000..a2b259bc38 --- /dev/null +++ b/kmesh/src/mapView.tsx @@ -0,0 +1,196 @@ +import { Icon } from '@iconify/react'; +import { K8s } from '@kinvolk/headlamp-plugin/lib'; +import type { + GraphEdge, + GraphNode, + GraphSource, +} from '@kinvolk/headlamp-plugin/lib/components/resourceMap/graph/graphModel'; +import type { KubeObject } from '@kinvolk/headlamp-plugin/lib/k8s/cluster'; +import type { ComponentType } from 'react'; +import { useMemo } from 'react'; +import WaypointDetail from './components/waypoints/Detail'; +import { + getWaypointNodeStatus, + isWaypointEnrolled, + resolveEffectiveWaypointName, +} from './map/mapUtils'; +import { KMESH_WAYPOINT_GATEWAY_CLASS, Waypoint } from './resources/waypoint'; +import { getWaypointCurrentStatus } from './resources/waypointUtils'; + +const kmeshMapIcon = ; + +const NamespaceResource = K8s.ResourceClasses.Namespace; +const ServiceResource = K8s.ResourceClasses.Service; + +type DetailsComponent = ComponentType<{ node: GraphNode }>; + +/** Wraps an existing route-param-driven Detail component so it can also be + * rendered as a Map node's details panel, given only the underlying KubeObject. */ +function makeDetailsComponent( + Detail: ComponentType<{ namespace?: string; name?: string }> +): DetailsComponent { + return function NodeDetails({ node }: { node: GraphNode }) { + const meta = node.kubeObject?.jsonData?.metadata; + if (!meta) return null; + return ; + }; +} + +const WaypointNodeDetails = makeDetailsComponent(WaypointDetail); + +function makeKubeToKubeEdge(from: KubeObject, to: KubeObject, label?: string): GraphEdge { + return { + id: `${from.metadata.uid}-${to.metadata.uid}`, + source: from.metadata.uid, + target: to.metadata.uid, + label, + }; +} + +function isKmeshWaypoint(waypoint: Waypoint): boolean { + return waypoint.spec?.gatewayClassName === KMESH_WAYPOINT_GATEWAY_CLASS; +} + +/** + * Waypoints (KMesh Gateways), plus a "hosts" edge back to their own Namespace. + */ +const waypointSource: GraphSource = { + id: 'kmesh-map-waypoints', + label: 'KMesh Waypoints', + icon: kmeshMapIcon, + useData() { + const [waypoints] = Waypoint.useList(); + const [namespaces] = NamespaceResource.useList(); + + return useMemo(() => { + if (!waypoints || !namespaces) return null; + + const kmeshWaypoints = waypoints.filter(isKmeshWaypoint); + + const nodes: GraphNode[] = kmeshWaypoints.map(waypoint => ({ + id: waypoint.metadata.uid, + kubeObject: waypoint, + weight: 2000, + status: getWaypointNodeStatus(getWaypointCurrentStatus(waypoint.status?.conditions)), + detailsComponent: WaypointNodeDetails, + })); + + const edges: GraphEdge[] = kmeshWaypoints + .map(waypoint => { + const ns = namespaces.find(n => n.metadata.name === waypoint.metadata.namespace); + return ns ? makeKubeToKubeEdge(ns, waypoint, 'hosts') : null; + }) + .filter((edge): edge is GraphEdge => edge !== null); + + return { nodes, edges }; + }, [waypoints, namespaces]); + }, +}; + +/** + * Namespaces that participate in the mesh: they either host a KMesh Waypoint, + * or they (or a Service inside them) are routed through one via the + * `istio.io/use-waypoint` label. Unrelated namespaces are left off the map + * so it doesn't balloon to the whole cluster. + */ +const namespaceSource: GraphSource = { + id: 'kmesh-map-namespaces', + label: 'KMesh Namespaces', + useData() { + const [namespaces] = NamespaceResource.useList(); + const [waypoints] = Waypoint.useList(); + const [services] = ServiceResource.useList(); + + return useMemo(() => { + if (!namespaces || !waypoints || !services) return null; + + const kmeshWaypoints = waypoints.filter(isKmeshWaypoint); + const waypointHostNamespaces = new Set(kmeshWaypoints.map(w => w.metadata.namespace)); + + const relevantNamespaces = namespaces.filter(ns => { + if (waypointHostNamespaces.has(ns.metadata.name)) return true; + if (isWaypointEnrolled(ns.metadata.labels, undefined)) return true; + return services.some( + svc => + svc.metadata.namespace === ns.metadata.name && + isWaypointEnrolled(svc.metadata.labels, ns.metadata.labels) + ); + }); + + const nodes: GraphNode[] = relevantNamespaces.map(ns => ({ + id: ns.metadata.uid, + kubeObject: ns, + weight: 3000, + })); + + const edges: GraphEdge[] = relevantNamespaces + .map(ns => { + const waypointName = resolveEffectiveWaypointName(ns.metadata.labels, undefined); + if (!waypointName) return null; + const target = kmeshWaypoints.find( + w => w.metadata.name === waypointName && w.metadata.namespace === ns.metadata.name + ); + return target ? makeKubeToKubeEdge(ns, target, 'uses') : null; + }) + .filter((edge): edge is GraphEdge => edge !== null); + + return { nodes, edges }; + }, [namespaces, waypoints, services]); + }, +}; + +/** + * Services routed through a KMesh Waypoint, either via their own + * `istio.io/use-waypoint` label or one inherited from their Namespace. + */ +const serviceSource: GraphSource = { + id: 'kmesh-map-services', + label: 'KMesh Services', + useData() { + const [services] = ServiceResource.useList(); + const [namespaces] = NamespaceResource.useList(); + const [waypoints] = Waypoint.useList(); + + return useMemo(() => { + if (!services || !namespaces || !waypoints) return null; + + const kmeshWaypoints = waypoints.filter(isKmeshWaypoint); + const namespacesByName = new Map(namespaces.map(ns => [ns.metadata.name, ns])); + + const enrolledServices = services.filter(svc => { + const ns = namespacesByName.get(svc.metadata.namespace); + return isWaypointEnrolled(svc.metadata.labels, ns?.metadata.labels); + }); + + const nodes: GraphNode[] = enrolledServices.map(svc => ({ + id: svc.metadata.uid, + kubeObject: svc, + weight: 1000, + })); + + const edges: GraphEdge[] = enrolledServices + .map(svc => { + const ns = namespacesByName.get(svc.metadata.namespace); + const waypointName = resolveEffectiveWaypointName( + svc.metadata.labels, + ns?.metadata.labels + ); + const target = kmeshWaypoints.find( + w => w.metadata.name === waypointName && w.metadata.namespace === svc.metadata.namespace + ); + return target ? makeKubeToKubeEdge(svc, target, 'routed via') : null; + }) + .filter((edge): edge is GraphEdge => edge !== null); + + return { nodes, edges }; + }, [services, namespaces, waypoints]); + }, +}; + +/** Top-level KMesh source shown in the Map view's source picker. */ +export const kmeshMapSource: GraphSource = { + id: 'kmesh', + label: 'KMesh', + icon: kmeshMapIcon, + sources: [namespaceSource, waypointSource, serviceSource], +};