Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions kmesh/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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();
61 changes: 61 additions & 0 deletions kmesh/src/map/mapUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
63 changes: 63 additions & 0 deletions kmesh/src/map/mapUtils.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment on lines +10 to +11

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<string, string> | undefined,
namespaceLabels: Record<string, string> | undefined
): string | undefined {
return resourceLabels?.[USE_WAYPOINT_LABEL] ?? namespaceLabels?.[USE_WAYPOINT_LABEL];
Comment on lines +44 to +48
}

/**
* 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<string, string> | undefined,
namespaceLabels: Record<string, string> | undefined
): boolean {
return resolveEffectiveWaypointName(resourceLabels, namespaceLabels) !== undefined;
}
12 changes: 12 additions & 0 deletions kmesh/src/mapRegistration.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
196 changes: 196 additions & 0 deletions kmesh/src/mapView.tsx
Original file line number Diff line number Diff line change
@@ -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 = <Icon icon="mdi:vector-triangle" width="100%" height="100%" />;

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 <Detail namespace={meta.namespace} name={meta.name} />;
};
}

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],
};
Loading