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 })}