- 抽取 deployment-volume-utils,修正 PVC/NFS/CM/Secret/HostPath/EmptyDir 读写 - 保存时保留系统卷与 serviceAccount 挂载,合并用户卷与挂载点 - 挂载支持 subPath;ConfigMap/Secret items 支持 key:path 简写
290 lines
9.0 KiB
TypeScript
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;
|
|
} |