29f19e2346
Three files had formatting inconsistencies causing the format:check CI step to fail on main since 2026-03-04. Fixes #3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
445 lines
13 KiB
TypeScript
445 lines
13 KiB
TypeScript
/**
|
|
* kbench integration: Job/PVC lifecycle management and FIO log parsing.
|
|
*
|
|
* kbench (https://github.com/longhorn/kbench) runs as a Kubernetes Job backed
|
|
* by a PVC. Results are parsed from pod logs after job completion.
|
|
*/
|
|
|
|
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface KbenchMetricGroup {
|
|
randomRead: number;
|
|
randomWrite: number;
|
|
sequentialRead: number;
|
|
sequentialWrite: number;
|
|
cpuIdleness: number;
|
|
}
|
|
|
|
export interface KbenchResult {
|
|
iops: KbenchMetricGroup;
|
|
bandwidth: KbenchMetricGroup; // KiB/s
|
|
latency: KbenchMetricGroup; // nanoseconds
|
|
metadata: KbenchResultMetadata;
|
|
}
|
|
|
|
export interface KbenchResultMetadata {
|
|
storageClass: string;
|
|
size: string;
|
|
startedAt: string;
|
|
completedAt: string;
|
|
jobName: string;
|
|
namespace: string;
|
|
}
|
|
|
|
export type BenchmarkStatus =
|
|
| 'idle'
|
|
| 'creating-pvc'
|
|
| 'waiting-pvc'
|
|
| 'running'
|
|
| 'parsing'
|
|
| 'complete'
|
|
| 'failed';
|
|
|
|
export type BenchmarkState =
|
|
| { status: 'idle' }
|
|
| { status: 'creating-pvc' }
|
|
| { status: 'waiting-pvc'; pvcName: string }
|
|
| { status: 'running'; jobName: string; pvcName: string; startedAt: string }
|
|
| { status: 'parsing'; jobName: string; pvcName: string }
|
|
| { status: 'complete'; result: KbenchResult; jobName: string; pvcName: string }
|
|
| { status: 'failed'; error: string; jobName: string; pvcName: string };
|
|
|
|
export interface KbenchJobSummary {
|
|
jobName: string;
|
|
namespace: string;
|
|
storageClass: string;
|
|
phase: 'Active' | 'Complete' | 'Failed' | 'Unknown';
|
|
startedAt: string;
|
|
completedAt?: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Labels / annotations used for tracking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const KBENCH_MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by';
|
|
export const KBENCH_MANAGED_BY_VALUE = 'headlamp-tns-csi-plugin';
|
|
export const KBENCH_FIO_LABEL = 'kbench';
|
|
export const KBENCH_FIO_VALUE = 'fio';
|
|
export const KBENCH_STORAGE_CLASS_ANNOTATION = 'tns-csi.headlamp/storage-class';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unique name generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function shortId(): string {
|
|
return Math.random().toString(36).slice(2, 8);
|
|
}
|
|
|
|
export function generateJobName(): string {
|
|
return `kbench-${shortId()}`;
|
|
}
|
|
|
|
export function generatePvcName(jobName: string): string {
|
|
return `${jobName}-pvc`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Kubernetes manifest builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface KbenchJobOptions {
|
|
jobName: string;
|
|
pvcName: string;
|
|
namespace: string;
|
|
storageClass: string;
|
|
size?: string; // default "30G"
|
|
mode?: string; // default "full"
|
|
}
|
|
|
|
export function buildPvcManifest(opts: KbenchJobOptions): object {
|
|
return {
|
|
apiVersion: 'v1',
|
|
kind: 'PersistentVolumeClaim',
|
|
metadata: {
|
|
name: opts.pvcName,
|
|
namespace: opts.namespace,
|
|
labels: {
|
|
[KBENCH_MANAGED_BY_LABEL]: KBENCH_MANAGED_BY_VALUE,
|
|
[KBENCH_FIO_LABEL]: KBENCH_FIO_VALUE,
|
|
},
|
|
annotations: {
|
|
[KBENCH_STORAGE_CLASS_ANNOTATION]: opts.storageClass,
|
|
},
|
|
},
|
|
spec: {
|
|
storageClassName: opts.storageClass,
|
|
accessModes: ['ReadWriteOnce'],
|
|
resources: {
|
|
requests: {
|
|
// kbench needs ~33Gi for a 30G test (10% buffer rule)
|
|
storage: '33Gi',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildJobManifest(opts: KbenchJobOptions): object {
|
|
return {
|
|
apiVersion: 'batch/v1',
|
|
kind: 'Job',
|
|
metadata: {
|
|
name: opts.jobName,
|
|
namespace: opts.namespace,
|
|
labels: {
|
|
[KBENCH_MANAGED_BY_LABEL]: KBENCH_MANAGED_BY_VALUE,
|
|
[KBENCH_FIO_LABEL]: KBENCH_FIO_VALUE,
|
|
},
|
|
annotations: {
|
|
[KBENCH_STORAGE_CLASS_ANNOTATION]: opts.storageClass,
|
|
},
|
|
},
|
|
spec: {
|
|
template: {
|
|
metadata: {
|
|
labels: {
|
|
[KBENCH_FIO_LABEL]: KBENCH_FIO_VALUE,
|
|
},
|
|
},
|
|
spec: {
|
|
containers: [
|
|
{
|
|
name: 'kbench',
|
|
image: 'yasker/kbench:latest',
|
|
env: [
|
|
{ name: 'MODE', value: opts.mode ?? 'full' },
|
|
{ name: 'FILE_NAME', value: '/volume/test' },
|
|
{ name: 'SIZE', value: opts.size ?? '30G' },
|
|
{ name: 'CPU_IDLE_PROF', value: 'disabled' },
|
|
],
|
|
volumeMounts: [{ name: 'vol', mountPath: '/volume/' }],
|
|
},
|
|
],
|
|
restartPolicy: 'Never',
|
|
volumes: [
|
|
{
|
|
name: 'vol',
|
|
persistentVolumeClaim: { claimName: opts.pvcName },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
backoffLimit: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function createPvc(opts: KbenchJobOptions): Promise<void> {
|
|
await ApiProxy.request(`/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(buildPvcManifest(opts)),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
export async function createJob(opts: KbenchJobOptions): Promise<void> {
|
|
await ApiProxy.request(`/apis/batch/v1/namespaces/${opts.namespace}/jobs`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(buildJobManifest(opts)),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
interface K8sJobStatus {
|
|
active?: number;
|
|
succeeded?: number;
|
|
failed?: number;
|
|
completionTime?: string;
|
|
}
|
|
|
|
interface K8sJob {
|
|
status?: K8sJobStatus;
|
|
metadata?: { creationTimestamp?: string };
|
|
}
|
|
|
|
export type JobPhase = 'Active' | 'Complete' | 'Failed' | 'Unknown';
|
|
|
|
export async function getJobPhase(
|
|
jobName: string,
|
|
namespace: string
|
|
): Promise<{ phase: JobPhase; job: K8sJob }> {
|
|
const job = (await ApiProxy.request(
|
|
`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`
|
|
)) as K8sJob;
|
|
|
|
const status = job.status;
|
|
let phase: JobPhase = 'Unknown';
|
|
if (status?.succeeded && status.succeeded > 0) phase = 'Complete';
|
|
else if (status?.failed && status.failed > 0) phase = 'Failed';
|
|
else if (status?.active && status.active > 0) phase = 'Active';
|
|
|
|
return { phase, job };
|
|
}
|
|
|
|
export async function getPvcPhase(pvcName: string, namespace: string): Promise<string> {
|
|
const pvc = (await ApiProxy.request(
|
|
`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`
|
|
)) as { status?: { phase?: string } };
|
|
return pvc.status?.phase ?? 'Unknown';
|
|
}
|
|
|
|
/**
|
|
* Fetches the logs from the kbench pod (via the Job's pod selector).
|
|
* Uses the pod label selector to find the pod.
|
|
*/
|
|
export async function fetchKbenchLogs(jobName: string, namespace: string): Promise<string> {
|
|
// Find pod with label kbench=fio and job-name=<jobName>
|
|
const podList = (await ApiProxy.request(
|
|
`/api/v1/namespaces/${namespace}/pods?labelSelector=${encodeURIComponent(
|
|
`job-name=${jobName}`
|
|
)}`
|
|
)) as { items?: Array<{ metadata?: { name?: string } }> };
|
|
|
|
const podName = podList.items?.[0]?.metadata?.name;
|
|
if (!podName) {
|
|
throw new Error(`No pod found for kbench job "${jobName}"`);
|
|
}
|
|
|
|
const logs = (await ApiProxy.request(
|
|
`/api/v1/namespaces/${namespace}/pods/${podName}/log?container=kbench`,
|
|
{ isJSON: false }
|
|
)) as unknown;
|
|
|
|
if (typeof logs !== 'string') {
|
|
throw new Error('Pod logs were not returned as text');
|
|
}
|
|
|
|
return logs;
|
|
}
|
|
|
|
export async function deleteJob(jobName: string, namespace: string): Promise<void> {
|
|
await ApiProxy.request(`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`, {
|
|
method: 'DELETE',
|
|
body: JSON.stringify({
|
|
apiVersion: 'v1',
|
|
kind: 'DeleteOptions',
|
|
propagationPolicy: 'Foreground',
|
|
}),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
export async function deletePvc(pvcName: string, namespace: string): Promise<void> {
|
|
await ApiProxy.request(`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FIO log parser
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parses a kbench FIO benchmark summary from pod log text.
|
|
*
|
|
* Expected format:
|
|
* =====================
|
|
* FIO Benchmark Summary
|
|
* ...
|
|
* IOPS (Read/Write)
|
|
* Random: 98368 / 89200
|
|
* Sequential: 108513 / 107636
|
|
* CPU Idleness: 68%
|
|
*
|
|
* Bandwidth in KiB/sec (Read/Write)
|
|
* Random: 542447 / 514487
|
|
* ...
|
|
*
|
|
* Latency in ns (Read/Write)
|
|
* ...
|
|
*/
|
|
export function parseKbenchLog(logText: string): KbenchResult | null {
|
|
const lines = logText.split('\n').map(l => l.trim());
|
|
|
|
function extractSection(header: string): string[] {
|
|
const idx = lines.findIndex(l => l.startsWith(header));
|
|
if (idx < 0) return [];
|
|
const section: string[] = [];
|
|
for (let i = idx + 1; i < lines.length && i < idx + 10; i++) {
|
|
const line = lines[i];
|
|
if (!line) break;
|
|
section.push(line);
|
|
}
|
|
return section;
|
|
}
|
|
|
|
function parseReadWrite(line: string): [number, number] | null {
|
|
const match = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)/.exec(line);
|
|
if (!match) return null;
|
|
const read = parseInt(match[1].replace(/,/g, ''), 10);
|
|
const write = parseInt(match[2].replace(/,/g, ''), 10);
|
|
if (!Number.isFinite(read) || !Number.isFinite(write)) return null;
|
|
return [read, write];
|
|
}
|
|
|
|
function parseCpu(line: string): number {
|
|
const match = /(\d+)%/.exec(line);
|
|
return match ? parseInt(match[1], 10) : 0;
|
|
}
|
|
|
|
function parseSection(header: string): KbenchMetricGroup | null {
|
|
const section = extractSection(header);
|
|
if (section.length === 0) return null;
|
|
|
|
const randomLine = section.find(l => l.startsWith('Random:'));
|
|
const seqLine = section.find(l => l.startsWith('Sequential:'));
|
|
const cpuLine = section.find(l => l.startsWith('CPU Idleness:'));
|
|
|
|
const random = randomLine ? parseReadWrite(randomLine) : null;
|
|
const sequential = seqLine ? parseReadWrite(seqLine) : null;
|
|
const cpu = cpuLine ? parseCpu(cpuLine) : 0;
|
|
|
|
if (!random || !sequential) return null;
|
|
|
|
return {
|
|
randomRead: random[0],
|
|
randomWrite: random[1],
|
|
sequentialRead: sequential[0],
|
|
sequentialWrite: sequential[1],
|
|
cpuIdleness: cpu,
|
|
};
|
|
}
|
|
|
|
const iops = parseSection('IOPS (Read/Write)');
|
|
const bandwidth = parseSection('Bandwidth in KiB/sec (Read/Write)');
|
|
const latency = parseSection('Latency in ns (Read/Write)');
|
|
|
|
if (!iops || !bandwidth || !latency) return null;
|
|
|
|
return {
|
|
iops,
|
|
bandwidth,
|
|
latency,
|
|
metadata: {
|
|
storageClass: '', // filled in by the caller
|
|
size: '30G',
|
|
startedAt: '',
|
|
completedAt: new Date().toISOString(),
|
|
jobName: '',
|
|
namespace: '',
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// List existing kbench Jobs (for Past Benchmarks view)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function listKbenchJobs(namespace: string = ''): Promise<KbenchJobSummary[]> {
|
|
const selector = encodeURIComponent(
|
|
`${KBENCH_MANAGED_BY_LABEL}=${KBENCH_MANAGED_BY_VALUE},${KBENCH_FIO_LABEL}=${KBENCH_FIO_VALUE}`
|
|
);
|
|
const path = namespace
|
|
? `/apis/batch/v1/namespaces/${namespace}/jobs?labelSelector=${selector}`
|
|
: `/apis/batch/v1/jobs?labelSelector=${selector}`;
|
|
|
|
const list = (await ApiProxy.request(path)) as {
|
|
items?: Array<{
|
|
metadata?: {
|
|
name?: string;
|
|
namespace?: string;
|
|
annotations?: Record<string, string>;
|
|
creationTimestamp?: string;
|
|
};
|
|
status?: K8sJobStatus;
|
|
}>;
|
|
};
|
|
|
|
return (list.items ?? []).map(job => {
|
|
const status = job.status;
|
|
let phase: JobPhase = 'Unknown';
|
|
if (status?.succeeded && status.succeeded > 0) phase = 'Complete';
|
|
else if (status?.failed && status.failed > 0) phase = 'Failed';
|
|
else if (status?.active && status.active > 0) phase = 'Active';
|
|
|
|
return {
|
|
jobName: job.metadata?.name ?? '',
|
|
namespace: job.metadata?.namespace ?? namespace,
|
|
storageClass: job.metadata?.annotations?.[KBENCH_STORAGE_CLASS_ANNOTATION] ?? '—',
|
|
phase,
|
|
startedAt: job.metadata?.creationTimestamp ?? '',
|
|
completedAt: status?.completionTime,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Formatting helpers for result display
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function formatIops(value: number): string {
|
|
return value.toLocaleString();
|
|
}
|
|
|
|
export function formatBandwidth(kib: number): string {
|
|
const mib = kib / 1024;
|
|
if (mib >= 1024) return `${(mib / 1024).toFixed(1)} GiB/s`;
|
|
if (mib >= 1) return `${mib.toFixed(0)} MiB/s`;
|
|
return `${kib.toFixed(0)} KiB/s`;
|
|
}
|
|
|
|
export function formatLatency(ns: number): string {
|
|
if (ns >= 1_000_000) return `${(ns / 1_000_000).toFixed(2)} ms`;
|
|
if (ns >= 1_000) return `${(ns / 1_000).toFixed(1)} µs`;
|
|
return `${ns} ns`;
|
|
}
|