From 6cbc7873c649d0e178161a657767413419784adb Mon Sep 17 00:00:00 2001 From: wuxs Date: Wed, 22 Jul 2026 02:25:40 +0800 Subject: [PATCH 01/14] feat(apps): Docker Compose app management MVP (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Docker Compose runtime alongside the existing Kubernetes one, behind a stable Controller seam. Backend-focused MVP covering stages 0-3 of issue #2. Domain & seam (stage 0): - Stable Application/DesiredApplication/Task/Revision model; JSON stays forward-compatible with the existing useApps()/useAppDetail() read path. - Controller interface is the only HTTP-facing seam; runtimeAdapter (compose + kubernetes) is internal. K8s Manager migrated to kubernetesRuntime adapter (deploymentToAppInfo -> deploymentToApplication; phase aggregated in backend). Compose runtime (stage 1): - Lightweight Docker Engine client over net/http (unix socket and tcp:// via DOCKER_HOST) for read/observe/logs — deliberately no docker SDK dependency. - Writes go through `docker compose` CLI via exec.CommandContext with arg arrays (no shell). Only devbox-managed projects (prefix devbox-) are - discovered; Docker downgrades cleanly without affecting K8s. Persistence & async tasks (stage 2): - SQLite (modernc.org/sqlite, pure Go) for app meta / tasks / revisions / idempotency / audit. Compose file stays the source of truth on disk. - Persistent Task worker: per-app serial queue, idempotency (same key+request -> same task; same key+different request -> 409), crash recovery on restart. - start/stop/restart/redeploy/remove; data kept by default, purge is explicit, external volumes are never removed. Create/edit (stage 3): - Inline Compose create/update; `compose config` preflight; risk policy (blocked/confirmation/warning); revision history + optimistic concurrency (expectedRevision mismatch -> 409). app ID path-traversal safe; Compose args shell-injection safe; secrets never returned or stored in task/revision/audit. HTTP & wiring: - Legacy read/action paths preserved as a compatibility shim (sync wait) so the existing UI is unchanged; new write APIs return 202+Task. New endpoints: validate, capability, tasks/{id}, actions/{...}, compose, revisions, revisions/{n}/restore, operations. - config: compose section (data_dir / docker_socket / enabled); main assembles the Controller (K8s + Compose adapters + sqlite + worker). UI (minimal): - New "Compose 应用" system entry -> ComposeManager page: runtime filter, lifecycle buttons, inline-Compose create dialog with preflight, task feedback. Desktop/AppMgmtDrawer intentionally untouched. Verified end-to-end against a real Docker daemon (remote TCP) via HTTP smoke: capability, validate, create(202), task poll->succeeded, list/detail (running, 2 services), stop/start (phase transitions), revision conflict 409, idempotency (202/202/409), delete->404. Real-docker integration test guarded by build tag `integration`; unit tests cover domain/risk/persistence/worker/HTTP. Out of scope (later stages): app-store unified install (stage 4), backup and multi-host (stage 5). Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/devbox/main.go | 30 +- console-ui/src/App.jsx | 4 +- console-ui/src/data/systemApps.js | 1 + console-ui/src/hooks/useApi.js | 102 +++ console-ui/src/pages/ComposeManager.jsx | 372 +++++++++ go.mod | 10 +- go.sum | 41 + pkg/apps/assembly.go | 55 ++ pkg/apps/compose.go | 268 +++++++ pkg/apps/compose_cli.go | 140 ++++ pkg/apps/compose_test.go | 54 ++ pkg/apps/controller.go | 711 ++++++++++++++++++ pkg/apps/controller_test.go | 293 ++++++++ pkg/apps/docker_engine.go | 212 ++++++ pkg/apps/domain.go | 408 ++++++++++ pkg/apps/domain_test.go | 47 ++ pkg/apps/helpers.go | 113 +++ pkg/apps/helpers_test.go | 83 ++ pkg/apps/integration_compose_test.go | 142 ++++ pkg/apps/kubernetes.go | 252 +++++++ pkg/apps/manager.go | 243 ------ pkg/apps/paths.go | 173 +++++ pkg/apps/repository.go | 518 +++++++++++++ pkg/apps/repository_test.go | 157 ++++ pkg/apps/risk.go | 305 ++++++++ pkg/apps/risk_test.go | 138 ++++ pkg/apps/types.go | 29 +- pkg/apps/worker.go | 254 +++++++ pkg/apps/worker_test.go | 151 ++++ pkg/config/config.go | 16 + pkg/console/dist/assets/index-BPLAoxgS.js | 173 ----- pkg/console/dist/assets/index-Brd9DvfI.js | 186 +++++ ...{index-BKESsNP_.css => index-DCcHkGpE.css} | 2 +- pkg/console/dist/index.html | 4 +- pkg/console/handlers_apps.go | 409 ++++++++-- pkg/console/handlers_apps_test.go | 230 ++++++ pkg/console/server.go | 6 +- 37 files changed, 5806 insertions(+), 526 deletions(-) create mode 100644 console-ui/src/pages/ComposeManager.jsx create mode 100644 pkg/apps/assembly.go create mode 100644 pkg/apps/compose.go create mode 100644 pkg/apps/compose_cli.go create mode 100644 pkg/apps/compose_test.go create mode 100644 pkg/apps/controller.go create mode 100644 pkg/apps/controller_test.go create mode 100644 pkg/apps/docker_engine.go create mode 100644 pkg/apps/domain.go create mode 100644 pkg/apps/domain_test.go create mode 100644 pkg/apps/helpers.go create mode 100644 pkg/apps/helpers_test.go create mode 100644 pkg/apps/integration_compose_test.go create mode 100644 pkg/apps/kubernetes.go delete mode 100644 pkg/apps/manager.go create mode 100644 pkg/apps/paths.go create mode 100644 pkg/apps/repository.go create mode 100644 pkg/apps/repository_test.go create mode 100644 pkg/apps/risk.go create mode 100644 pkg/apps/risk_test.go create mode 100644 pkg/apps/worker.go create mode 100644 pkg/apps/worker_test.go delete mode 100644 pkg/console/dist/assets/index-BPLAoxgS.js create mode 100644 pkg/console/dist/assets/index-Brd9DvfI.js rename pkg/console/dist/assets/{index-BKESsNP_.css => index-DCcHkGpE.css} (65%) create mode 100644 pkg/console/handlers_apps_test.go diff --git a/cmd/devbox/main.go b/cmd/devbox/main.go index 462dc8e..c145f08 100644 --- a/cmd/devbox/main.go +++ b/cmd/devbox/main.go @@ -49,16 +49,28 @@ func main() { col := collector.New(logger, version) go col.Start(ctx) - // K8s 应用管理器:可选,初始化失败仅禁用相关功能。 - var appMgr *apps.Manager - if mgr, err := apps.NewManager(logger, apps.Config{ - Kubeconfig: cfg.Kubernetes.Kubeconfig, - Namespace: cfg.Kubernetes.Namespace, - }); err != nil { - logger.Warn("K8s app manager unavailable; app management disabled", zap.Error(err)) + // 应用管理 Controller(统一 K8s + Docker Compose 运行时,Issue #2)。 + // 装配失败仅禁用应用管理,不影响控制台其它功能。 + var appController apps.Controller + var appCleanup func() + if c, cleanup, err := apps.AssembleController(ctx, apps.ControllerConfig{ + DataDir: cfg.Compose.DataDir, + DockerSocket: cfg.Compose.DockerSocket, + ComposeEnabled: cfg.Compose.Enabled, + Kubeconfig: cfg.Kubernetes.Kubeconfig, + Namespace: cfg.Kubernetes.Namespace, + KubernetesEnabled: true, + }, logger); err != nil { + logger.Warn("App controller unavailable; app management disabled", zap.Error(err)) } else { - appMgr = mgr + appController = c + appCleanup = cleanup } + defer func() { + if appCleanup != nil { + appCleanup() + } + }() // 应用商店管理器:仅在显式配置 APIServerURL 时启用。 var storeMgr *apps.StoreManager @@ -85,7 +97,7 @@ func main() { AuthPassword: cfg.Auth.Password, AuthSessionTTL: cfg.Auth.SessionTTL, LinksPath: cfg.Console.LinksPath, - }, col, appMgr, storeMgr) + }, col, appController, storeMgr) go func() { if err := consoleServer.Start(ctx); err != nil { diff --git a/console-ui/src/App.jsx b/console-ui/src/App.jsx index eadca0a..f13ab9b 100644 --- a/console-ui/src/App.jsx +++ b/console-ui/src/App.jsx @@ -41,6 +41,7 @@ import { Dock } from './components/Dock' import AppWindow, { btnSecondary, btnPrimary } from './components/AppWindow' import DashboardApp from './pages/Dashboard' import AppStore from './pages/AppStore' +import { ComposeManager } from './pages/ComposeManager' import AlertCenter from './pages/AlertCenter' import AuditLog from './pages/AuditLog' import Supervisor from './pages/Supervisor' @@ -423,6 +424,7 @@ export default function App() { > {appId === 'dashboard' && } {appId === 'store' && } + {appId === 'compose-manager' && } {appId === 'alerts' && } {appId === 'audit' && } {appId === 'supervisor'&& } @@ -430,7 +432,7 @@ export default function App() { {appId === 'hardware' && } {appId === 'links' && } {(appId === 'diag' || appId === 'settings') && } - {!['dashboard','store','alerts','audit','supervisor','virtual-machines','hardware','links','diag','settings'].includes(appId) + {!['dashboard','store','compose-manager','alerts','audit','supervisor','virtual-machines','hardware','links','diag','settings'].includes(appId) && setMgmtOpen(true)}/>} diff --git a/console-ui/src/data/systemApps.js b/console-ui/src/data/systemApps.js index 49f6d64..f809bfe 100644 --- a/console-ui/src/data/systemApps.js +++ b/console-ui/src/data/systemApps.js @@ -16,6 +16,7 @@ export const SYSTEM_APPS = [ { id: 'monitoring', kind: 'system', name: '监控', icon: 'sparkle', color: T.indigo, bg: 'linear-gradient(160deg,#6366f1,#4f46e5)' }, { id: 'ai-activity', kind: 'system', name: 'AI 活动', icon: 'brain', color: T.violet, bg: 'linear-gradient(160deg,#a855f7,#6d28d9)' }, { id: 'store', kind: 'system', name: '应用商店', icon: 'store', color: T.green, bg: 'linear-gradient(160deg,#34d399,#059669)' }, + { id: 'compose-manager', kind: 'system', name: 'Compose 应用', icon: 'apps', color: '#0891b2', bg: 'linear-gradient(160deg,#22d3ee,#0891b2)' }, { id: 'files', kind: 'system', name: '文件', icon: 'folder', color: '#0891b2', bg: 'linear-gradient(160deg,#22d3ee,#0891b2)' }, { id: 'processes', kind: 'system', name: '进程', icon: 'cpu', color: '#475569', bg: 'linear-gradient(160deg,#64748b,#1e293b)' }, { id: 'supervisor', kind: 'system', name: '进程守护', icon: 'shield', color: '#0d9488', bg: 'linear-gradient(160deg,#14b8a6,#0f766e)' }, diff --git a/console-ui/src/hooks/useApi.js b/console-ui/src/hooks/useApi.js index ecce2bd..21505bc 100644 --- a/console-ui/src/hooks/useApi.js +++ b/console-ui/src/hooks/useApi.js @@ -300,6 +300,8 @@ export function useApps(interval = 10000) { return apps.map((a) => ({ id: a.id, kind: 'app', + // runtime 由后端提供(compose | kubernetes);旧 K8s app 默认 kubernetes。 + runtime: a.runtime || 'kubernetes', name: a.name, icon: guessIcon(a.name), color: '#3b82f6', @@ -598,3 +600,103 @@ export async function getLogs(appId, tail = 100) { return ''; } } + +// ─── Docker Compose 应用管理(Issue #2) ────────────────────────── +// +// 写操作统一返回 Task(202)。前端提交后用 useTask 轮询进度。 +// 兼容旧 action(appOp)与旧 delete(deleteApp)保留;以下为新异步 API。 + +export function useAppCapability(interval = 15000) { + return usePoll('/apps/capability', { interval, fallback: null }); +} + +// useTask 轮询单个任务到终态后停止。 +export function useTask(taskId, interval = 1500) { + const [task, setTask] = useState(null); + const [loading, setLoading] = useState(true); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + if (!taskId) { setTask(null); setLoading(false); return; } + let timer = null; + async function poll() { + try { + const r = await authFetch(`${API}/tasks/${encodeURIComponent(taskId)}`); + if (!r.ok) return; + const t = await r.json(); + if (!mountedRef.current) return; + setTask(t); + if (t.status && ['succeeded', 'failed', 'canceled', 'superseded'].includes(t.status)) { + if (timer) clearInterval(timer); + setLoading(false); + return; + } + } catch { /* keep */ } + finally { if (mountedRef.current) setLoading(false); } + } + poll(); + timer = setInterval(poll, interval); + return () => { mountedRef.current = false; if (timer) clearInterval(timer); }; + }, [taskId, interval]); + + return { task, loading }; +} + +// useAppOperations 轮询某应用的最近操作历史。 +export function useAppOperations(appId, interval = 4000) { + const url = appId ? `/apps/${encodeURIComponent(appId)}/operations` : null; + return usePoll(url, { interval, fallback: [] }); +} + +export function useAppRevisions(appId) { + const url = appId ? `/apps/${encodeURIComponent(appId)}/revisions` : null; + return usePoll(url, { interval: 0, fallback: [] }); +} + +async function readErr(r) { + const t = await r.text().catch(() => ''); + return t || `HTTP ${r.status}`; +} + +// 预检(不落盘)。 +export async function validateCompose(req) { + const r = await authFetch(`${API}/apps/validate`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req), + }); + if (!r.ok) throw new Error(await readErr(r)); + return r.json(); +} + +// 创建/更新 inline Compose(202 + Task)。 +export async function applyComposeApp(desired, idempotencyKey) { + const headers = { 'Content-Type': 'application/json' }; + if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey; + const isUpdate = !!desired.id; + const r = await authFetch(`${API}/apps${isUpdate ? '/' + encodeURIComponent(desired.id) : ''}`, { + method: isUpdate ? 'PUT' : 'POST', headers, body: JSON.stringify(desired), + }); + if (!r.ok) throw new Error(await readErr(r)); + return r.json(); +} + +// 异步生命周期(202 + Task)。 +export async function appActionAsync(appId, action) { + const r = await authFetch(`${API}/apps/${encodeURIComponent(appId)}/actions/${action}`, { method: 'POST' }); + if (!r.ok) throw new Error(await readErr(r)); + return r.json(); +} + +// 卸载(兼容同步;purge=true 删除受管数据,external 永不删)。 +export async function removeAppEx(appId, purge = false) { + const r = await authFetch(`${API}/apps/${encodeURIComponent(appId)}${purge ? '?purge=true' : ''}`, { method: 'DELETE' }); + if (!r.ok) throw new Error(await readErr(r)); + return r.json(); +} + +// 回滚到历史 revision(202 + Task)。 +export async function restoreAppRevision(appId, rev) { + const r = await authFetch(`${API}/apps/${encodeURIComponent(appId)}/revisions/${rev}/restore`, { method: 'POST' }); + if (!r.ok) throw new Error(await readErr(r)); + return r.json(); +} diff --git a/console-ui/src/pages/ComposeManager.jsx b/console-ui/src/pages/ComposeManager.jsx new file mode 100644 index 0000000..eed49fe --- /dev/null +++ b/console-ui/src/pages/ComposeManager.jsx @@ -0,0 +1,372 @@ +// ComposeManager — Docker Compose 应用管理入口(Issue #2 MVP UI)。 +// +// 刻意做成独立页面,不重写 Desktop / AppMgmtDrawer: +// - 列表按 runtime(全部 / Compose / Kubernetes)筛选 +// - 生命周期:启动 / 停止 / 重启 / 重部署 / 卸载(默认保留数据,purge 显式) +// - 新建:粘贴 inline Compose → 预检(服务/镜像/风险)→ 部署 +// - 任务进度:写操作返回 202+Task,前端轮询并反馈 +// - 受管应用以事实源 compose.yaml 驱动;external volume 永不删(后端保证) +import { useState, useEffect, useMemo } from 'react'; +import { T } from '../tokens'; +import { + useApps, useAppCapability, useTask, appActionAsync, removeAppEx, + validateCompose, applyComposeApp, +} from '../hooks/useApi'; +import { useToast } from '../components/toastContext'; + +const PHASE_LABEL = { + running: '运行中', stopped: '已停止', degraded: '降级', deploying: '部署中', + failed: '失败', pending: '等待', removing: '卸载中', unknown: '未知', +}; +const PHASE_COLOR = { + running: '#16a34a', stopped: '#64748b', degraded: '#d97706', deploying: '#2563eb', + failed: '#dc2626', pending: '#2563eb', removing: '#7c3aed', unknown: '#94a3b8', +}; + +function observedPhase(app) { + return app?.observed?.phase || app?.state || 'unknown'; +} + +export function ComposeManager({ authed, onRequireAuth }) { + const { data: apps, refresh } = useApps(5000); + const { data: cap } = useAppCapability(); + const [filter, setFilter] = useState('all'); + const [showCreate, setShowCreate] = useState(false); + const [activeTask, setActiveTask] = useState(null); // {id, label, appId} + const toast = useToast(); + + const { task } = useTask(activeTask?.id); + useEffect(() => { + if (!task || !activeTask) return; + if (['succeeded', 'failed', 'canceled', 'superseded'].includes(task.status)) { + if (task.status === 'succeeded') toast.ok(`${activeTask.label} 完成`); + else toast.err(`${activeTask.label} 失败:${task.message || task.status}`); + const t = setTimeout(() => setActiveTask(null), 1500); + refresh(); + return () => clearTimeout(t); + } + }, [task, activeTask, toast, refresh]); + + const composeCap = cap?.compose; + const composeDown = composeCap && composeCap.available === false; + + const list = useMemo(() => { + const arr = Array.isArray(apps) ? apps : []; + return filter === 'all' ? arr : arr.filter((a) => (a.runtime || 'kubernetes') === filter); + }, [apps, filter]); + + function guard(fn) { + return async (...args) => { + if (!authed) { onRequireAuth?.(); return; } + return fn(...args); + }; + } + + async function doAction(appId, action, label) { + try { + const t = await appActionAsync(appId, action); + setActiveTask({ id: t.id, label, appId }); + toast.ok(`${label} 已提交`); + } catch (e) { toast.err(`${label} 失败:${e.message}`); } + } + + async function doRemove(app, purge) { + const confirmText = purge + ? `确认卸载「${app.name}」并删除其受管数据?external volume 不会被删除。` + : `确认卸载「${app.name}」?(默认保留数据)`; + if (!window.confirm(confirmText)) return; + try { + const t = await removeAppEx(app.id, purge); + // 兼容同步接口:返回 {status, taskId?};若有 taskId 则跟踪,否则直接刷新。 + if (t && t.taskId) setActiveTask({ id: t.taskId, label: '卸载', appId: app.id }); + else { toast.ok('已卸载'); refresh(); } + } catch (e) { toast.err(`卸载失败:${e.message}`); } + } + + return ( +
+
(authed ? setShowCreate(true) : onRequireAuth?.())} + /> + + {activeTask && task && ( + + )} + + {composeDown && ( +
+ Docker Compose 运行时不可用:{composeCap.reason}。K8s 应用不受影响。 +
+ )} + +
+ {list.length === 0 && ( +
暂无应用。点击右上角「新建 Compose」粘贴一个 compose.yaml 试试。
+ )} + {list.map((app) => ( + guard(doAction)(app.id, action, label)} + onRemove={(purge) => guard(doRemove)(app, purge)} + /> + ))} +
+ + {showCreate && ( + setShowCreate(false)} + onDeployed={(t) => { + setShowCreate(false); + setActiveTask({ id: t.id, label: '部署', appId: t.appId }); + refresh(); + }} + /> + )} +
+ ); +} + +function Header({ composeCap, filter, setFilter, onCreate }) { + const filters = [ + { id: 'all', label: '全部' }, + { id: 'compose', label: 'Docker Compose' }, + { id: 'kubernetes', label: 'Kubernetes' }, + ]; + return ( +
+
应用管理
+
+ {composeCap?.available ? `Compose ${composeCap.version || '可用'}` : 'Compose 未就绪'} +
+
+
+ {filters.map((f) => ( + + ))} +
+ +
+ ); +} + +function TaskBanner({ task, label }) { + const color = task.status === 'failed' ? '#dc2626' : task.status === 'succeeded' ? '#16a34a' : '#2563eb'; + return ( +
+ {label} + + {task.status} · {task.phase || ''} {task.message ? `· ${task.message}` : ''} + +
+ ); +} + +function AppCard({ app, disabled, onAction, onRemove }) { + const phase = observedPhase(app); + const isCompose = (app.runtime || 'kubernetes') === 'compose'; + const running = phase === 'running'; + return ( +
+
+ + {app.name} + {isCompose ? 'Compose' : 'K8s'} + {PHASE_LABEL[phase] || phase} + + {app.observed?.services && ( + + {app.observed.services.length} 服务 · {app.ready || 0}/{app.replicas || (app.observed.services?.length || 0)} 就绪 + + )} +
+
+ {app.image || '—'} + {app.observed?.endpoints?.length ? ` · 入口 ${app.observed.endpoints.map((e) => e.url).join(', ')}` : ''} +
+
+ onAction('start', '启动')}>启动 + onAction('stop', '停止')}>停止 + onAction('restart', '重启')}>重启 + {isCompose && onAction('redeploy', '重部署')}>重部署} + onRemove(false)}>卸载 + onRemove(true)}>卸载并删数据 +
+
+ ); +} + +function Btn({ children, onClick, disabled, danger }) { + return ( + + ); +} + +function CreateDialog({ onClose, onDeployed }) { + const [name, setName] = useState(''); + const [compose, setCompose] = useState(SAMPLE); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + const [secrets, setSecrets] = useState(''); // KEY=VALUE 每行(仅写,不回传) + const toast = useToast(); + + async function onValidate() { + setResult(null); + try { + const r = await validateCompose({ compose, name }); + setResult(r); + if (r.ok) toast.ok('预检通过'); + else toast.warn('预检发现阻断/错误'); + } catch (e) { toast.err(`预检失败:${e.message}`); } + } + + async function onDeploy() { + setBusy(true); + try { + const secretMap = parseEnv(secrets); + const desired = { name: name || slugify(name), source: { kind: 'inline' }, compose, secrets: secretMap }; + const t = await applyComposeApp(desired); + toast.ok('已提交部署任务'); + onDeployed(t); + } catch (e) { + toast.err(`部署失败:${e.message}`); + setBusy(false); + } + } + + const blocked = result && !result.ok; + return ( +
+
e.stopPropagation()}> +
+ 新建 Compose 应用 + + +
+ + setName(e.target.value)} placeholder="my-app" style={input} /> + +