Files
aiops/frontend/src/components/kubernetes/deployment-volume-utils.ts
T
Logan.Li a8305de0f5 fix(k8s): Deployment 存储挂载六种卷类型 YAML 双向映射
- 抽取 deployment-volume-utils,修正 PVC/NFS/CM/Secret/HostPath/EmptyDir 读写
- 保存时保留系统卷与 serviceAccount 挂载,合并用户卷与挂载点
- 挂载支持 subPath;ConfigMap/Secret items 支持 key:path 简写
2026-06-26 14:57:09 +08:00

290 lines
9.0 KiB
TypeScript

// Deployment Pod 数据卷 ↔ K8s YAML 双向映射 // Logan.Li
export type VolumeType =
| 'persistentVolumeClaim'
| 'emptyDir'
| 'hostPath'
| 'nfs'
| 'configMap'
| 'secret'
| 'custom';
export interface VolumeForm {
name: string;
type: VolumeType;
pvcName?: string;
pvcReadOnly?: boolean;
medium?: string;
sizeLimit?: string;
path?: string;
pathType?: string;
nfsServer?: string;
nfsPath?: string;
nfsReadOnly?: boolean;
configMapName?: string;
cmItems?: string;
secretName?: string;
secItems?: string;
/** 未识别卷类型时保留原始对象,避免保存丢失 // Logan.Li */
_raw?: Record<string, any>;
}
export interface MountForm {
containerName: string;
volumeName: string;
mountPath: string;
subPath: string;
readOnly: boolean;
}
const SYSTEM_VOLUME_PREFIXES = ['kube-api-access-', 'default-token-', 'kube-'];
/** 是否系统/不可编辑卷(保存时保留)// Logan.Li */
export function isPassthroughVolume(v: Record<string, any>): boolean {
if (!v || typeof v !== 'object') return false;
const name = String(v.name || '');
if (SYSTEM_VOLUME_PREFIXES.some((p) => name.startsWith(p))) return true;
return !!(v.projected || v.downwardAPI || v.csi || v.ephemeral);
}
/** 解析 KeyToPath:支持 JSON 或 key:path,key:path // Logan.Li */
export function parseKeyToPathItems(text?: string): Array<{ key: string; path: string }> | undefined {
const raw = text?.trim();
if (!raw) return undefined;
if (raw.startsWith('[')) {
try {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) return arr.filter((i) => i?.key && i?.path);
} catch { /* fallthrough */ }
}
const items = raw.split(',').map((s) => s.trim()).filter(Boolean).map((pair) => {
const idx = pair.indexOf(':');
if (idx <= 0) return null;
return { key: pair.slice(0, idx).trim(), path: pair.slice(idx + 1).trim() };
}).filter(Boolean) as Array<{ key: string; path: string }>;
return items.length ? items : undefined;
}
/** K8s volume → 表单 // Logan.Li */
export function volumeToForm(v: Record<string, any>): VolumeForm {
if (!v || typeof v !== 'object') return { name: '', type: 'emptyDir' };
const name = v.name || '';
if (v.persistentVolumeClaim) {
return {
name,
type: 'persistentVolumeClaim',
pvcName: v.persistentVolumeClaim.claimName || '',
pvcReadOnly: !!v.persistentVolumeClaim.readOnly,
};
}
if (v.nfs) {
return {
name,
type: 'nfs',
nfsServer: v.nfs.server || '',
nfsPath: v.nfs.path || '',
nfsReadOnly: !!v.nfs.readOnly,
};
}
if (v.configMap) {
return {
name,
type: 'configMap',
configMapName: v.configMap.name || '',
cmItems: v.configMap.items ? JSON.stringify(v.configMap.items) : '',
};
}
if (v.secret) {
return {
name,
type: 'secret',
secretName: v.secret.secretName || '',
secItems: v.secret.items ? JSON.stringify(v.secret.items) : '',
};
}
if (v.hostPath) {
return {
name,
type: 'hostPath',
path: v.hostPath.path || '',
pathType: v.hostPath.type || '',
};
}
if (v.emptyDir != null) {
return {
name,
type: 'emptyDir',
medium: v.emptyDir.medium || '',
sizeLimit: v.emptyDir.sizeLimit || '',
};
}
if (isPassthroughVolume(v)) {
return { name, type: 'custom', _raw: v };
}
return { name, type: 'custom', _raw: v };
}
/** 表单 → K8s volume // Logan.Li */
export function formToVolume(f: VolumeForm): Record<string, any> | null {
if (f.type === 'custom' && f._raw) return JSON.parse(JSON.stringify(f._raw));
const volName = f.name?.trim();
if (!volName) return null;
const v: Record<string, any> = { name: volName };
switch (f.type) {
case 'emptyDir': {
const ed: Record<string, any> = {};
if (f.medium?.trim()) ed.medium = f.medium.trim();
if (f.sizeLimit?.trim()) ed.sizeLimit = f.sizeLimit.trim();
v.emptyDir = ed;
break;
}
case 'hostPath': {
const hp: Record<string, any> = { path: f.path?.trim() || '/' };
if (f.pathType?.trim()) hp.type = f.pathType.trim();
v.hostPath = hp;
break;
}
case 'configMap': {
const cmName = f.configMapName?.trim();
if (!cmName) return null;
const cm: Record<string, any> = { name: cmName };
const items = parseKeyToPathItems(f.cmItems);
if (items) cm.items = items;
v.configMap = cm;
break;
}
case 'secret': {
const secName = f.secretName?.trim();
if (!secName) return null;
const sec: Record<string, any> = { secretName: secName };
const items = parseKeyToPathItems(f.secItems);
if (items) sec.items = items;
v.secret = sec;
break;
}
case 'persistentVolumeClaim': {
const claim = f.pvcName?.trim();
if (!claim) return null;
const pvc: Record<string, any> = { claimName: claim };
if (f.pvcReadOnly) pvc.readOnly = true;
v.persistentVolumeClaim = pvc;
break;
}
case 'nfs': {
const server = f.nfsServer?.trim();
const path = f.nfsPath?.trim();
if (!server || !path) return null;
const nfs: Record<string, any> = { server, path };
if (f.nfsReadOnly) nfs.readOnly = true;
v.nfs = nfs;
break;
}
default:
return null;
}
return v;
}
export function mountToForm(containerName: string, vm: Record<string, any>): MountForm {
return {
containerName,
volumeName: vm.name || '',
mountPath: vm.mountPath || '',
subPath: vm.subPath || '',
readOnly: !!vm.readOnly,
};
}
export function formToMount(m: MountForm): Record<string, any> | null {
if (!m.volumeName?.trim() || !m.mountPath?.trim()) return null;
const vm: Record<string, any> = { name: m.volumeName.trim(), mountPath: m.mountPath.trim() };
if (m.subPath?.trim()) vm.subPath = m.subPath.trim();
if (m.readOnly) vm.readOnly = true;
return vm;
}
const SYSTEM_MOUNT_PREFIXES = ['/var/run/secrets/kubernetes.io/serviceaccount'];
export function isPassthroughMount(vm: Record<string, any>, passthroughVolumeNames: Set<string>): boolean {
if (!vm) return false;
if (passthroughVolumeNames.has(vm.name)) return true;
const mp = vm.mountPath || '';
return SYSTEM_MOUNT_PREFIXES.some((p) => mp.startsWith(p));
}
/** 从 manifest 解析卷与挂载表单 // Logan.Li */
export function parseStorageFromManifest(manifest: Record<string, any>): {
volumes: VolumeForm[];
mounts: MountForm[];
containers: string[];
passthroughVolumes: Record<string, any>[];
} {
const podSpec = manifest?.spec?.template?.spec || {};
const rawVols: Record<string, any>[] = Array.isArray(podSpec.volumes) ? podSpec.volumes : [];
const passthroughVolumes = rawVols.filter(isPassthroughVolume);
const editableVols = rawVols.filter((v) => !isPassthroughVolume(v));
const allContainers = [
...(Array.isArray(podSpec.containers) ? podSpec.containers : []),
...(Array.isArray(podSpec.initContainers) ? podSpec.initContainers : []),
];
const containers = allContainers.map((c) => c.name || '(unnamed)');
const passthroughNames = new Set(passthroughVolumes.map((v) => v.name));
const mounts: MountForm[] = [];
for (const c of allContainers) {
const cName = c.name || '(unnamed)';
const vms: Record<string, any>[] = Array.isArray(c.volumeMounts) ? c.volumeMounts : [];
for (const vm of vms) {
if (isPassthroughMount(vm, passthroughNames)) continue;
mounts.push(mountToForm(cName, vm));
}
}
return {
volumes: editableVols.map(volumeToForm),
mounts,
containers,
passthroughVolumes,
};
}
/** 将表单写回 manifest.spec.template.spec // Logan.Li */
export function applyStorageToManifest(
manifest: Record<string, any>,
volumes: VolumeForm[],
mounts: MountForm[],
passthroughVolumes: Record<string, any>[],
): Record<string, any> {
const base = JSON.parse(JSON.stringify(manifest));
base.spec = base.spec || {};
base.spec.template = base.spec.template || {};
base.spec.template.spec = base.spec.template.spec || {};
const podSpec = base.spec.template.spec;
const userVols = volumes.map(formToVolume).filter(Boolean) as Record<string, any>[];
const mergedVols = [...passthroughVolumes, ...userVols];
if (mergedVols.length > 0) podSpec.volumes = mergedVols;
else delete podSpec.volumes;
const passthroughNames = new Set(passthroughVolumes.map((v) => v.name));
const applyMounts = (c: Record<string, any>) => {
const cName = c.name || '(unnamed)';
const existing: Record<string, any>[] = Array.isArray(c.volumeMounts) ? c.volumeMounts : [];
const kept = existing.filter((vm) => isPassthroughMount(vm, passthroughNames));
const user = mounts
.filter((m) => m.containerName === cName)
.map(formToMount)
.filter(Boolean) as Record<string, any>[];
const merged = [...kept, ...user];
if (merged.length > 0) c.volumeMounts = merged;
else delete c.volumeMounts;
};
(podSpec.containers || []).forEach(applyMounts);
(podSpec.initContainers || []).forEach(applyMounts);
return base;
}