fix(k8s): Deployment 存储挂载六种卷类型 YAML 双向映射

- 抽取 deployment-volume-utils,修正 PVC/NFS/CM/Secret/HostPath/EmptyDir 读写
- 保存时保留系统卷与 serviceAccount 挂载,合并用户卷与挂载点
- 挂载支持 subPath;ConfigMap/Secret items 支持 key:path 简写
This commit is contained in:
Logan.Li committed 2026-06-26 14:57:09 +08:00
1 parent 5fad2db0c6
commit a8305de0f5
98 files changed
+453 -250

No files matched your search

@@ -12,6 +12,13 @@ import {
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { saveDeploymentManifest } from './deployment-manifest-save';
import {
type VolumeForm,
type MountForm,
parseStorageFromManifest,
applyStorageToManifest,
formToVolume,
} from './deployment-volume-utils';
interface Props {
clusterCode: string;
@@ -22,33 +29,6 @@ interface Props {
onCancel: () => void;
}
/** 数据卷定义 */
interface VolumeForm {
name: string;
type: 'persistentVolumeClaim' | 'emptyDir' | 'hostPath' | 'nfs' | 'configMap' | 'secret';
// PVC
pvcName?: string; pvcReadOnly?: boolean;
// EmptyDir
medium?: string; sizeLimit?: string;
// HostPath
path?: string; pathType?: string;
// NFS
nfsServer?: string; nfsPath?: string; nfsReadOnly?: boolean;
// ConfigMap
configMapName?: string; cmItems?: string;
// Secret
secretName?: string; secItems?: string;
}
/** 容器挂载点 */
interface MountForm {
containerName: string;
volumeName: string;
mountPath: string;
subPath: string;
readOnly: boolean;
}
const VOLUME_TYPES: { key: VolumeForm['type']; label: string; icon: typeof HardDrive; color: string; desc: string }[] = [
{ key: 'persistentVolumeClaim', label: '存储卷声明', icon: Database, color: 'text-emerald-400', desc: 'PVC' },
{ key: 'emptyDir', label: '临时目录', icon: Disc, color: 'text-slate-400', desc: 'EmptyDir' },
@@ -58,32 +38,6 @@ const VOLUME_TYPES: { key: VolumeForm['type']; label: string; icon: typeof HardD
{ key: 'secret', label: '密文', icon: Lock, color: 'text-rose-400', desc: 'Secret' },
];
function volumeToForm( v: any): VolumeForm {
if (!v || typeof v !== 'object') return { name: '', type: 'emptyDir' };
const name = v.name || '';
if (v.emptyDir) return { name, type: 'emptyDir', medium: v.emptyDir.medium || '', sizeLimit: v.emptyDir.sizeLimit || '' };
if (v.hostPath) return { name, type: 'hostPath', path: v.hostPath.path || '', pathType: v.hostPath.type || 'Directory' };
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.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 };
return { name, type: 'emptyDir' };
}
function formToVolume(f: VolumeForm): any {
const v: Record<string, any> = { name: f.name.trim() };
if (!v.name) return null;
switch (f.type) {
case 'emptyDir': { const ed: Record<string, any> = {}; if (f.medium) ed.medium = f.medium; if (f.sizeLimit) ed.sizeLimit = f.sizeLimit; v.emptyDir = ed; break; }
case 'hostPath': v.hostPath = { path: f.path || '', type: f.pathType || 'Directory' }; break;
case 'configMap': { v.configMap = { name: f.configMapName || '' }; if (f.cmItems) try { v.configMap.items = JSON.parse(f.cmItems); } catch {} break; }
case 'secret': { v.secret = { secretName: f.secretName || '' }; if (f.secItems) try { v.secret.items = JSON.parse(f.secItems); } catch {} break; }
case 'persistentVolumeClaim': v.persistentVolumeClaim = { claimName: f.pvcName || '', ...(f.pvcReadOnly ? { readOnly: true } : {}) }; break;
case 'nfs': v.nfs = { server: f.nfsServer || '', path: f.nfsPath || '', ...(f.nfsReadOnly ? { readOnly: true } : {}) }; break;
}
return v;
}
export function DeploymentEditStorage({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: Props) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
@@ -92,41 +46,17 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
const [volumes, setVolumes] = useState<VolumeForm[]>([]);
const [mounts, setMounts] = useState<MountForm[]>([]);
const [containers, setContainers] = useState<string[]>([]);
const [passthroughVolumes, setPassthroughVolumes] = useState<Record<string, any>[]>([]);
useEffect(() => {
const m = manifest;
if (!m) { setLoading(false); return; }
setRawManifest(m);
const podSpec = m?.spec?.template?.spec || {};
// 数据卷
const vols: Record<string, any>[] = Array.isArray(podSpec.volumes) ? podSpec.volumes : [];
setVolumes(vols.map(volumeToForm));
// 容器列表
const allContainers = [
...(podSpec.containers || []),
...(podSpec.initContainers || []),
];
const containerNames = allContainers.map(( c: any) => c.name || '(unnamed)');
setContainers(containerNames);
// 挂载点
const allMounts: 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) {
allMounts.push({
containerName: cName,
volumeName: vm.name || '',
mountPath: vm.mountPath || '',
subPath: vm.subPath || '',
readOnly: !!vm.readOnly,
});
}
}
setMounts(allMounts);
const parsed = parseStorageFromManifest(m);
setVolumes(parsed.volumes);
setMounts(parsed.mounts);
setContainers(parsed.containers);
setPassthroughVolumes(parsed.passthroughVolumes);
setLoading(false);
}, [manifest]);
@@ -147,35 +77,19 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
setMounts(prev => prev.map((m, idx) => (idx === i ? { ...m, ...patch } : m)));
const save = async () => {
for (const vol of volumes) {
if (!vol.name.trim()) {
toast({ title: '请填写数据卷名称', variant: 'destructive' });
return;
}
if (!formToVolume(vol)) {
toast({ title: '数据卷配置不完整', description: `请补全「${vol.name}」的类型必填项`, variant: 'destructive' });
return;
}
}
setSaving(true);
try {
const base = JSON.parse(JSON.stringify(rawManifest));
base.spec = base.spec || {};
base.spec.template = base.spec.template || {};
base.spec.template.spec = base.spec.template.spec || {};
const podSpec = base.spec.template.spec;
// Save volumes
const k8sVols = volumes.map(formToVolume).filter(Boolean);
if (k8sVols.length > 0) podSpec.volumes = k8sVols;
else delete podSpec.volumes;
// 分别更新工作容器和初始化容器的 volumeMounts // Logan.Li
const applyMounts = (c: any) => {
const cName = c.name || '(unnamed)';
const cMounts = mounts
.filter(m => m.containerName === cName && m.volumeName && m.mountPath)
.map(m => {
const vm: Record<string, any> = { name: m.volumeName, mountPath: m.mountPath, readOnly: m.readOnly };
if (m.subPath) vm.subPath = m.subPath;
return vm;
});
if (cMounts.length > 0) c.volumeMounts = cMounts;
else delete c.volumeMounts;
};
(podSpec.containers || []).forEach(applyMounts);
(podSpec.initContainers || []).forEach(applyMounts);
const base = applyStorageToManifest(rawManifest, volumes, mounts, passthroughVolumes);
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: '存储配置已保存', description: `${deploymentName} 更新已提交` });
onSaved(base);
@@ -279,8 +193,13 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
{vol.type === 'emptyDir' && (
<div className="flex items-center gap-2 pl-[88px]">
<select value={vol.medium || ''} onChange={e => updateVolume(i, { medium: e.target.value })}
className="h-7 w-28 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5">
<option value="">默认磁盘</option>
<option value="Memory">Memory</option>
</select>
<Input value={vol.sizeLimit || ''} onChange={e => updateVolume(i, { sizeLimit: e.target.value })}
placeholder="sizeLimit (如 512Mi, 可为空)"
placeholder="sizeLimit (如 512Mi)"
className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
)}
@@ -323,7 +242,7 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
placeholder="ConfigMap 名称"
className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Input value={vol.cmItems || ''} onChange={e => updateVolume(i, { cmItems: e.target.value })}
placeholder="KeyToPath (可为空)"
placeholder="key:path 或 JSON"
className="h-7 w-40 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
)}
@@ -335,7 +254,7 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
placeholder="Secret 名称"
className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Input value={vol.secItems || ''} onChange={e => updateVolume(i, { secItems: e.target.value })}
placeholder="KeyToPath (可为空)"
placeholder="key:path 或 JSON"
className="h-7 w-40 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
)}
@@ -384,6 +303,9 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
<Input value={m.mountPath} onChange={e => updateMount(i, { mountPath: e.target.value })}
placeholder="挂载路径 /app/data"
className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Input value={m.subPath} onChange={e => updateMount(i, { subPath: e.target.value })}
placeholder="subPath (可选)"
className="h-7 w-28 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
{/* 只读/读写 */}
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-0.5 border border-slate-700">
<button onClick={() => updateMount(i, { readOnly: false })}
@@ -100,18 +100,8 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
const loadManifest = useCallback(async () => {
try {
const yamlText = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'deployments', deploymentName);
// 简易 YAML→JSON:交给浏览器原生 JSON.parse 兜底(K8s API 多以 JSON 返回);
// 若是纯 YAML,则尝试用 js-yaml(如项目已安装),否则保留为 null 让编辑模式降级。
let parsed: Record<string, any> = null;
try { parsed = JSON.parse(yamlText); } catch {
// 非纯 JSON,尝试动态加载 js-yaml
try {
const jsyaml: Record<string, any> = await import('js-yaml');
parsed = jsyaml.load(yamlText);
} catch {
parsed = null;
}
}
const { parseEditableManifest } = await import('./k8s-manifest-utils');
const { manifest: parsed } = await parseEditableManifest(yamlText);
setManifest(parsed || deployment);
} catch (e) {
console.warn('加载 manifest 失败(编辑功能降级):', e);
@@ -0,0 +1,290 @@
// 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;
}