feat: initial kube-vip Headlamp plugin
Headlamp plugin providing visibility into kube-vip virtual IP and load balancer deployments. Features: - Overview dashboard with deployment status, VIP mode, leader election - Services page with LoadBalancer VIP assignments and detail panels - Nodes page showing kube-vip pod status and leader designation - Configuration page with DaemonSet config, IP pools, leases - Service detail section injected into native Headlamp Service views Read-only plugin — no cluster write operations. Uses standard K8s resources (no CRDs): Services, Nodes, Pods, DaemonSets, Leases, ConfigMaps with kube-vip.io/* annotations. 74 tests across 7 test files. All tsc/lint/format/test checks pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock headlamp plugin APIs before importing the module under test
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: {
|
||||
request: vi.fn().mockResolvedValue({ items: [] }),
|
||||
},
|
||||
K8s: {
|
||||
ResourceClasses: {
|
||||
Service: {
|
||||
useList: vi.fn(() => [[], null]),
|
||||
},
|
||||
Node: {
|
||||
useList: vi.fn(() => [[], null]),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { KubeVipDataProvider, useKubeVipContext } from './KubeVipDataContext';
|
||||
|
||||
describe('useKubeVipContext', () => {
|
||||
it('throws when used outside KubeVipDataProvider', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useKubeVipContext());
|
||||
}).toThrow('useKubeVipContext must be used within a KubeVipDataProvider');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns context value when inside KubeVipDataProvider', async () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KubeVipDataProvider>{children}</KubeVipDataProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useKubeVipContext(), { wrapper });
|
||||
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.current.loadBalancerServices).toBeInstanceOf(Array);
|
||||
expect(result.current.nodes).toBeInstanceOf(Array);
|
||||
expect(result.current.kubeVipPods).toBeInstanceOf(Array);
|
||||
expect(result.current.cloudProviderPods).toBeInstanceOf(Array);
|
||||
expect(result.current.leases).toBeInstanceOf(Array);
|
||||
expect(result.current.ipPools).toBeInstanceOf(Array);
|
||||
expect(typeof result.current.refresh).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* KubeVipDataContext — shared data provider for kube-vip Kubernetes resources.
|
||||
*
|
||||
* Fetches Services (LoadBalancer), Nodes, kube-vip DaemonSet/pods,
|
||||
* Leases, and the kubevip ConfigMap. Provides filtered data to all
|
||||
* child pages via React context.
|
||||
*/
|
||||
|
||||
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
DaemonSetStatus,
|
||||
filterLoadBalancerServices,
|
||||
IPPool,
|
||||
isKubeList,
|
||||
KUBE_VIP_CLOUD_PROVIDER_SELECTOR,
|
||||
KUBE_VIP_CONFIGMAP_NAME,
|
||||
KUBE_VIP_DAEMONSET_NAME,
|
||||
KUBE_VIP_NAMESPACE,
|
||||
KUBE_VIP_POD_SELECTOR,
|
||||
KubeVipConfigMap,
|
||||
KubeVipDaemonSet,
|
||||
KubeVipLease,
|
||||
KubeVipNode,
|
||||
KubeVipPod,
|
||||
KubeVipService,
|
||||
parseIPPools,
|
||||
} from './k8s';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KubeVipContextValue {
|
||||
// kube-vip deployment
|
||||
kubeVipInstalled: boolean;
|
||||
daemonSetStatus: DaemonSetStatus | null;
|
||||
kubeVipPods: KubeVipPod[];
|
||||
cloudProviderPods: KubeVipPod[];
|
||||
|
||||
// Services managed by kube-vip (type: LoadBalancer)
|
||||
loadBalancerServices: KubeVipService[];
|
||||
|
||||
// Nodes
|
||||
nodes: KubeVipNode[];
|
||||
|
||||
// Leader election
|
||||
leases: KubeVipLease[];
|
||||
|
||||
// IP pool configuration
|
||||
ipPools: IPPool[];
|
||||
configMapData: Record<string, string>;
|
||||
|
||||
// kube-vip configuration (from DaemonSet env vars)
|
||||
kubeVipConfig: Record<string, string>;
|
||||
|
||||
// Loading / error state
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Manual refresh trigger
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KubeVipContext = createContext<KubeVipContextValue | null>(null);
|
||||
|
||||
export function useKubeVipContext(): KubeVipContextValue {
|
||||
const ctx = useContext(KubeVipContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useKubeVipContext must be used within a KubeVipDataProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function KubeVipDataProvider({ children }: { children: React.ReactNode }) {
|
||||
// Async-fetched resources
|
||||
const [kubeVipPods, setKubeVipPods] = useState<KubeVipPod[]>([]);
|
||||
const [cloudProviderPods, setCloudProviderPods] = useState<KubeVipPod[]>([]);
|
||||
const [daemonSetStatus, setDaemonSetStatus] = useState<DaemonSetStatus | null>(null);
|
||||
const [leases, setLeases] = useState<KubeVipLease[]>([]);
|
||||
const [configMapData, setConfigMapData] = useState<Record<string, string>>({});
|
||||
const [kubeVipConfig, setKubeVipConfig] = useState<Record<string, string>>({});
|
||||
const [asyncLoading, setAsyncLoading] = useState(true);
|
||||
const [asyncError, setAsyncError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// K8s resource hooks — Headlamp re-fetches on cluster changes automatically
|
||||
const [allServices, svcError] = K8s.ResourceClasses.Service.useList({ namespace: '' });
|
||||
const [allNodes, nodeError] = K8s.ResourceClasses.Node.useList();
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchAsync() {
|
||||
setAsyncLoading(true);
|
||||
setAsyncError(null);
|
||||
try {
|
||||
// kube-vip DaemonSet
|
||||
try {
|
||||
const ds = (await ApiProxy.request(
|
||||
`/apis/apps/v1/namespaces/${KUBE_VIP_NAMESPACE}/daemonsets/${KUBE_VIP_DAEMONSET_NAME}`
|
||||
)) as KubeVipDaemonSet;
|
||||
if (!cancelled) {
|
||||
setDaemonSetStatus(ds.status ?? null);
|
||||
// Extract config from DaemonSet template env vars
|
||||
const env = ds.spec?.template?.spec?.containers?.[0]?.env;
|
||||
if (env) {
|
||||
const config: Record<string, string> = {};
|
||||
for (const e of env) {
|
||||
if (e.value !== undefined) config[e.name] = e.value;
|
||||
}
|
||||
setKubeVipConfig(config);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setDaemonSetStatus(null);
|
||||
setKubeVipConfig({});
|
||||
}
|
||||
}
|
||||
|
||||
// kube-vip pods
|
||||
try {
|
||||
const podList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
|
||||
KUBE_VIP_POD_SELECTOR
|
||||
)}`
|
||||
);
|
||||
if (!cancelled && isKubeList(podList)) {
|
||||
setKubeVipPods(podList.items as KubeVipPod[]);
|
||||
}
|
||||
} catch {
|
||||
// If label selector doesn't match, try listing all pods in kube-system
|
||||
// and filtering by name prefix (for static pod deployments)
|
||||
try {
|
||||
const allPods = await ApiProxy.request(`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/pods`);
|
||||
if (!cancelled && isKubeList(allPods)) {
|
||||
const kvPods = (allPods.items as KubeVipPod[]).filter(p =>
|
||||
p.metadata.name.startsWith('kube-vip')
|
||||
);
|
||||
setKubeVipPods(kvPods);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setKubeVipPods([]);
|
||||
}
|
||||
}
|
||||
|
||||
// kube-vip-cloud-provider pods
|
||||
try {
|
||||
const cpList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
|
||||
KUBE_VIP_CLOUD_PROVIDER_SELECTOR
|
||||
)}`
|
||||
);
|
||||
if (!cancelled && isKubeList(cpList)) {
|
||||
setCloudProviderPods(cpList.items as KubeVipPod[]);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setCloudProviderPods([]);
|
||||
}
|
||||
|
||||
// Leases (kube-vip uses leases for leader election)
|
||||
try {
|
||||
const leaseList = await ApiProxy.request(
|
||||
`/apis/coordination.k8s.io/v1/namespaces/${KUBE_VIP_NAMESPACE}/leases`
|
||||
);
|
||||
if (!cancelled && isKubeList(leaseList)) {
|
||||
const kvLeases = (leaseList.items as KubeVipLease[]).filter(
|
||||
l => l.metadata.name.startsWith('plndr-') || l.metadata.name.startsWith('kube-vip-')
|
||||
);
|
||||
setLeases(kvLeases);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setLeases([]);
|
||||
}
|
||||
|
||||
// kubevip ConfigMap (IP pool configuration)
|
||||
try {
|
||||
const cm = (await ApiProxy.request(
|
||||
`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/configmaps/${KUBE_VIP_CONFIGMAP_NAME}`
|
||||
)) as KubeVipConfigMap;
|
||||
if (!cancelled) {
|
||||
setConfigMapData(cm.data ?? {});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setConfigMapData({});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setAsyncError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setAsyncLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void fetchAsync();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshKey]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived / filtered values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const extractJsonData = (items: unknown[]): unknown[] =>
|
||||
items.map(item =>
|
||||
item && typeof item === 'object' && 'jsonData' in item
|
||||
? (item as { jsonData: unknown }).jsonData
|
||||
: item
|
||||
);
|
||||
|
||||
const loadBalancerServices = useMemo(() => {
|
||||
if (!allServices) return [];
|
||||
return filterLoadBalancerServices(extractJsonData(allServices as unknown[]));
|
||||
}, [allServices]);
|
||||
|
||||
const nodes = useMemo(() => {
|
||||
if (!allNodes) return [];
|
||||
return extractJsonData(allNodes as unknown[]) as KubeVipNode[];
|
||||
}, [allNodes]);
|
||||
|
||||
const ipPools = useMemo(() => parseIPPools(configMapData), [configMapData]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combined loading / error state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const loading = asyncLoading || !allServices || !allNodes;
|
||||
|
||||
const errors: string[] = [];
|
||||
if (svcError) errors.push(String(svcError));
|
||||
if (nodeError) errors.push(String(nodeError));
|
||||
if (asyncError) errors.push(asyncError);
|
||||
const error = errors.length > 0 ? errors.join('; ') : null;
|
||||
|
||||
const kubeVipInstalled = kubeVipPods.length > 0 || daemonSetStatus !== null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memoized context value
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const value = useMemo<KubeVipContextValue>(
|
||||
() => ({
|
||||
kubeVipInstalled,
|
||||
daemonSetStatus,
|
||||
kubeVipPods,
|
||||
cloudProviderPods,
|
||||
loadBalancerServices,
|
||||
nodes,
|
||||
leases,
|
||||
ipPools,
|
||||
configMapData,
|
||||
kubeVipConfig,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
}),
|
||||
[
|
||||
kubeVipInstalled,
|
||||
daemonSetStatus,
|
||||
kubeVipPods,
|
||||
cloudProviderPods,
|
||||
loadBalancerServices,
|
||||
nodes,
|
||||
leases,
|
||||
ipPools,
|
||||
configMapData,
|
||||
kubeVipConfig,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
]
|
||||
);
|
||||
|
||||
return <KubeVipContext.Provider value={value}>{children}</KubeVipContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractPodConfig,
|
||||
filterLoadBalancerServices,
|
||||
formatAge,
|
||||
getNodeInternalIP,
|
||||
getNodeVipLabel,
|
||||
getPodImage,
|
||||
getPodRestarts,
|
||||
getServiceVIPs,
|
||||
getVipHost,
|
||||
isControlPlaneNode,
|
||||
isEgressEnabled,
|
||||
isKubeList,
|
||||
isKubeVipService,
|
||||
isLoadBalancerService,
|
||||
isNodeReady,
|
||||
isPodReady,
|
||||
isServiceIgnored,
|
||||
parseIPPools,
|
||||
phaseToStatus,
|
||||
} from './k8s';
|
||||
|
||||
describe('isKubeList', () => {
|
||||
it('returns true for objects with items array', () => {
|
||||
expect(isKubeList({ items: [] })).toBe(true);
|
||||
expect(isKubeList({ items: [1, 2] })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-objects and missing items', () => {
|
||||
expect(isKubeList(null)).toBe(false);
|
||||
expect(isKubeList(undefined)).toBe(false);
|
||||
expect(isKubeList('string')).toBe(false);
|
||||
expect(isKubeList({ data: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLoadBalancerService', () => {
|
||||
it('identifies LoadBalancer services', () => {
|
||||
expect(isLoadBalancerService({ spec: { type: 'LoadBalancer' }, metadata: { name: 'x' } })).toBe(
|
||||
true
|
||||
);
|
||||
expect(isLoadBalancerService({ spec: { type: 'ClusterIP' }, metadata: { name: 'x' } })).toBe(
|
||||
false
|
||||
);
|
||||
expect(isLoadBalancerService(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isKubeVipService', () => {
|
||||
it('returns true when kube-vip annotations are present', () => {
|
||||
expect(
|
||||
isKubeVipService({
|
||||
metadata: { name: 'x', annotations: { 'kube-vip.io/loadbalancerIPs': '1.2.3.4' } },
|
||||
spec: { type: 'LoadBalancer' },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no kube-vip annotations', () => {
|
||||
expect(
|
||||
isKubeVipService({
|
||||
metadata: { name: 'x' },
|
||||
spec: { type: 'LoadBalancer' },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getServiceVIPs', () => {
|
||||
it('returns IPs from annotation', () => {
|
||||
const vips = getServiceVIPs({
|
||||
metadata: { name: 'x', annotations: { 'kube-vip.io/loadbalancerIPs': '1.2.3.4,5.6.7.8' } },
|
||||
spec: { type: 'LoadBalancer' },
|
||||
});
|
||||
expect(vips).toEqual(['1.2.3.4', '5.6.7.8']);
|
||||
});
|
||||
|
||||
it('falls back to status.loadBalancer.ingress', () => {
|
||||
const vips = getServiceVIPs({
|
||||
metadata: { name: 'x' },
|
||||
spec: { type: 'LoadBalancer' },
|
||||
status: { loadBalancer: { ingress: [{ ip: '10.0.0.1' }] } },
|
||||
});
|
||||
expect(vips).toEqual(['10.0.0.1']);
|
||||
});
|
||||
|
||||
it('falls back to spec.loadBalancerIP', () => {
|
||||
const vips = getServiceVIPs({
|
||||
metadata: { name: 'x' },
|
||||
spec: { type: 'LoadBalancer', loadBalancerIP: '10.0.0.2' },
|
||||
});
|
||||
expect(vips).toEqual(['10.0.0.2']);
|
||||
});
|
||||
|
||||
it('returns empty array when no VIP info', () => {
|
||||
const vips = getServiceVIPs({
|
||||
metadata: { name: 'x' },
|
||||
spec: { type: 'LoadBalancer' },
|
||||
});
|
||||
expect(vips).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVipHost', () => {
|
||||
it('returns the vipHost annotation value', () => {
|
||||
expect(
|
||||
getVipHost({
|
||||
metadata: { name: 'x', annotations: { 'kube-vip.io/vipHost': 'node-1' } },
|
||||
spec: { type: 'LoadBalancer' },
|
||||
})
|
||||
).toBe('node-1');
|
||||
});
|
||||
|
||||
it('returns undefined when not present', () => {
|
||||
expect(
|
||||
getVipHost({
|
||||
metadata: { name: 'x' },
|
||||
spec: { type: 'LoadBalancer' },
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEgressEnabled / isServiceIgnored', () => {
|
||||
it('detects egress enabled', () => {
|
||||
expect(
|
||||
isEgressEnabled({
|
||||
metadata: { name: 'x', annotations: { 'kube-vip.io/egress': 'true' } },
|
||||
spec: { type: 'LoadBalancer' },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('detects ignored service', () => {
|
||||
expect(
|
||||
isServiceIgnored({
|
||||
metadata: { name: 'x', annotations: { 'kube-vip.io/ignore': 'true' } },
|
||||
spec: { type: 'LoadBalancer' },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterLoadBalancerServices', () => {
|
||||
it('filters only LoadBalancer services', () => {
|
||||
const items = [
|
||||
{ spec: { type: 'LoadBalancer' }, metadata: { name: 'a' } },
|
||||
{ spec: { type: 'ClusterIP' }, metadata: { name: 'b' } },
|
||||
{ spec: { type: 'LoadBalancer' }, metadata: { name: 'c' } },
|
||||
];
|
||||
expect(filterLoadBalancerServices(items)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node helpers', () => {
|
||||
const node = {
|
||||
metadata: {
|
||||
name: 'node-1',
|
||||
labels: { 'node-role.kubernetes.io/control-plane': '' },
|
||||
},
|
||||
status: {
|
||||
conditions: [{ type: 'Ready', status: 'True' }],
|
||||
addresses: [{ type: 'InternalIP', address: '10.0.0.1' }],
|
||||
},
|
||||
};
|
||||
|
||||
it('isNodeReady returns true for Ready node', () => {
|
||||
expect(isNodeReady(node)).toBe(true);
|
||||
});
|
||||
|
||||
it('isControlPlaneNode returns true for control-plane labeled node', () => {
|
||||
expect(isControlPlaneNode(node)).toBe(true);
|
||||
});
|
||||
|
||||
it('getNodeInternalIP returns the InternalIP', () => {
|
||||
expect(getNodeInternalIP(node)).toBe('10.0.0.1');
|
||||
});
|
||||
|
||||
it('getNodeVipLabel returns undefined when no VIP label', () => {
|
||||
expect(getNodeVipLabel(node)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pod helpers', () => {
|
||||
const pod = {
|
||||
metadata: { name: 'kube-vip-ds-abc' },
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: 'kube-vip',
|
||||
image: 'ghcr.io/kube-vip/kube-vip:v0.8.0',
|
||||
env: [
|
||||
{ name: 'address', value: '192.168.1.100' },
|
||||
{ name: 'vip_arp', value: 'true' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
status: {
|
||||
phase: 'Running',
|
||||
conditions: [{ type: 'Ready', status: 'True' }],
|
||||
containerStatuses: [
|
||||
{
|
||||
name: 'kube-vip',
|
||||
ready: true,
|
||||
restartCount: 2,
|
||||
image: 'ghcr.io/kube-vip/kube-vip:v0.8.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('isPodReady returns true for Ready pod', () => {
|
||||
expect(isPodReady(pod)).toBe(true);
|
||||
});
|
||||
|
||||
it('getPodRestarts sums container restarts', () => {
|
||||
expect(getPodRestarts(pod)).toBe(2);
|
||||
});
|
||||
|
||||
it('getPodImage returns container image', () => {
|
||||
expect(getPodImage(pod)).toBe('ghcr.io/kube-vip/kube-vip:v0.8.0');
|
||||
});
|
||||
|
||||
it('extractPodConfig extracts env vars', () => {
|
||||
const config = extractPodConfig(pod);
|
||||
expect(config).toEqual({ address: '192.168.1.100', vip_arp: 'true' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIPPools', () => {
|
||||
it('parses range and cidr pools', () => {
|
||||
const data = {
|
||||
'range-global': '192.168.1.200-192.168.1.250',
|
||||
'cidr-default': '10.0.0.0/24',
|
||||
};
|
||||
const pools = parseIPPools(data);
|
||||
expect(pools).toHaveLength(2);
|
||||
expect(pools[0]).toEqual({
|
||||
name: 'range-global',
|
||||
type: 'range',
|
||||
value: '192.168.1.200-192.168.1.250',
|
||||
scope: 'global',
|
||||
});
|
||||
expect(pools[1]).toEqual({
|
||||
name: 'cidr-default',
|
||||
type: 'cidr',
|
||||
value: '10.0.0.0/24',
|
||||
scope: 'global',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses namespace-scoped pools', () => {
|
||||
const data = {
|
||||
'staging/range-pool1': '10.1.0.100-10.1.0.200',
|
||||
};
|
||||
const pools = parseIPPools(data);
|
||||
expect(pools).toHaveLength(1);
|
||||
expect(pools[0].scope).toBe('namespace');
|
||||
expect(pools[0].namespace).toBe('staging');
|
||||
});
|
||||
|
||||
it('returns empty for undefined data', () => {
|
||||
expect(parseIPPools(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAge', () => {
|
||||
it('returns "unknown" for undefined', () => {
|
||||
expect(formatAge(undefined)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('formats days', () => {
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
expect(formatAge(twoDaysAgo)).toBe('2d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('phaseToStatus', () => {
|
||||
it('maps Running to success', () => {
|
||||
expect(phaseToStatus('Running')).toBe('success');
|
||||
});
|
||||
|
||||
it('maps Pending to warning', () => {
|
||||
expect(phaseToStatus('Pending')).toBe('warning');
|
||||
});
|
||||
|
||||
it('maps unknown to error', () => {
|
||||
expect(phaseToStatus('Failed')).toBe('error');
|
||||
});
|
||||
});
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Kubernetes type definitions and helper functions for kube-vip resources.
|
||||
*
|
||||
* kube-vip uses no CRDs — all state is in standard Kubernetes resources
|
||||
* (DaemonSets, Pods, Services, Nodes, Leases, ConfigMaps).
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const KUBE_VIP_NAMESPACE = 'kube-system' as const;
|
||||
export const KUBE_VIP_DAEMONSET_NAME = 'kube-vip-ds' as const;
|
||||
export const KUBE_VIP_CLOUD_PROVIDER_NAME = 'kube-vip-cloud-provider' as const;
|
||||
export const KUBE_VIP_CONFIGMAP_NAME = 'kubevip' as const;
|
||||
export const KUBE_VIP_ANNOTATION_PREFIX = 'kube-vip.io/' as const;
|
||||
export const KUBE_VIP_METRICS_PORT = 2112 as const;
|
||||
|
||||
/** Label selectors for kube-vip pods. */
|
||||
export const KUBE_VIP_POD_SELECTOR = 'app.kubernetes.io/name=kube-vip-ds';
|
||||
export const KUBE_VIP_CLOUD_PROVIDER_SELECTOR = 'app=kube-vip-cloud-provider';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Annotation keys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ANNOTATION_LOADBALANCER_IPS = 'kube-vip.io/loadbalancerIPs';
|
||||
export const ANNOTATION_IGNORE = 'kube-vip.io/ignore';
|
||||
export const ANNOTATION_VIP_HOST = 'kube-vip.io/vipHost';
|
||||
export const ANNOTATION_EGRESS = 'kube-vip.io/egress';
|
||||
export const ANNOTATION_SERVICE_INTERFACE = 'kube-vip.io/serviceInterface';
|
||||
export const ANNOTATION_HOSTNAME = 'kube-vip.io/loadbalancerHostname';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic Kubernetes object base shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KubeObjectMeta {
|
||||
name: string;
|
||||
namespace?: string;
|
||||
creationTimestamp?: string;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export interface KubeObject {
|
||||
apiVersion?: string;
|
||||
kind?: string;
|
||||
metadata: KubeObjectMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// K8s API list response envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KubeList<T> {
|
||||
items: T[];
|
||||
metadata?: { resourceVersion?: string };
|
||||
}
|
||||
|
||||
export function isKubeList(value: unknown): value is KubeList<unknown> {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
return Array.isArray((value as Record<string, unknown>)['items']);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service (LoadBalancer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ServicePort {
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
port: number;
|
||||
targetPort?: number | string;
|
||||
nodePort?: number;
|
||||
}
|
||||
|
||||
export interface ServiceSpec {
|
||||
type?: string;
|
||||
clusterIP?: string;
|
||||
externalTrafficPolicy?: string;
|
||||
loadBalancerIP?: string;
|
||||
ports?: ServicePort[];
|
||||
selector?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ServiceStatus {
|
||||
loadBalancer?: {
|
||||
ingress?: Array<{ ip?: string; hostname?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface KubeVipService extends KubeObject {
|
||||
spec: ServiceSpec;
|
||||
status?: ServiceStatus;
|
||||
}
|
||||
|
||||
/** Returns true if a Service is of type LoadBalancer. */
|
||||
export function isLoadBalancerService(svc: unknown): svc is KubeVipService {
|
||||
if (!svc || typeof svc !== 'object') return false;
|
||||
const obj = svc as Record<string, unknown>;
|
||||
const spec = obj['spec'] as Record<string, unknown> | undefined;
|
||||
return spec?.['type'] === 'LoadBalancer';
|
||||
}
|
||||
|
||||
/** Returns true if a LoadBalancer service has a kube-vip annotation. */
|
||||
export function isKubeVipService(svc: KubeVipService): boolean {
|
||||
const annotations = svc.metadata.annotations ?? {};
|
||||
return Object.keys(annotations).some(key => key.startsWith(KUBE_VIP_ANNOTATION_PREFIX));
|
||||
}
|
||||
|
||||
/** Get the VIP address(es) from a service. */
|
||||
export function getServiceVIPs(svc: KubeVipService): string[] {
|
||||
// Check kube-vip annotation first
|
||||
const annotatedIPs = svc.metadata.annotations?.[ANNOTATION_LOADBALANCER_IPS];
|
||||
if (annotatedIPs) return annotatedIPs.split(',').map(ip => ip.trim());
|
||||
|
||||
// Fall back to status.loadBalancer.ingress
|
||||
const ingress = svc.status?.loadBalancer?.ingress;
|
||||
if (ingress && ingress.length > 0) {
|
||||
return ingress.map(i => i.ip ?? i.hostname ?? '').filter(Boolean);
|
||||
}
|
||||
|
||||
// Fall back to spec.loadBalancerIP
|
||||
if (svc.spec.loadBalancerIP) return [svc.spec.loadBalancerIP];
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Get the node currently hosting the VIP for this service. */
|
||||
export function getVipHost(svc: KubeVipService): string | undefined {
|
||||
return svc.metadata.annotations?.[ANNOTATION_VIP_HOST];
|
||||
}
|
||||
|
||||
/** Check if egress is enabled on this service. */
|
||||
export function isEgressEnabled(svc: KubeVipService): boolean {
|
||||
return svc.metadata.annotations?.[ANNOTATION_EGRESS] === 'true';
|
||||
}
|
||||
|
||||
/** Check if service is ignored by kube-vip. */
|
||||
export function isServiceIgnored(svc: KubeVipService): boolean {
|
||||
return svc.metadata.annotations?.[ANNOTATION_IGNORE] === 'true';
|
||||
}
|
||||
|
||||
/** Filter LoadBalancer services from a list of unknown objects. */
|
||||
export function filterLoadBalancerServices(items: unknown[]): KubeVipService[] {
|
||||
return items.filter(isLoadBalancerService);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface NodeAddress {
|
||||
type: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface NodeCondition {
|
||||
type: string;
|
||||
status: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
lastTransitionTime?: string;
|
||||
}
|
||||
|
||||
export interface NodeStatus {
|
||||
conditions?: NodeCondition[];
|
||||
addresses?: NodeAddress[];
|
||||
nodeInfo?: {
|
||||
kubeletVersion?: string;
|
||||
osImage?: string;
|
||||
containerRuntimeVersion?: string;
|
||||
architecture?: string;
|
||||
};
|
||||
allocatable?: Record<string, string>;
|
||||
capacity?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NodeSpec {
|
||||
podCIDR?: string;
|
||||
taints?: Array<{ key: string; effect: string; value?: string }>;
|
||||
}
|
||||
|
||||
export interface KubeVipNode extends KubeObject {
|
||||
spec?: NodeSpec;
|
||||
status?: NodeStatus;
|
||||
}
|
||||
|
||||
/** Check if a node is Ready. */
|
||||
export function isNodeReady(node: KubeVipNode): boolean {
|
||||
return node.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
|
||||
}
|
||||
|
||||
/** Get the InternalIP of a node. */
|
||||
export function getNodeInternalIP(node: KubeVipNode): string {
|
||||
return node.status?.addresses?.find(a => a.type === 'InternalIP')?.address ?? '—';
|
||||
}
|
||||
|
||||
/** Check if a node is a control plane node. */
|
||||
export function isControlPlaneNode(node: KubeVipNode): boolean {
|
||||
const labels = node.metadata.labels ?? {};
|
||||
return (
|
||||
'node-role.kubernetes.io/control-plane' in labels || 'node-role.kubernetes.io/master' in labels
|
||||
);
|
||||
}
|
||||
|
||||
/** Get kube-vip VIP label from a node (if node labeling is enabled). */
|
||||
export function getNodeVipLabel(node: KubeVipNode): string | undefined {
|
||||
const labels = node.metadata.labels ?? {};
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
if (key.startsWith('kube-vip.io/has-ip=')) return value;
|
||||
if (key === 'kube-vip.io/has-ip') return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pod
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ContainerStatus {
|
||||
name: string;
|
||||
ready: boolean;
|
||||
restartCount: number;
|
||||
image?: string;
|
||||
state?: {
|
||||
running?: { startedAt?: string };
|
||||
waiting?: { reason?: string; message?: string };
|
||||
terminated?: { exitCode?: number; reason?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface PodStatus {
|
||||
phase?: string;
|
||||
conditions?: Array<{ type: string; status: string }>;
|
||||
containerStatuses?: ContainerStatus[];
|
||||
hostIP?: string;
|
||||
podIP?: string;
|
||||
}
|
||||
|
||||
export interface PodSpec {
|
||||
nodeName?: string;
|
||||
hostNetwork?: boolean;
|
||||
containers?: Array<{
|
||||
name: string;
|
||||
image?: string;
|
||||
env?: Array<{ name: string; value?: string }>;
|
||||
args?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface KubeVipPod extends KubeObject {
|
||||
spec?: PodSpec;
|
||||
status?: PodStatus;
|
||||
}
|
||||
|
||||
/** Check if a pod is Ready. */
|
||||
export function isPodReady(pod: KubeVipPod): boolean {
|
||||
return pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
|
||||
}
|
||||
|
||||
/** Get total restarts for a pod. */
|
||||
export function getPodRestarts(pod: KubeVipPod): number {
|
||||
return pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0;
|
||||
}
|
||||
|
||||
/** Get the container image for a pod. */
|
||||
export function getPodImage(pod: KubeVipPod): string {
|
||||
return pod.spec?.containers?.[0]?.image ?? pod.status?.containerStatuses?.[0]?.image ?? 'unknown';
|
||||
}
|
||||
|
||||
/** Extract kube-vip configuration from pod environment variables. */
|
||||
export function extractPodConfig(pod: KubeVipPod): Record<string, string> {
|
||||
const config: Record<string, string> = {};
|
||||
const env = pod.spec?.containers?.[0]?.env;
|
||||
if (!env) return config;
|
||||
for (const e of env) {
|
||||
if (e.value !== undefined) {
|
||||
config[e.name] = e.value;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DaemonSet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DaemonSetStatus {
|
||||
currentNumberScheduled?: number;
|
||||
desiredNumberScheduled?: number;
|
||||
numberReady?: number;
|
||||
numberAvailable?: number;
|
||||
numberMisscheduled?: number;
|
||||
updatedNumberScheduled?: number;
|
||||
}
|
||||
|
||||
export interface DaemonSetSpec {
|
||||
selector?: { matchLabels?: Record<string, string> };
|
||||
template?: {
|
||||
spec?: PodSpec;
|
||||
};
|
||||
}
|
||||
|
||||
export interface KubeVipDaemonSet extends KubeObject {
|
||||
spec?: DaemonSetSpec;
|
||||
status?: DaemonSetStatus;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lease (leader election)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LeaseSpec {
|
||||
holderIdentity?: string;
|
||||
leaseDurationSeconds?: number;
|
||||
acquireTime?: string;
|
||||
renewTime?: string;
|
||||
leaseTransitions?: number;
|
||||
}
|
||||
|
||||
export interface KubeVipLease extends KubeObject {
|
||||
spec?: LeaseSpec;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigMap (IP pool configuration for kube-vip-cloud-provider)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KubeVipConfigMap extends KubeObject {
|
||||
data?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Parse IP pool ranges from the kubevip ConfigMap data. */
|
||||
export function parseIPPools(data: Record<string, string> | undefined): IPPool[] {
|
||||
if (!data) return [];
|
||||
const pools: IPPool[] = [];
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key.startsWith('range-') || key.startsWith('cidr-')) {
|
||||
pools.push({
|
||||
name: key,
|
||||
type: key.startsWith('range-') ? 'range' : 'cidr',
|
||||
value,
|
||||
scope: 'global',
|
||||
});
|
||||
} else if (key.includes('/')) {
|
||||
// Namespace-specific pool: "namespace/range-name" or "namespace/cidr-name"
|
||||
const [ns, poolName] = key.split('/', 2);
|
||||
const type = poolName.startsWith('range-')
|
||||
? 'range'
|
||||
: poolName.startsWith('cidr-')
|
||||
? 'cidr'
|
||||
: 'unknown';
|
||||
pools.push({
|
||||
name: poolName,
|
||||
type,
|
||||
value,
|
||||
scope: 'namespace',
|
||||
namespace: ns,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pools;
|
||||
}
|
||||
|
||||
export interface IPPool {
|
||||
name: string;
|
||||
type: 'range' | 'cidr' | 'unknown';
|
||||
value: string;
|
||||
scope: 'global' | 'namespace';
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatAge(timestamp: string | undefined): string {
|
||||
if (!timestamp) return 'unknown';
|
||||
const diffMs = Date.now() - new Date(timestamp).getTime();
|
||||
const secs = Math.floor(diffMs / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
export function phaseToStatus(phase: string | undefined): 'success' | 'warning' | 'error' {
|
||||
switch (phase) {
|
||||
case 'Running':
|
||||
case 'Active':
|
||||
case 'Ready':
|
||||
case 'Bound':
|
||||
return 'success';
|
||||
case 'Pending':
|
||||
case 'Terminating':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user