feat: initial release of headlamp-rook-ceph-plugin v0.1.0

Headlamp plugin for Rook-Ceph cluster visibility.

Pages:
- Overview dashboard: CephCluster health, capacity bar, resource counts
  (block pools, filesystems, object stores, PVs, PVCs), daemon pod
  health summary, non-Bound PVC alerts
- Block Pools: CephBlockPool table with replication, failure domain,
  mirroring; slide-in detail panel
- Pods: all Rook-Ceph daemon pods grouped by role with ready/total counts

Native Headlamp integrations:
- StorageClass table: Rook Type, Pool, Cluster ID columns
- PV table: Rook Type, Pool columns
- PVC detail injection: driver, type, pool, volume handle
- PV detail injection: CSI volume attributes
- Pod detail injection: Ceph daemon role badge
- App bar badge: cluster health (HEALTH_OK/WARN/ERR), color-coded

API / architecture:
- src/api/k8s.ts: types + filters for ceph.rook.io/v1 CRDs; handles
  both default rook-ceph.* and custom-namespace provisioner strings
- src/api/RookCephDataContext.tsx: shared context provider; fetches
  CephCluster, CephBlockPool, CephFilesystem, CephObjectStore CRDs
  plus daemon pods via label selectors
- 37 unit tests (vitest + @testing-library/react)
- TypeScript strict mode, zero any types
- CI + release GitHub Actions workflows

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
2026-02-18 16:55:39 -05:00
commit 25175b65b8
30 changed files with 21588 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
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: {
StorageClass: {
useList: vi.fn(() => [[], null]),
},
PersistentVolume: {
useList: vi.fn(() => [[], null]),
},
PersistentVolumeClaim: {
useList: vi.fn(() => [[], null]),
},
},
},
}));
import { RookCephDataProvider, useRookCephContext } from './RookCephDataContext';
describe('useRookCephContext', () => {
it('throws when used outside RookCephDataProvider', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useRookCephContext());
}).toThrow('useRookCephContext must be used within a RookCephDataProvider');
spy.mockRestore();
});
it('returns context value when inside RookCephDataProvider', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RookCephDataProvider>{children}</RookCephDataProvider>
);
const { result } = renderHook(() => useRookCephContext(), { wrapper });
expect(result.current).toBeDefined();
expect(result.current.storageClasses).toBeInstanceOf(Array);
expect(result.current.persistentVolumes).toBeInstanceOf(Array);
expect(result.current.persistentVolumeClaims).toBeInstanceOf(Array);
expect(result.current.cephClusters).toBeInstanceOf(Array);
expect(result.current.blockPools).toBeInstanceOf(Array);
expect(result.current.filesystems).toBeInstanceOf(Array);
expect(result.current.objectStores).toBeInstanceOf(Array);
expect(result.current.operatorPods).toBeInstanceOf(Array);
expect(result.current.monPods).toBeInstanceOf(Array);
expect(result.current.osdPods).toBeInstanceOf(Array);
expect(result.current.mgrPods).toBeInstanceOf(Array);
expect(typeof result.current.refresh).toBe('function');
});
});
+331
View File
@@ -0,0 +1,331 @@
/**
* RookCephDataContext — shared data provider for Rook-Ceph Kubernetes resources.
*
* Fetches CephCluster, CephBlockPool, CephFilesystem, CephObjectStore CRDs
* plus StorageClasses, PVs, PVCs, and Rook-Ceph pods via Headlamp hooks and
* ApiProxy. 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 {
CephBlockPool,
CephCluster,
CephFilesystem,
CephObjectStore,
filterRookCephPersistentVolumes,
filterRookCephPVCs,
filterRookCephStorageClasses,
isKubeList,
ROOK_CEPH_NAMESPACE,
RookCephPersistentVolume,
RookCephPVC,
RookCephPod,
RookCephStorageClass,
ROOK_CSI_CEPHFS_SELECTOR,
ROOK_CSI_RBD_SELECTOR,
ROOK_MGR_SELECTOR,
ROOK_MON_SELECTOR,
ROOK_OSD_SELECTOR,
ROOK_OPERATOR_SELECTOR,
} from './k8s';
// ---------------------------------------------------------------------------
// Context shape
// ---------------------------------------------------------------------------
export interface RookCephContextValue {
// Cluster presence
cephClusters: CephCluster[];
clusterInstalled: boolean;
// Core CRD resources
blockPools: CephBlockPool[];
filesystems: CephFilesystem[];
objectStores: CephObjectStore[];
// Core K8s resources (filtered to Rook-Ceph only)
storageClasses: RookCephStorageClass[];
persistentVolumes: RookCephPersistentVolume[];
persistentVolumeClaims: RookCephPVC[];
// Operator / daemon pods
operatorPods: RookCephPod[];
monPods: RookCephPod[];
osdPods: RookCephPod[];
mgrPods: RookCephPod[];
csiRbdPods: RookCephPod[];
csiCephfsPods: RookCephPod[];
// Loading / error state
loading: boolean;
error: string | null;
// Manual refresh trigger
refresh: () => void;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const RookCephContext = createContext<RookCephContextValue | null>(null);
export function useRookCephContext(): RookCephContextValue {
const ctx = useContext(RookCephContext);
if (!ctx) {
throw new Error('useRookCephContext must be used within a RookCephDataProvider');
}
return ctx;
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function RookCephDataProvider({ children }: { children: React.ReactNode }) {
// K8s resource hooks — Headlamp re-fetches on cluster changes automatically
const [allStorageClasses, scError] = K8s.ResourceClasses.StorageClass.useList();
const [allPvs, pvError] = K8s.ResourceClasses.PersistentVolume.useList();
const [allPvcs, pvcError] = K8s.ResourceClasses.PersistentVolumeClaim.useList({ namespace: '' });
// Async-fetched resources (CRDs, pods)
const [cephClusters, setCephClusters] = useState<CephCluster[]>([]);
const [blockPools, setBlockPools] = useState<CephBlockPool[]>([]);
const [filesystems, setFilesystems] = useState<CephFilesystem[]>([]);
const [objectStores, setObjectStores] = useState<CephObjectStore[]>([]);
const [operatorPods, setOperatorPods] = useState<RookCephPod[]>([]);
const [monPods, setMonPods] = useState<RookCephPod[]>([]);
const [osdPods, setOsdPods] = useState<RookCephPod[]>([]);
const [mgrPods, setMgrPods] = useState<RookCephPod[]>([]);
const [csiRbdPods, setCsiRbdPods] = useState<RookCephPod[]>([]);
const [csiCephfsPods, setCsiCephfsPods] = useState<RookCephPod[]>([]);
const [asyncLoading, setAsyncLoading] = useState(true);
const [asyncError, setAsyncError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => {
setRefreshKey(k => k + 1);
}, []);
useEffect(() => {
let cancelled = false;
async function fetchAsync() {
setAsyncLoading(true);
setAsyncError(null);
try {
// CephCluster CRDs
try {
const clusterList = await ApiProxy.request(
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephclusters`
);
if (!cancelled && isKubeList(clusterList)) {
setCephClusters(clusterList.items as CephCluster[]);
}
} catch {
if (!cancelled) setCephClusters([]);
}
// CephBlockPool CRDs
try {
const poolList = await ApiProxy.request(
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephblockpools`
);
if (!cancelled && isKubeList(poolList)) {
setBlockPools(poolList.items as CephBlockPool[]);
}
} catch {
if (!cancelled) setBlockPools([]);
}
// CephFilesystem CRDs
try {
const fsList = await ApiProxy.request(
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephfilesystems`
);
if (!cancelled && isKubeList(fsList)) {
setFilesystems(fsList.items as CephFilesystem[]);
}
} catch {
if (!cancelled) setFilesystems([]);
}
// CephObjectStore CRDs
try {
const osList = await ApiProxy.request(
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephobjectstores`
);
if (!cancelled && isKubeList(osList)) {
setObjectStores(osList.items as CephObjectStore[]);
}
} catch {
if (!cancelled) setObjectStores([]);
}
// Operator pods
try {
const opList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_OPERATOR_SELECTOR)}`
);
if (!cancelled && isKubeList(opList)) setOperatorPods(opList.items as RookCephPod[]);
} catch {
if (!cancelled) setOperatorPods([]);
}
// MON pods
try {
const monList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_MON_SELECTOR)}`
);
if (!cancelled && isKubeList(monList)) setMonPods(monList.items as RookCephPod[]);
} catch {
if (!cancelled) setMonPods([]);
}
// OSD pods
try {
const osdList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_OSD_SELECTOR)}`
);
if (!cancelled && isKubeList(osdList)) setOsdPods(osdList.items as RookCephPod[]);
} catch {
if (!cancelled) setOsdPods([]);
}
// MGR pods
try {
const mgrList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_MGR_SELECTOR)}`
);
if (!cancelled && isKubeList(mgrList)) setMgrPods(mgrList.items as RookCephPod[]);
} catch {
if (!cancelled) setMgrPods([]);
}
// CSI RBD provisioner pods
try {
const csiRbdList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_CSI_RBD_SELECTOR)}`
);
if (!cancelled && isKubeList(csiRbdList)) setCsiRbdPods(csiRbdList.items as RookCephPod[]);
} catch {
if (!cancelled) setCsiRbdPods([]);
}
// CSI CephFS provisioner pods
try {
const csiCephfsList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_CSI_CEPHFS_SELECTOR)}`
);
if (!cancelled && isKubeList(csiCephfsList)) setCsiCephfsPods(csiCephfsList.items as RookCephPod[]);
} catch {
if (!cancelled) setCsiCephfsPods([]);
}
} 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 — memoized to avoid recomputation on every render
// ---------------------------------------------------------------------------
// Headlamp useList() returns KubeObject class instances that store raw
// Kubernetes JSON under `.jsonData`. Extract it so our plain-object helpers
// work correctly.
const extractJsonData = (items: unknown[]): unknown[] =>
items.map(item =>
item && typeof item === 'object' && 'jsonData' in item
? (item as { jsonData: unknown }).jsonData
: item
);
const storageClasses = useMemo(() => {
if (!allStorageClasses) return [];
return filterRookCephStorageClasses(extractJsonData(allStorageClasses as unknown[]));
}, [allStorageClasses]);
const persistentVolumes = useMemo(() => {
if (!allPvs) return [];
return filterRookCephPersistentVolumes(extractJsonData(allPvs as unknown[]));
}, [allPvs]);
const persistentVolumeClaims = useMemo(() => {
if (!allPvcs || persistentVolumes.length === 0) return [];
return filterRookCephPVCs(
extractJsonData(allPvcs as unknown[]) as RookCephPVC[],
persistentVolumes
);
}, [allPvcs, persistentVolumes]);
// ---------------------------------------------------------------------------
// Combined loading / error state
// ---------------------------------------------------------------------------
const loading = asyncLoading || !allStorageClasses || !allPvs || !allPvcs;
const errors: string[] = [];
if (scError) errors.push(String(scError));
if (pvError) errors.push(String(pvError));
if (pvcError) errors.push(String(pvcError));
if (asyncError) errors.push(asyncError);
const error = errors.length > 0 ? errors.join('; ') : null;
const clusterInstalled = cephClusters.length > 0;
// ---------------------------------------------------------------------------
// Memoized context value
// ---------------------------------------------------------------------------
const value = useMemo<RookCephContextValue>(
() => ({
cephClusters,
clusterInstalled,
blockPools,
filesystems,
objectStores,
storageClasses,
persistentVolumes,
persistentVolumeClaims,
operatorPods,
monPods,
osdPods,
mgrPods,
csiRbdPods,
csiCephfsPods,
loading,
error,
refresh,
}),
[
cephClusters,
clusterInstalled,
blockPools,
filesystems,
objectStores,
storageClasses,
persistentVolumes,
persistentVolumeClaims,
operatorPods,
monPods,
osdPods,
mgrPods,
csiRbdPods,
csiCephfsPods,
loading,
error,
refresh,
]
);
return <RookCephContext.Provider value={value}>{children}</RookCephContext.Provider>;
}
+287
View File
@@ -0,0 +1,287 @@
import { describe, expect, it } from 'vitest';
import {
filterRookCephPersistentVolumes,
filterRookCephStorageClasses,
formatAge,
formatAccessModes,
formatBytes,
formatStorageType,
healthToStatus,
isKubeList,
isPodReady,
isRookCephPersistentVolume,
isRookCephProvisioner,
isRookCephStorageClass,
parseStorageToBytes,
phaseToStatus,
ROOK_CEPH_CEPHFS_PROVISIONER,
ROOK_CEPH_RBD_PROVISIONER,
storageClassType,
filterRookCephPVCs,
findBoundPv,
getPodRestarts,
} from './k8s';
describe('isRookCephProvisioner', () => {
it('recognises default namespace RBD provisioner', () => {
expect(isRookCephProvisioner(ROOK_CEPH_RBD_PROVISIONER)).toBe(true);
});
it('recognises default namespace CephFS provisioner', () => {
expect(isRookCephProvisioner(ROOK_CEPH_CEPHFS_PROVISIONER)).toBe(true);
});
it('recognises custom namespace provisioners', () => {
expect(isRookCephProvisioner('my-namespace.rbd.csi.ceph.com')).toBe(true);
expect(isRookCephProvisioner('my-namespace.cephfs.csi.ceph.com')).toBe(true);
});
it('rejects non-rook provisioners', () => {
expect(isRookCephProvisioner('tns.csi.io')).toBe(false);
expect(isRookCephProvisioner('ebs.csi.aws.com')).toBe(false);
expect(isRookCephProvisioner('')).toBe(false);
});
});
describe('isRookCephStorageClass', () => {
it('accepts a Rook-Ceph SC', () => {
const sc = { metadata: { name: 'rook-ceph-block' }, provisioner: ROOK_CEPH_RBD_PROVISIONER };
expect(isRookCephStorageClass(sc)).toBe(true);
});
it('rejects a non-Rook SC', () => {
const sc = { metadata: { name: 'other' }, provisioner: 'tns.csi.io' };
expect(isRookCephStorageClass(sc)).toBe(false);
});
it('rejects null / non-object', () => {
expect(isRookCephStorageClass(null)).toBe(false);
expect(isRookCephStorageClass('string')).toBe(false);
});
});
describe('filterRookCephStorageClasses', () => {
it('filters to Rook-Ceph only', () => {
const items = [
{ metadata: { name: 'rook-block' }, provisioner: ROOK_CEPH_RBD_PROVISIONER },
{ metadata: { name: 'other' }, provisioner: 'tns.csi.io' },
{ metadata: { name: 'rook-cephfs' }, provisioner: ROOK_CEPH_CEPHFS_PROVISIONER },
];
const result = filterRookCephStorageClasses(items);
expect(result).toHaveLength(2);
expect(result.map(s => s.metadata.name)).toEqual(['rook-block', 'rook-cephfs']);
});
});
describe('storageClassType', () => {
it('returns rbd for RBD provisioner', () => {
const sc = { metadata: { name: 'x' }, provisioner: ROOK_CEPH_RBD_PROVISIONER };
expect(storageClassType(sc)).toBe('rbd');
});
it('returns cephfs for CephFS provisioner', () => {
const sc = { metadata: { name: 'x' }, provisioner: ROOK_CEPH_CEPHFS_PROVISIONER };
expect(storageClassType(sc)).toBe('cephfs');
});
});
describe('isRookCephPersistentVolume', () => {
it('accepts a Rook-Ceph PV', () => {
const pv = {
metadata: { name: 'pvc-123' },
spec: { csi: { driver: ROOK_CEPH_RBD_PROVISIONER } },
};
expect(isRookCephPersistentVolume(pv)).toBe(true);
});
it('rejects a non-Rook PV', () => {
const pv = { metadata: { name: 'other' }, spec: { csi: { driver: 'tns.csi.io' } } };
expect(isRookCephPersistentVolume(pv)).toBe(false);
});
it('rejects PVs with no spec.csi', () => {
expect(isRookCephPersistentVolume({ metadata: { name: 'x' }, spec: {} })).toBe(false);
});
});
describe('filterRookCephPersistentVolumes', () => {
it('returns only Rook-Ceph PVs', () => {
const items = [
{ metadata: { name: 'pv-a' }, spec: { csi: { driver: ROOK_CEPH_RBD_PROVISIONER } } },
{ metadata: { name: 'pv-b' }, spec: { csi: { driver: 'tns.csi.io' } } },
];
expect(filterRookCephPersistentVolumes(items)).toHaveLength(1);
});
});
describe('filterRookCephPVCs', () => {
it('returns PVCs bound to Rook-Ceph PVs', () => {
const pvs = [
{
metadata: { name: 'pv-1' },
spec: {
csi: { driver: ROOK_CEPH_RBD_PROVISIONER },
claimRef: { name: 'my-pvc', namespace: 'default' },
},
},
];
const pvcs = [
{ metadata: { name: 'my-pvc', namespace: 'default' }, spec: {} },
{ metadata: { name: 'other-pvc', namespace: 'default' }, spec: {} },
];
const result = filterRookCephPVCs(pvcs as never, pvs as never);
expect(result).toHaveLength(1);
expect(result[0].metadata.name).toBe('my-pvc');
});
});
describe('findBoundPv', () => {
it('finds the matching PV', () => {
const pv = {
metadata: { name: 'pv-1' },
spec: {
csi: { driver: ROOK_CEPH_RBD_PROVISIONER },
claimRef: { name: 'my-pvc', namespace: 'default' },
},
};
const pvc = { metadata: { name: 'my-pvc', namespace: 'default' }, spec: {} };
const result = findBoundPv(pvc as never, [pv] as never);
expect(result?.metadata.name).toBe('pv-1');
});
});
describe('healthToStatus', () => {
it('maps health strings correctly', () => {
expect(healthToStatus('HEALTH_OK')).toBe('success');
expect(healthToStatus('HEALTH_WARN')).toBe('warning');
expect(healthToStatus('HEALTH_ERR')).toBe('error');
expect(healthToStatus(undefined)).toBe('error');
});
});
describe('phaseToStatus', () => {
it('maps phase strings correctly', () => {
expect(phaseToStatus('Ready')).toBe('success');
expect(phaseToStatus('Bound')).toBe('success');
expect(phaseToStatus('Progressing')).toBe('warning');
expect(phaseToStatus('Pending')).toBe('warning');
expect(phaseToStatus('Failed')).toBe('error');
expect(phaseToStatus(undefined)).toBe('error');
});
});
describe('isPodReady', () => {
it('returns true when Ready condition is True', () => {
const pod = {
metadata: { name: 'p' },
status: { conditions: [{ type: 'Ready', status: 'True' }] },
};
expect(isPodReady(pod as never)).toBe(true);
});
it('returns false when Ready condition is False', () => {
const pod = {
metadata: { name: 'p' },
status: { conditions: [{ type: 'Ready', status: 'False' }] },
};
expect(isPodReady(pod as never)).toBe(false);
});
});
describe('getPodRestarts', () => {
it('sums restart counts across containers', () => {
const pod = {
metadata: { name: 'p' },
status: {
containerStatuses: [
{ name: 'c1', ready: true, restartCount: 2 },
{ name: 'c2', ready: true, restartCount: 3 },
],
},
};
expect(getPodRestarts(pod as never)).toBe(5);
});
});
describe('formatAge', () => {
it('returns unknown for undefined', () => {
expect(formatAge(undefined)).toBe('unknown');
});
it('formats seconds', () => {
const ts = new Date(Date.now() - 30_000).toISOString();
expect(formatAge(ts)).toBe('30s');
});
it('formats minutes', () => {
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
expect(formatAge(ts)).toBe('5m');
});
it('formats hours', () => {
const ts = new Date(Date.now() - 3 * 3600_000).toISOString();
expect(formatAge(ts)).toBe('3h');
});
it('formats days', () => {
const ts = new Date(Date.now() - 2 * 86400_000).toISOString();
expect(formatAge(ts)).toBe('2d');
});
});
describe('formatAccessModes', () => {
it('abbreviates access modes', () => {
expect(formatAccessModes(['ReadWriteOnce'])).toBe('RWO');
expect(formatAccessModes(['ReadWriteMany', 'ReadOnlyMany'])).toBe('RWX, ROX');
});
it('returns — for empty', () => {
expect(formatAccessModes([])).toBe('—');
expect(formatAccessModes(undefined)).toBe('—');
});
});
describe('formatBytes', () => {
it('formats various byte sizes', () => {
expect(formatBytes(0)).toBe('0 B');
expect(formatBytes(1024)).toBe('1.0 KiB');
expect(formatBytes(1024 ** 2)).toBe('1.0 MiB');
expect(formatBytes(1024 ** 3)).toBe('1.0 GiB');
expect(formatBytes(1024 ** 4)).toBe('1.0 TiB');
});
});
describe('parseStorageToBytes', () => {
it('parses Gi suffix', () => {
expect(parseStorageToBytes('10Gi')).toBe(10 * 1024 ** 3);
});
it('parses Mi suffix', () => {
expect(parseStorageToBytes('512Mi')).toBe(512 * 1024 ** 2);
});
it('returns 0 for invalid', () => {
expect(parseStorageToBytes('invalid')).toBe(0);
});
});
describe('formatStorageType', () => {
it('formats storage types', () => {
expect(formatStorageType('rbd')).toBe('Block (RBD)');
expect(formatStorageType('cephfs')).toBe('Filesystem (CephFS)');
expect(formatStorageType('unknown')).toBe('Unknown');
});
});
describe('isKubeList', () => {
it('accepts objects with items array', () => {
expect(isKubeList({ items: [] })).toBe(true);
expect(isKubeList({ items: [1, 2] })).toBe(true);
});
it('rejects non-list shapes', () => {
expect(isKubeList(null)).toBe(false);
expect(isKubeList({})).toBe(false);
expect(isKubeList({ items: 'not-array' })).toBe(false);
});
});
+468
View File
@@ -0,0 +1,468 @@
/**
* Kubernetes type definitions and helper functions for Rook-Ceph resources.
*
* All K8s resource types are typed at the fields we actually use.
* External data from the API is validated at the boundary before use.
*/
// ---------------------------------------------------------------------------
// Provisioner constants (namespace-prefixed — default namespace: rook-ceph)
// ---------------------------------------------------------------------------
export const ROOK_CEPH_NAMESPACE = 'rook-ceph' as const;
export const ROOK_CEPH_API_GROUP = 'ceph.rook.io' as const;
export const ROOK_CEPH_API_VERSION = 'v1' as const;
/** RBD (block) provisioner — prefix matches operator namespace */
export const ROOK_CEPH_RBD_PROVISIONER = `${ROOK_CEPH_NAMESPACE}.rbd.csi.ceph.com` as const;
/** CephFS provisioner — prefix matches operator namespace */
export const ROOK_CEPH_CEPHFS_PROVISIONER = `${ROOK_CEPH_NAMESPACE}.cephfs.csi.ceph.com` as const;
/** Returns true if the provisioner string is a known Rook-Ceph provisioner. */
export function isRookCephProvisioner(provisioner: string): boolean {
return (
provisioner === ROOK_CEPH_RBD_PROVISIONER ||
provisioner === ROOK_CEPH_CEPHFS_PROVISIONER ||
// Handle non-default namespaces: ends with .rbd.csi.ceph.com or .cephfs.csi.ceph.com
provisioner.endsWith('.rbd.csi.ceph.com') ||
provisioner.endsWith('.cephfs.csi.ceph.com')
);
}
// ---------------------------------------------------------------------------
// Pod label selectors
// ---------------------------------------------------------------------------
export const ROOK_OPERATOR_SELECTOR = 'app=rook-ceph-operator';
export const ROOK_MON_SELECTOR = 'app=rook-ceph-mon';
export const ROOK_OSD_SELECTOR = 'app=rook-ceph-osd';
export const ROOK_MGR_SELECTOR = 'app=rook-ceph-mgr';
export const ROOK_MDS_SELECTOR = 'app=rook-ceph-mds';
export const ROOK_RGW_SELECTOR = 'app=rook-ceph-rgw';
export const ROOK_CSI_RBD_SELECTOR = 'app=csi-rbdplugin-provisioner';
export const ROOK_CSI_CEPHFS_SELECTOR = 'app=csi-cephfsplugin-provisioner';
// ---------------------------------------------------------------------------
// 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;
}
// ---------------------------------------------------------------------------
// CephCluster
// ---------------------------------------------------------------------------
export interface CephClusterStatusCeph {
health?: 'HEALTH_OK' | 'HEALTH_WARN' | 'HEALTH_ERR' | string;
lastChecked?: string;
capacity?: {
bytesAvailable?: number;
bytesTotal?: number;
bytesUsed?: number;
lastUpdated?: string;
};
}
export interface CephClusterStatusStorage {
deviceClasses?: Array<{ name: string }>;
osd?: { storeType?: Record<string, number> };
}
export interface CephClusterStatusVersion {
image?: string;
version?: string;
}
export interface CephClusterCondition {
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: string;
lastHeartbeatTime?: string;
}
export interface CephClusterStatus {
phase?: 'Ready' | 'Progressing' | 'Failed' | string;
state?: 'Created' | 'Updating' | 'Deleting' | string;
message?: string;
ceph?: CephClusterStatusCeph;
storage?: CephClusterStatusStorage;
version?: CephClusterStatusVersion;
conditions?: CephClusterCondition[];
}
export interface CephClusterSpec {
cephVersion?: { image?: string; allowUnsupported?: boolean };
dataDirHostPath?: string;
mon?: { count?: number; allowMultiplePerNode?: boolean };
mgr?: { count?: number };
dashboard?: { enabled?: boolean; ssl?: boolean };
monitoring?: { enabled?: boolean };
storage?: {
useAllNodes?: boolean;
useAllDevices?: boolean;
deviceFilter?: string;
nodes?: unknown[];
};
network?: { hostNetwork?: boolean };
resources?: Record<string, unknown>;
}
export interface CephCluster extends KubeObject {
spec?: CephClusterSpec;
status?: CephClusterStatus;
}
export function healthToStatus(health: string | undefined): 'success' | 'warning' | 'error' {
switch (health) {
case 'HEALTH_OK': return 'success';
case 'HEALTH_WARN': return 'warning';
default: return 'error';
}
}
export function phaseToStatus(phase: string | undefined): 'success' | 'warning' | 'error' {
switch (phase) {
case 'Ready':
case 'Bound':
case 'Available':
case 'Running':
case 'Succeeded':
return 'success';
case 'Progressing':
case 'Pending':
case 'Released':
return 'warning';
default:
return 'error';
}
}
// ---------------------------------------------------------------------------
// CephBlockPool
// ---------------------------------------------------------------------------
export interface CephBlockPoolSpec {
failureDomain?: string;
replicated?: { size?: number; requireSafeReplicaSize?: boolean };
erasureCoded?: { codingChunks?: number; dataChunks?: number };
parameters?: Record<string, string>;
mirroring?: { enabled?: boolean };
}
export interface CephBlockPoolStatus {
phase?: string;
info?: Record<string, string>;
conditions?: CephClusterCondition[];
}
export interface CephBlockPool extends KubeObject {
spec?: CephBlockPoolSpec;
status?: CephBlockPoolStatus;
}
// ---------------------------------------------------------------------------
// CephFilesystem
// ---------------------------------------------------------------------------
export interface CephFilesystemSpec {
metadataPool?: { replicated?: { size?: number } };
dataPools?: Array<{ name?: string; replicated?: { size?: number } }>;
metadataServer?: { activeCount?: number; activeStandby?: boolean };
}
export interface CephFilesystemStatus {
phase?: string;
conditions?: CephClusterCondition[];
info?: Record<string, string>;
}
export interface CephFilesystem extends KubeObject {
spec?: CephFilesystemSpec;
status?: CephFilesystemStatus;
}
// ---------------------------------------------------------------------------
// CephObjectStore
// ---------------------------------------------------------------------------
export interface CephObjectStoreSpec {
metadataPool?: { replicated?: { size?: number } };
dataPool?: { replicated?: { size?: number } };
gateway?: { port?: number; securePort?: number; instances?: number };
}
export interface CephObjectStoreStatus {
phase?: string;
conditions?: CephClusterCondition[];
info?: Record<string, string>;
}
export interface CephObjectStore extends KubeObject {
spec?: CephObjectStoreSpec;
status?: CephObjectStoreStatus;
}
// ---------------------------------------------------------------------------
// StorageClass (Rook-Ceph provisioned)
// ---------------------------------------------------------------------------
export interface RookCephStorageClass extends KubeObject {
provisioner: string;
reclaimPolicy?: string;
volumeBindingMode?: string;
allowVolumeExpansion?: boolean;
parameters?: Record<string, string>;
}
export function isRookCephStorageClass(sc: unknown): sc is RookCephStorageClass {
if (!sc || typeof sc !== 'object') return false;
const obj = sc as Record<string, unknown>;
const provisioner = obj['provisioner'];
return typeof provisioner === 'string' && isRookCephProvisioner(provisioner);
}
export function filterRookCephStorageClasses(items: unknown[]): RookCephStorageClass[] {
return items.filter(isRookCephStorageClass);
}
/** Returns 'rbd' or 'cephfs' based on provisioner string, or 'unknown'. */
export function storageClassType(sc: RookCephStorageClass): 'rbd' | 'cephfs' | 'unknown' {
if (sc.provisioner.includes('.rbd.')) return 'rbd';
if (sc.provisioner.includes('.cephfs.')) return 'cephfs';
return 'unknown';
}
// ---------------------------------------------------------------------------
// PersistentVolume (Rook-Ceph provisioned)
// ---------------------------------------------------------------------------
export interface RookCsiSpec {
driver: string;
volumeHandle?: string;
volumeAttributes?: Record<string, string>;
}
export interface ClaimRef {
name: string;
namespace: string;
}
export interface PersistentVolumeSpec {
csi?: RookCsiSpec;
capacity?: { storage?: string };
accessModes?: string[];
persistentVolumeReclaimPolicy?: string;
storageClassName?: string;
claimRef?: ClaimRef;
}
export interface RookCephPersistentVolume extends KubeObject {
spec: PersistentVolumeSpec;
status?: { phase?: string };
}
export function isRookCephPersistentVolume(pv: unknown): pv is RookCephPersistentVolume {
if (!pv || typeof pv !== 'object') return false;
const obj = pv as Record<string, unknown>;
const spec = obj['spec'] as Record<string, unknown> | undefined;
if (!spec) return false;
const csi = spec['csi'] as Record<string, unknown> | undefined;
const driver = csi?.['driver'];
return typeof driver === 'string' && isRookCephProvisioner(driver);
}
export function filterRookCephPersistentVolumes(items: unknown[]): RookCephPersistentVolume[] {
return items.filter(isRookCephPersistentVolume);
}
// ---------------------------------------------------------------------------
// PersistentVolumeClaim (Rook-Ceph)
// ---------------------------------------------------------------------------
export interface PVCSpec {
storageClassName?: string;
accessModes?: string[];
resources?: { requests?: { storage?: string } };
volumeName?: string;
}
export interface RookCephPVC extends KubeObject {
spec: PVCSpec;
status?: {
phase?: string;
capacity?: { storage?: string };
accessModes?: string[];
};
}
export function filterRookCephPVCs(
pvcs: RookCephPVC[],
rookPvs: RookCephPersistentVolume[]
): RookCephPVC[] {
const boundSet = new Set<string>();
for (const pv of rookPvs) {
const ref = pv.spec.claimRef;
if (ref) boundSet.add(`${ref.namespace}/${ref.name}`);
}
return pvcs.filter(pvc => {
const ns = pvc.metadata.namespace ?? '';
return boundSet.has(`${ns}/${pvc.metadata.name}`);
});
}
export function findBoundPv(
pvc: RookCephPVC,
rookPvs: RookCephPersistentVolume[]
): RookCephPersistentVolume | undefined {
const ns = pvc.metadata.namespace ?? '';
const name = pvc.metadata.name;
return rookPvs.find(
pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name
);
}
// ---------------------------------------------------------------------------
// 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[];
}
export interface PodSpec {
nodeName?: string;
}
export interface RookCephPod extends KubeObject {
spec?: PodSpec;
status?: PodStatus;
}
export function isPodReady(pod: RookCephPod): boolean {
return (
pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false
);
}
export function getPodRestarts(pod: RookCephPod): number {
return (
pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0
);
}
export function getPodImage(pod: RookCephPod): string {
return pod.status?.containerStatuses?.[0]?.image ?? 'unknown';
}
// ---------------------------------------------------------------------------
// 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']);
}
// ---------------------------------------------------------------------------
// 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`;
}
const ACCESS_MODE_ABBREV: Record<string, string> = {
ReadWriteOnce: 'RWO',
ReadWriteMany: 'RWX',
ReadOnlyMany: 'ROX',
ReadWriteOncePod: 'RWOP',
};
export function formatAccessModes(modes: string[] | undefined): string {
if (!modes || modes.length === 0) return '—';
return modes.map(m => ACCESS_MODE_ABBREV[m] ?? m).join(', ');
}
export function formatBytes(bytes: number): string {
if (bytes >= 1024 ** 4) return `${(bytes / 1024 ** 4).toFixed(1)} TiB`;
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${bytes} B`;
}
export function parseStorageToBytes(storage: string): number {
const match = /^(\d+(?:\.\d+)?)\s*(Ki|Mi|Gi|Ti|Pi|K|M|G|T|P)?$/.exec(storage.trim());
if (!match) return 0;
const value = parseFloat(match[1]);
const suffix = match[2] ?? '';
const multipliers: Record<string, number> = {
'': 1,
K: 1e3, Ki: 1024,
M: 1e6, Mi: 1024 ** 2,
G: 1e9, Gi: 1024 ** 3,
T: 1e12, Ti: 1024 ** 4,
P: 1e15, Pi: 1024 ** 5,
};
return value * (multipliers[suffix] ?? 1);
}
/** Returns display label for storage type (rbd → Block, cephfs → Filesystem). */
export function formatStorageType(type: 'rbd' | 'cephfs' | 'unknown'): string {
switch (type) {
case 'rbd': return 'Block (RBD)';
case 'cephfs': return 'Filesystem (CephFS)';
default: return 'Unknown';
}
}
/** Extracts pool/subvolume group name from a Rook-Ceph PV volumeHandle. */
export function extractPoolFromVolumeHandle(handle: string | undefined): string {
if (!handle) return '—';
// RBD format: "<csi-vol-id>-<pool>-..." — pool is in volumeAttributes
// We rely on volumeAttributes.pool instead; this just provides a fallback.
return handle;
}