From ee59706ab9f95c34eee3ea71e47299837a5a3d70 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Tue, 27 Oct 2026 15:40:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=87=AD=E8=AF=81=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8C=96=E8=A1=A8=E5=8D=95=E9=87=8D=E6=9E=84=EF=BC=88=E5=8F=82?= =?UTF-8?q?=E8=80=83=20LokiStack/aiops=EF=BC=89=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=20aksk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 类型精简为发布流程所需 4 种:SSH 密钥 / Docker 认证 / Git 仓库 / Kubernetes(旧 password/kubeconfig/aksk 仅作历史数据展示,新建不提供) - 类型化表单替代通用 JSON textarea(aiops 模式): ssh=私钥/用户/端口/密码;docker=Registry/用户/Token; git=仓库/用户/Token;编辑时留空=不修改,类型不可改 - Kubernetes 凭证双模式(LokiStack 模式),默认 Token(推荐): apiServer+命名空间+Token,或整份 Kubeconfig+context - 命名空间长期授权引导:折叠块内一键复制 kubectl 命令 (Namespace+SA+命名空间级 Role/RoleBinding+长期 Token Secret), 命名空间随表单动态注入;附最小权限安全说明 - 后端摘要支持 kubernetes 类型(K8s Token ns@server / Kubeconfig) --- backend/internal/handler/resources.go | 6 + frontend/src/pages/CredentialManager.tsx | 322 +++++++++++++++++++++-- 2 files changed, 304 insertions(+), 24 deletions(-) diff --git a/backend/internal/handler/resources.go b/backend/internal/handler/resources.go index 10496c3..d570b9e 100644 --- a/backend/internal/handler/resources.go +++ b/backend/internal/handler/resources.go @@ -305,6 +305,12 @@ func summarizeCredential(typ, contentJSON string) string { return typ + " " + pick("server") + " " + pick("username") case "git": return typ + " " + pick("url", "username") + case "kubernetes": + mode, _ := m["mode"].(string) + if mode == "kubeconfig" { + return "K8s Kubeconfig" + } + return "K8s Token " + pick("namespace") + "@" + pick("apiServer") case "kubeconfig": return typ + " (kubeconfig)" case "aksk": diff --git a/frontend/src/pages/CredentialManager.tsx b/frontend/src/pages/CredentialManager.tsx index 4f22062..2e0d006 100644 --- a/frontend/src/pages/CredentialManager.tsx +++ b/frontend/src/pages/CredentialManager.tsx @@ -10,7 +10,9 @@ import { GitBranch, Container, Cloud, + Boxes, Loader2, + KeyRound, } from 'lucide-react'; import { toast } from 'sonner'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -22,14 +24,19 @@ import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; const typeConfig: Record = { - ssh: { icon: Key, label: 'SSH密钥', color: '#0969da', bg: '#ddf4ff' }, + ssh: { icon: Key, label: 'SSH 密钥', color: '#0969da', bg: '#ddf4ff' }, + docker: { icon: Container, label: 'Docker 认证', color: '#1a7f37', bg: '#dafbe1' }, + git: { icon: GitBranch, label: 'Git 仓库', color: '#8250df', bg: '#fbefff' }, + kubernetes: { icon: Boxes, label: 'Kubernetes', color: '#bc4c00', bg: '#fff1e5' }, + // 旧类型仅作历史数据展示,不再提供新建 password: { icon: Shield, label: '用户名密码', color: '#9a6700', bg: '#fff8c5' }, - docker: { icon: Container, label: 'Docker认证', color: '#1a7f37', bg: '#dafbe1' }, - git: { icon: GitBranch, label: 'Git凭证', color: '#8250df', bg: '#fbefff' }, kubeconfig: { icon: Key, label: 'Kubeconfig', color: '#bc4c00', bg: '#fff1e5' }, aksk: { icon: Cloud, label: 'AK/SK', color: '#0f766e', bg: '#ccfbf1' }, }; +// 新建可选类型(发布流程所需:ssh / docker / git / kubernetes) +const CREATE_TYPES = ['ssh', 'docker', 'git', 'kubernetes']; + const fallbackConfig = { icon: Key, label: '其他', color: '#6e7781', bg: '#f6f8fa' }; function getConfig(type: string) { @@ -45,10 +52,133 @@ interface CredentialForm { name: string; type: string; scope: string; - content: string; + // ssh + privateKey: string; sshUser: string; sshPort: string; sshPassword: string; + // docker + dockerServer: string; dockerUsername: string; dockerPassword: string; + // git + gitUrl: string; gitUsername: string; gitToken: string; + // kubernetes + k8sMode: 'token' | 'kubeconfig'; + k8sApiServer: string; k8sNamespace: string; k8sToken: string; + k8sKubeconfig: string; k8sContext: string; + rbacOpen: boolean; } -const emptyForm: CredentialForm = { name: '', type: 'ssh', scope: '全局', content: '' }; +const emptyForm: CredentialForm = { + name: '', type: 'ssh', scope: '全局', + privateKey: '', sshUser: 'root', sshPort: '22', sshPassword: '', + dockerServer: '', dockerUsername: '', dockerPassword: '', + gitUrl: '', gitUsername: '', gitToken: '', + k8sMode: 'token', k8sApiServer: '', k8sNamespace: 'flowstack', k8sToken: '', + k8sKubeconfig: '', k8sContext: '', + rbacOpen: false, +}; + +// 按类型组装加密内容 JSON(编辑时仅提交非空字段 = 留空不修改) +function buildContent(form: CredentialForm): string | undefined { + const has = (s: string) => s && s.trim() !== ''; + let obj: Record; + switch (form.type) { + case 'ssh': + obj = {}; + if (has(form.privateKey)) obj.private_key = form.privateKey.trim(); + if (has(form.sshUser)) obj.ssh_user = form.sshUser.trim(); + if (has(form.sshPort)) obj.ssh_port = Number(form.sshPort) || 22; + if (has(form.sshPassword)) obj.password = form.sshPassword; + break; + case 'docker': + obj = {}; + if (has(form.dockerServer)) obj.server = form.dockerServer.trim(); + if (has(form.dockerUsername)) obj.username = form.dockerUsername.trim(); + if (has(form.dockerPassword)) obj.password = form.dockerPassword; + break; + case 'git': + obj = {}; + if (has(form.gitUrl)) obj.url = form.gitUrl.trim(); + if (has(form.gitUsername)) obj.username = form.gitUsername.trim(); + if (has(form.gitToken)) obj.token = form.gitToken; + break; + case 'kubernetes': + obj = { mode: form.k8sMode }; + if (form.k8sMode === 'token') { + if (has(form.k8sApiServer)) obj.apiServer = form.k8sApiServer.trim(); + if (has(form.k8sNamespace)) obj.namespace = form.k8sNamespace.trim(); + if (has(form.k8sToken)) obj.token = form.k8sToken.trim(); + } else { + if (has(form.k8sKubeconfig)) obj.kubeconfig = form.k8sKubeconfig.trim(); + if (has(form.k8sContext)) obj.context = form.k8sContext.trim(); + } + break; + default: + return undefined; + } + const values = Object.values(obj).filter((v) => v !== undefined && v !== '' && v !== 'mode'); + if (values.length === 0) return undefined; + return JSON.stringify(obj); +} + +// 命名空间最小授权命令(参考 LokiStack:SA + 命名空间级 Role + 长期 Token) +function buildRbacCommand(ns: string): string { + const safeNs = (ns && ns.trim()) || 'flowstack'; + return [ + "# 1) 一次创建:命名空间 + ServiceAccount + 命名空间级 Role + 长期 Token", + "kubectl apply -f - <<'YAML'", + "apiVersion: v1", + "kind: Namespace", + "metadata:", + " name: " + safeNs, + "---", + "apiVersion: v1", + "kind: ServiceAccount", + "metadata:", + " name: flowstack", + " namespace: " + safeNs, + "---", + "apiVersion: rbac.authorization.k8s.io/v1", + "kind: Role", + "metadata:", + " name: flowstack", + " namespace: " + safeNs, + "rules:", + ' - apiGroups: [""]', + ' resources: ["pods", "pods/log", "services", "configmaps", "secrets", "events"]', + ' verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]', + ' - apiGroups: ["apps"]', + ' resources: ["deployments", "statefulsets", "daemonsets"]', + ' verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]', + ' - apiGroups: ["networking.k8s.io"]', + ' resources: ["ingresses"]', + ' verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]', + "---", + "apiVersion: rbac.authorization.k8s.io/v1", + "kind: RoleBinding", + "metadata:", + " name: flowstack", + " namespace: " + safeNs, + "subjects:", + " - kind: ServiceAccount", + " name: flowstack", + " namespace: " + safeNs, + "roleRef:", + " kind: Role", + " name: flowstack", + " apiGroup: rbac.authorization.k8s.io", + "---", + "apiVersion: v1", + "kind: Secret", + "metadata:", + " name: flowstack-token", + " namespace: " + safeNs, + " annotations:", + " kubernetes.io/service-account.name: flowstack", + "type: kubernetes.io/service-account-token", + "YAML", + "", + "# 2) 取出长期 Token(不自动过期),粘贴到上方 Token 输入框", + "kubectl -n " + safeNs + " get secret flowstack-token -o jsonpath='{.data.token}' | base64 -d", + ].join("\n"); +} const inputStyle = { background: 'var(--panel-bg-secondary)', @@ -81,8 +211,8 @@ export default function CredentialManager() { setEditing(cred ?? null); setForm( cred - ? { name: cred.name ?? '', type: cred.type ?? 'ssh', scope: cred.scope ?? '', content: '' } - : emptyForm, + ? { ...emptyForm, name: cred.name ?? '', type: cred.type ?? 'ssh', scope: cred.scope ?? '' } + : { ...emptyForm }, ); setDialogOpen(true); }; @@ -114,9 +244,14 @@ export default function CredentialManager() { toast.error('请填写凭证名称'); return; } - // 内容加密存储(write-only):编辑时留空表示不修改 + // 内容加密存储(write-only):编辑时字段留空表示不修改 + const content = buildContent(form); + if (!editing && !content) { + toast.error('请填写凭证内容'); + return; + } const body: any = { name: form.name.trim(), type: form.type, scope: form.scope.trim() }; - if (form.content.trim()) body.content = form.content.trim(); + if (content) body.content = content; saveMutation.mutate({ id: editing?.id, body }); }; @@ -338,14 +473,14 @@ export default function CredentialManager() {
- setForm({ ...form, type: v })} disabled={!!editing}> - {Object.entries(typeConfig).map(([type, config]) => ( + {CREATE_TYPES.map((type) => ( - {config.label} + {typeConfig[type].label} ))} @@ -362,18 +497,157 @@ export default function CredentialManager() { />
-
- -