diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 499292e..ad3ab86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: run: task frontend:install - name: Run formatting checks - run: task fmt + run: task fmt:check - name: Run lint checks run: task lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bbee86..ad198e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: run: task frontend:install - name: Run frontend format checks - run: task frontend:fmt + run: task frontend:fmt:check - name: Run frontend lint checks run: task frontend:lint diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..900d8e0 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,3 @@ +{ + "default": true +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..03ba32c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# 文档目录 + +[API](docs/api/contracts.md) + +## 规范 + +[目录规范](docs/standards/directory.md) +[后端规范](docs/standards/backend.md) +[前端规范](docs/standards/frontend.md) +[运维规范](docs/standards/ops.md) + +## 设计 + +[容器实例设计](docs/design-docs/instance_lifecycle.md) + +## 执行计划 + +[执行计划目录说明](docs/exec-plans/README.md) diff --git a/Readme.md b/Readme.md index ab4c7a3..c4187c5 100644 --- a/Readme.md +++ b/Readme.md @@ -4,41 +4,48 @@ ## 运行指南 -### Task 命令 ```bash task dev # 一键启动前后端开发服务 -task fmt # 执行全局格式检查(当前依赖 backend:fmt) -task vet # 执行全局静态检查(当前依赖 backend:vet) -task test # 执行全局测试(当前依赖 backend:test) -task build # 统一编译前后端 -task clean # 清理构建产物 -task frontend:install # 安装前端依赖 ``` ## 待办清单 -### 实例生命周期与资源调度 -- [x] 开服与停服 -- [x] 实例的创建与删除 -- [ ] 实例重启与强制结束进程 -- [ ] 基于 Cgroups 的 CPU 与内存资源配额可视化配置 +### 镜像与模板 + +- [x] 静态镜像注册表(后端 JSON 配置) +- [x] 镜像市场前端页面(浏览、筛选、选用镜像) +- [x] 基于 YAML 的游戏模板规范设计 +- [ ] 模板解析引擎(根据模板自动生成 Docker 启动参数) + +### 实例生命周期 + +- [x] 容器基础生命周期:创建、删除、开启、停止 +- [x] 容器运行状态实时同步 +- [x] 创建容器时支持镜像选择 +- [x] 创建容器时支持环境变量注入 +- [x] 创建容器时支持 Volume 持久化目录挂载 +- [ ] 创建容器时支持动态端口映射与防冲突检测 +- [ ] 实例重启与强制终止 +- [ ] 实例详情页(镜像信息、端口、环境变量、运行时长等) + +### 资源管控与监控 + +- [ ] 基于 Cgroups 的 CPU 与内存资源配额配置 +- [ ] 容器 CPU、内存、网络 I/O 运行态数据采集 +- [ ] 前端实时折线图表展示硬件运行态数据 + +### 实时交互 -### 实时交互与性能监控 - [ ] Web 控制台标准输出日志实时推流 -- [ ] 网页端在线交互指令无缝下发 -- [ ] 容器 CPU、内存、网络 I/O 运行态数据实时图表展示 +- [ ] 网页端在线交互指令下发 +- [ ] 前端终端高亮渲染及日志自动滚动 -### 数据灾备与持久化 -- [ ] 游戏容器 Volume 持久化目录挂载映射 -- [ ] 游戏存档/数据一键手动快照备份 -- [ ] 基于 Cron 表达式的自动化定时备份任务 -- [ ] 一键回档功能 +### 运维与文件管理 -### 在线文件管理与差异化配置 - [ ] 可视化 Web 文件浏览器 -- [ ] 游戏基础配置文件的在线文本编辑器 -- [ ] 大文件 (Mod/插件) 上传及在线解压缩 - -## 注意事项 -默认 SQLite 数据库路径为 `backend/data/minedock.db`(在 `backend` 目录启动时对应 `data/minedock.db`)。 -可通过环境变量 `MINEDOCK_DB_PATH` 覆盖。 \ No newline at end of file +- [ ] 游戏配置文件在线文本编辑器 +- [ ] 大文件上传及在线解压缩 +- [ ] 对接第三方社区 API,实现整合包一键解析与下载 +- [ ] 游戏存档一键手动快照备份 +- [ ] 基于 Cron 表达式的自动化定时备份 +- [ ] 一键回档功能 diff --git a/Taskfile.yml b/Taskfile.yml index fdc0a24..9981ef3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -9,7 +9,7 @@ tasks: cmds: - go run main.go - backend:fmt: + backend:fmt:check: desc: Verify backend Go formatting dir: backend cmds: @@ -21,12 +21,24 @@ tasks: exit 1 fi + backend:fmt:fix: + desc: Format backend Go files with gofmt + dir: backend + cmds: + - gofmt -w . + backend:vet: desc: Run backend go vet checks dir: backend cmds: - go vet ./... + backend:lint: + desc: Run backend golangci-lint checks + dir: backend + cmds: + - go run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 run --config .golangci.yml ./... + backend:test: desc: Run backend tests dir: backend @@ -64,12 +76,59 @@ tasks: cmds: - npm run lint - frontend:fmt: + frontend:fmt:check: desc: Run frontend Prettier format checks dir: frontend cmds: - npm run format:check + frontend:fmt:fix: + desc: Format frontend files with Prettier + dir: frontend + cmds: + - npm run format + + docs:todo: + desc: Generate docs/exec-plans/TODO.md from TODO comments + cmds: + - go run ./scripts/todo-gen/main.go -root . -out ./docs/exec-plans/TODO.md + + docs:lint:rules: + desc: Run Markdown rule checks for the knowledge base + cmds: + - npx --yes markdownlint-cli2 "AGENTS.md" "Readme.md" "docs/**/*.md" + + docs:lint:rules:fix: + desc: Auto-fix Markdown rule violations where possible + cmds: + - npx --yes markdownlint-cli2 --fix "AGENTS.md" "Readme.md" "docs/**/*.md" + + docs:links:check: + desc: Check Markdown links for the knowledge base + cmds: + - npx --yes markdown-link-check -q "AGENTS.md" "Readme.md" "docs" + + docs:lint: + desc: Run documentation lint checks + deps: + - docs:lint:rules + - docs:links:check + + docs:lint:fix: + desc: Auto-fix documentation lint issues where possible + deps: + - docs:lint:rules:fix + + docs:fmt:check: + desc: Run Markdown formatting checks + cmds: + - npx --yes prettier --check "AGENTS.md" "Readme.md" "docs/**/*.md" + + docs:fmt:fix: + desc: Format Markdown documents with Prettier + cmds: + - npx --yes prettier --write "AGENTS.md" "Readme.md" "docs/**/*.md" + dev: desc: Run backend and frontend dev servers in parallel deps: @@ -87,16 +146,26 @@ tasks: cmds: - rm -rf backend/bin frontend/dist - fmt: + fmt:check: desc: Run global formatting checks deps: - - backend:fmt - - frontend:fmt + - backend:fmt:check + - frontend:fmt:check + - docs:fmt:check + + fmt:fix: + desc: Run global formatting fixes + deps: + - backend:fmt:fix + - frontend:fmt:fix + - docs:fmt:fix lint: desc: Run global lint checks deps: + - backend:lint - frontend:lint + - docs:lint vet: desc: Run global vet checks diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 0000000..5bb27df --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,17 @@ +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + disable-all: true + enable: + - govet + - staticcheck + - ineffassign + - unused + - gosimple + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/backend/games.json b/backend/games.json new file mode 100644 index 0000000..7755f42 --- /dev/null +++ b/backend/games.json @@ -0,0 +1,23 @@ +[ + { + "id": "minecraft-java", + "name": "Minecraft Java Edition", + "description": "最流行的 Minecraft Java 版服务器,支持原版/Forge/Fabric/Paper 等多种模组加载器。", + "category": "minecraft", + "icon": "minecraft-java" + }, + { + "id": "minecraft-bedrock", + "name": "Minecraft Bedrock Edition", + "description": "Minecraft 基岩版服务器,支持手机/主机/Win10 跨平台联机。", + "category": "minecraft", + "icon": "minecraft-bedrock" + }, + { + "id": "terraria", + "name": "Terraria", + "description": "Terraria 专用服务器,支持 tShock 管理。", + "category": "sandbox", + "icon": "terraria" + } +] diff --git a/backend/go.mod b/backend/go.mod index 880d95e..df74b5f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module minedock/backend go 1.25.0 require ( + github.com/coder/websocket v1.8.14 github.com/docker/docker v28.0.1+incompatible modernc.org/sqlite v1.47.0 ) @@ -37,6 +38,7 @@ require ( go.opentelemetry.io/otel/trace v1.42.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 3064815..0e31d0f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,6 +6,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -139,6 +141,7 @@ google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/backend/internal/api/config_handler.go b/backend/internal/api/config_handler.go new file mode 100644 index 0000000..f44c6a2 --- /dev/null +++ b/backend/internal/api/config_handler.go @@ -0,0 +1,94 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + + "minedock/backend/internal/model" + "minedock/backend/internal/service" +) + +// InstanceConfigurator 定义配置 Handler 依赖的业务能力。 +type InstanceConfigurator interface { + GetInstanceConfig(ctx context.Context, containerID string) (*service.InstanceConfig, error) + UpdateInstanceConfig( + ctx context.Context, + containerID string, + params map[string]string, + ports []model.PortMapping, + ) (string, error) +} + +// ConfigHandler 暴露容器配置相关 HTTP 处理器。 +type ConfigHandler struct { + cfg InstanceConfigurator +} + +// NewConfigHandler 创建 ConfigHandler。 +func NewConfigHandler(cfg InstanceConfigurator) *ConfigHandler { + return &ConfigHandler{cfg: cfg} +} + +type updateConfigRequest struct { + Params map[string]string `json:"params"` + Ports []model.PortMapping `json:"ports"` +} + +type updateConfigResponse struct { + Status string `json:"status"` + ContainerID string `json:"container_id"` +} + +// HandleGetConfig 处理 GET /api/instances/{id}/config。 +func (h *ConfigHandler) HandleGetConfig(w http.ResponseWriter, r *http.Request) { + if h == nil || h.cfg == nil { + writeJSON(w, http.StatusServiceUnavailable, statusResponse{Status: "error", Error: "config service unavailable"}) + return + } + + id, ok := pathContainerID(r) + if !ok { + writeJSON(w, http.StatusBadRequest, statusResponse{Status: "error", Error: "invalid container id"}) + return + } + + cfg, err := h.cfg.GetInstanceConfig(r.Context(), id) + if err != nil { + writeJSON(w, mapErrorCode(err), statusResponse{Status: "error", Error: err.Error()}) + return + } + + writeJSON(w, http.StatusOK, cfg) +} + +// HandleUpdateConfig 处理 PUT /api/instances/{id}/config。 +func (h *ConfigHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Request) { + if h == nil || h.cfg == nil { + writeJSON(w, http.StatusServiceUnavailable, statusResponse{Status: "error", Error: "config service unavailable"}) + return + } + + id, ok := pathContainerID(r) + if !ok { + writeJSON(w, http.StatusBadRequest, statusResponse{Status: "error", Error: "invalid container id"}) + return + } + + var req updateConfigRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, statusResponse{Status: "error", Error: "invalid json body"}) + return + } + if req.Params == nil { + req.Params = map[string]string{} + } + + newID, err := h.cfg.UpdateInstanceConfig(r.Context(), id, req.Params, req.Ports) + if err != nil { + writeJSON(w, mapErrorCode(err), statusResponse{Status: "error", Error: err.Error()}) + return + } + + writeJSON(w, http.StatusOK, updateConfigResponse{Status: "success", ContainerID: newID}) +} diff --git a/backend/internal/api/config_handler_test.go b/backend/internal/api/config_handler_test.go new file mode 100644 index 0000000..e2964d9 --- /dev/null +++ b/backend/internal/api/config_handler_test.go @@ -0,0 +1,163 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "minedock/backend/internal/model" + "minedock/backend/internal/service" +) + +type mockConfigurator struct { + getFn func(ctx context.Context, containerID string) (*service.InstanceConfig, error) + updateFn func(ctx context.Context, containerID string, params map[string]string, ports []model.PortMapping) (string, error) +} + +func (m *mockConfigurator) GetInstanceConfig(ctx context.Context, containerID string) (*service.InstanceConfig, error) { + if m == nil || m.getFn == nil { + return nil, errTest + } + return m.getFn(ctx, containerID) +} + +func (m *mockConfigurator) UpdateInstanceConfig( + ctx context.Context, + containerID string, + params map[string]string, + ports []model.PortMapping, +) (string, error) { + if m == nil || m.updateFn == nil { + return "", errTest + } + return m.updateFn(ctx, containerID, params, ports) +} + +func newConfigTestRouter(cfg *mockConfigurator) http.Handler { + h := NewHandler(&mockService{ + listFn: func(_ context.Context) ([]model.Instance, error) { + return []model.Instance{}, nil + }, + createFn: func(_ context.Context, _, _ string, _ map[string]string, _ []model.PortMapping) (string, error) { + return "", nil + }, + startFn: func(_ context.Context, _ string) error { return nil }, + stopFn: func(_ context.Context, _ string) error { return nil }, + deleteFn: func(_ context.Context, _ string) error { + return nil + }, + }) + + return NewRouter(h, nil, nil, nil, NewConfigHandler(cfg)) +} + +func TestGetConfig_Success(t *testing.T) { + router := newConfigTestRouter(&mockConfigurator{ + getFn: func(_ context.Context, containerID string) (*service.InstanceConfig, error) { + if containerID != "c1" { + t.Fatalf("unexpected container id: %s", containerID) + } + return &service.InstanceConfig{ + GameID: "minecraft-java", + Status: "Stopped", + Ports: []model.PortMapping{{Host: 25565, Container: 25565, Protocol: "tcp"}}, + Params: map[string]string{"MAX_PLAYERS": "20"}, + }, nil + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/instances/c1/config", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var got service.InstanceConfig + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.GameID != "minecraft-java" || got.Params["MAX_PLAYERS"] != "20" { + t.Fatalf("unexpected response: %+v", got) + } + if len(got.Ports) != 1 || got.Ports[0].Host != 25565 { + t.Fatalf("unexpected ports: %+v", got.Ports) + } +} + +func TestGetConfig_InvalidContainerID(t *testing.T) { + router := newConfigTestRouter(&mockConfigurator{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/instances/%20/config", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestUpdateConfig_Success(t *testing.T) { + router := newConfigTestRouter(&mockConfigurator{ + updateFn: func(_ context.Context, containerID string, params map[string]string, ports []model.PortMapping) (string, error) { + if containerID != "c1" { + t.Fatalf("unexpected container id: %s", containerID) + } + if params["MAX_PLAYERS"] != "50" { + t.Fatalf("unexpected params: %+v", params) + } + if len(ports) != 1 || ports[0].Host != 25575 || ports[0].Container != 25565 { + t.Fatalf("unexpected ports: %+v", ports) + } + return "c2", nil + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/api/instances/c1/config", strings.NewReader(`{"params":{"MAX_PLAYERS":"50"},"ports":[{"host":25575,"container":25565,"protocol":"tcp"}]}`)) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var got updateConfigResponse + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Status != "success" || got.ContainerID != "c2" { + t.Fatalf("unexpected response: %+v", got) + } +} + +func TestUpdateConfig_InvalidJSON(t *testing.T) { + router := newConfigTestRouter(&mockConfigurator{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/api/instances/c1/config", strings.NewReader("{bad")) + router.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestUpdateConfig_ContainerNotStopped(t *testing.T) { + router := newConfigTestRouter(&mockConfigurator{ + updateFn: func(_ context.Context, _ string, _ map[string]string, _ []model.PortMapping) (string, error) { + return "", model.ErrContainerNotStopped + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/api/instances/c1/config", strings.NewReader(`{"params":{}}`)) + router.ServeHTTP(w, r) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", w.Code) + } +} diff --git a/backend/internal/api/console_handler.go b/backend/internal/api/console_handler.go new file mode 100644 index 0000000..b643650 --- /dev/null +++ b/backend/internal/api/console_handler.go @@ -0,0 +1,137 @@ +package api + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/coder/websocket" + "github.com/docker/docker/api/types" +) + +const consoleWriteTimeout = 3 * time.Second + +// ContainerConsole 定义控制台处理器依赖的 Attach 能力。 +type ContainerConsole interface { + Attach(ctx context.Context, containerID string) (types.HijackedResponse, error) +} + +// ConsoleHandler 暴露容器控制台 WebSocket 处理器。 +type ConsoleHandler struct { + console ContainerConsole +} + +// NewConsoleHandler 创建 ConsoleHandler。 +func NewConsoleHandler(console ContainerConsole) *ConsoleHandler { + return &ConsoleHandler{console: console} +} + +// HandleConsole 处理 GET /api/ws/console/{id}。 +func (h *ConsoleHandler) HandleConsole(w http.ResponseWriter, r *http.Request) { + if h == nil || h.console == nil { + writeJSON(w, http.StatusServiceUnavailable, statusResponse{Status: "error", Error: "console service unavailable"}) + return + } + + id, ok := pathContainerID(r) + if !ok { + writeJSON(w, http.StatusBadRequest, statusResponse{Status: "error", Error: "invalid container id"}) + return + } + + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{}) + if err != nil { + return + } + + hijacked, err := h.console.Attach(r.Context(), id) + if err != nil { + _ = conn.Close(websocket.StatusPolicyViolation, err.Error()) + return + } + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + defer hijacked.Close() + + errCh := make(chan error, 2) + + go func() { + errCh <- pipeDockerToWebSocket(ctx, conn, hijacked) + }() + + go func() { + errCh <- pipeWebSocketToDocker(ctx, conn, hijacked) + }() + + if err := <-errCh; err != nil { + if code := websocket.CloseStatus(err); code == websocket.StatusNormalClosure || code == websocket.StatusGoingAway { + _ = conn.Close(websocket.StatusNormalClosure, "closed") + return + } + _ = conn.Close(websocket.StatusInternalError, "console bridge failed") + return + } + + _ = conn.Close(websocket.StatusNormalClosure, "closed") +} + +func pipeDockerToWebSocket( + ctx context.Context, + conn *websocket.Conn, + hijacked types.HijackedResponse, +) error { + writer := &wsBinaryWriter{ctx: ctx, conn: conn} + _, err := io.Copy(writer, hijacked.Reader) + if errors.Is(err, io.EOF) { + return nil + } + return err +} + +func pipeWebSocketToDocker(ctx context.Context, conn *websocket.Conn, hijacked types.HijackedResponse) error { + for { + typ, data, err := conn.Read(ctx) + if err != nil { + return err + } + + if typ != websocket.MessageText && typ != websocket.MessageBinary { + continue + } + + if len(data) == 0 { + continue + } + + if _, err := hijacked.Conn.Write(data); err != nil { + return fmt.Errorf("write stdin: %w", err) + } + } +} + +type wsBinaryWriter struct { + ctx context.Context + conn *websocket.Conn +} + +func (w *wsBinaryWriter) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + buf := make([]byte, len(p)) + copy(buf, p) + + writeCtx, cancel := context.WithTimeout(w.ctx, consoleWriteTimeout) + defer cancel() + + if err := w.conn.Write(writeCtx, websocket.MessageBinary, buf); err != nil { + return 0, err + } + + return len(p), nil +} diff --git a/backend/internal/api/game_handlers.go b/backend/internal/api/game_handlers.go new file mode 100644 index 0000000..14a061c --- /dev/null +++ b/backend/internal/api/game_handlers.go @@ -0,0 +1,68 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strings" + + "minedock/backend/internal/model" +) + +// GameLister 定义游戏目录 Handler 依赖的查询操作。 +type GameLister interface { + ListGames(ctx context.Context) []model.Game + GetTemplate(ctx context.Context, id string) (model.GameTemplate, error) +} + +// GameHandler 暴露游戏目录与模板相关 HTTP 处理器。 +type GameHandler struct { + games GameLister +} + +// NewGameHandler 创建 GameHandler。 +func NewGameHandler(g GameLister) *GameHandler { + return &GameHandler{games: g} +} + +// GetGames 处理 GET /api/games,返回游戏目录列表。 +func (h *GameHandler) GetGames(w http.ResponseWriter, r *http.Request) { + if h == nil || h.games == nil { + writeJSON(w, http.StatusInternalServerError, statusResponse{Status: "error", Error: "game service unavailable"}) + return + } + + games := h.games.ListGames(r.Context()) + writeJSON(w, http.StatusOK, games) +} + +// GetGameTemplate 处理 GET /api/games/{id}/template,按需返回模板详情。 +func (h *GameHandler) GetGameTemplate(w http.ResponseWriter, r *http.Request) { + if h == nil || h.games == nil { + writeJSON(w, http.StatusInternalServerError, statusResponse{Status: "error", Error: "game service unavailable"}) + return + } + + id := strings.TrimSpace(r.PathValue("id")) + if id == "" || strings.Contains(id, "/") { + writeJSON(w, http.StatusBadRequest, statusResponse{Status: "error", Error: "invalid game id"}) + return + } + + tpl, err := h.games.GetTemplate(r.Context(), id) + if err != nil { + switch { + case errors.Is(err, model.ErrGameNotFound): + writeJSON(w, http.StatusNotFound, statusResponse{Status: "error", Error: err.Error()}) + case errors.Is(err, model.ErrTemplateNotFound): + writeJSON(w, http.StatusInternalServerError, statusResponse{Status: "error", Error: err.Error()}) + case errors.Is(err, model.ErrTemplateInvalid): + writeJSON(w, http.StatusInternalServerError, statusResponse{Status: "error", Error: err.Error()}) + default: + writeJSON(w, http.StatusInternalServerError, statusResponse{Status: "error", Error: err.Error()}) + } + return + } + + writeJSON(w, http.StatusOK, tpl) +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 18f7c36..7490e16 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -1,37 +1,55 @@ package api import ( + "context" "encoding/json" + "errors" "net/http" "strings" - "minedock/backend/internal/service" - "minedock/backend/internal/store" + "minedock/backend/internal/model" ) -// Handler exposes HTTP handlers. +// InstanceService 定义 API Handler 依赖的业务操作。 +type InstanceService interface { + ListInstances(ctx context.Context) ([]model.Instance, error) + CreateInstance(ctx context.Context, name, gameID string, params map[string]string, ports []model.PortMapping) (string, error) + StartInstance(ctx context.Context, containerID string) error + StopInstance(ctx context.Context, containerID string) error + DeleteInstance(ctx context.Context, containerID string) error +} + +// Handler 暴露 HTTP 处理器。 type Handler struct { - svc *service.DockerService + svc InstanceService } -func NewHandler(svc *service.DockerService) *Handler { +// NewHandler 使用给定的实例服务创建 Handler。 +func NewHandler(svc InstanceService) *Handler { return &Handler{svc: svc} } +// createRequest 定义创建实例请求体。 type createRequest struct { - Name string `json:"name"` + Name string `json:"name"` + GameID string `json:"game_id"` + Params map[string]string `json:"params"` + Ports []model.PortMapping `json:"ports"` } +// statusResponse 定义通用状态响应体。 type statusResponse struct { Status string `json:"status"` Error string `json:"error,omitempty"` } +// createResponse 定义创建成功时的响应体。 type createResponse struct { Status string `json:"status"` ContainerID string `json:"container_id"` } +// GetInstances 处理 GET /api/instances 并返回所有托管实例。 func (h *Handler) GetInstances(w http.ResponseWriter, r *http.Request) { instances, err := h.svc.ListInstances(r.Context()) if err != nil { @@ -41,6 +59,7 @@ func (h *Handler) GetInstances(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, instances) } +// CreateInstance 处理 POST /api/instances 并创建新实例。 func (h *Handler) CreateInstance(w http.ResponseWriter, r *http.Request) { var req createRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -53,20 +72,27 @@ func (h *Handler) CreateInstance(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, statusResponse{Status: "error", Error: "name is required"}) return } + req.GameID = strings.TrimSpace(req.GameID) + if req.GameID == "" { + writeJSON(w, http.StatusBadRequest, statusResponse{Status: "error", Error: "game_id is required"}) + return + } + if req.Params == nil { + req.Params = map[string]string{} + } - id, err := h.svc.CreateInstance(r.Context(), req.Name) + id, err := h.svc.CreateInstance(r.Context(), req.Name, req.GameID, req.Params, req.Ports) if err != nil { - code := http.StatusInternalServerError - if err == store.ErrNameExists { - code = http.StatusConflict - } - writeJSON(w, code, statusResponse{Status: "error", Error: err.Error()}) + writeJSON(w, mapErrorCode(err), statusResponse{Status: "error", Error: err.Error()}) return } writeJSON(w, http.StatusOK, createResponse{Status: "success", ContainerID: id}) } +// TODO: 抽取 start/stop/delete 的公共处理流程。 +// 这三个处理器都重复了:解析容器 ID、调用 Service、写回状态 JSON。 +// StartInstance 处理 POST /api/instances/{id}/start 并启动实例。 func (h *Handler) StartInstance(w http.ResponseWriter, r *http.Request) { id, ok := pathContainerID(r) if !ok { @@ -82,6 +108,7 @@ func (h *Handler) StartInstance(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, statusResponse{Status: "success"}) } +// StopInstance 处理 POST /api/instances/{id}/stop 并停止实例。 func (h *Handler) StopInstance(w http.ResponseWriter, r *http.Request) { id, ok := pathContainerID(r) if !ok { @@ -97,6 +124,7 @@ func (h *Handler) StopInstance(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, statusResponse{Status: "success"}) } +// DeleteInstance 处理 DELETE /api/instances/{id} 并删除实例。 func (h *Handler) DeleteInstance(w http.ResponseWriter, r *http.Request) { id, ok := pathContainerID(r) if !ok { @@ -105,17 +133,14 @@ func (h *Handler) DeleteInstance(w http.ResponseWriter, r *http.Request) { } if err := h.svc.DeleteInstance(r.Context(), id); err != nil { - code := http.StatusInternalServerError - if err == service.ErrInstanceRunning { - code = http.StatusConflict - } - writeJSON(w, code, statusResponse{Status: "error", Error: err.Error()}) + writeJSON(w, mapErrorCode(err), statusResponse{Status: "error", Error: err.Error()}) return } writeJSON(w, http.StatusOK, statusResponse{Status: "success"}) } +// pathContainerID 解析并校验 URL 路径中的容器 ID。 func pathContainerID(r *http.Request) (string, bool) { id := strings.TrimSpace(r.PathValue("id")) if id == "" || strings.Contains(id, "/") { @@ -124,7 +149,32 @@ func pathContainerID(r *http.Request) (string, bool) { return id, true } +// mapErrorCode 将领域错误统一映射为 HTTP 状态码。 +func mapErrorCode(err error) int { + switch { + case errors.Is(err, model.ErrGameNotFound): + return http.StatusBadRequest + case errors.Is(err, model.ErrInvalidParams): + return http.StatusBadRequest + case errors.Is(err, model.ErrNameExists): + return http.StatusConflict + case errors.Is(err, model.ErrInstanceRunning): + return http.StatusConflict + case errors.Is(err, model.ErrContainerNotStopped): + return http.StatusConflict + case errors.Is(err, model.ErrTemplateNotFound): + return http.StatusInternalServerError + case errors.Is(err, model.ErrTemplateInvalid): + return http.StatusInternalServerError + default: + return http.StatusInternalServerError + } +} + +// writeJSON 按指定状态码写入 JSON 响应。 func writeJSON(w http.ResponseWriter, code int, v any) { + // 说明:当前会忽略编码错误,因为状态码可能已写入。 + // TODO: 增加统一的编码错误日志,提升可观测性。 w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) diff --git a/backend/internal/api/handlers_test.go b/backend/internal/api/handlers_test.go new file mode 100644 index 0000000..46b5b42 --- /dev/null +++ b/backend/internal/api/handlers_test.go @@ -0,0 +1,456 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "minedock/backend/internal/model" +) + +// mockService 为 Handler 测试实现 InstanceService。 +type mockService struct { + listFn func(ctx context.Context) ([]model.Instance, error) + createFn func(ctx context.Context, name, gameID string, params map[string]string, ports []model.PortMapping) (string, error) + startFn func(ctx context.Context, id string) error + stopFn func(ctx context.Context, id string) error + deleteFn func(ctx context.Context, id string) error +} + +func (m *mockService) ListInstances(ctx context.Context) ([]model.Instance, error) { + return m.listFn(ctx) +} +func (m *mockService) CreateInstance( + ctx context.Context, + name, gameID string, + params map[string]string, + ports []model.PortMapping, +) (string, error) { + return m.createFn(ctx, name, gameID, params, ports) +} +func (m *mockService) StartInstance(ctx context.Context, id string) error { + return m.startFn(ctx, id) +} +func (m *mockService) StopInstance(ctx context.Context, id string) error { + return m.stopFn(ctx, id) +} +func (m *mockService) DeleteInstance(ctx context.Context, id string) error { + return m.deleteFn(ctx, id) +} + +type mockRegistryLister struct { + listFn func(ctx context.Context) []model.Game + getTplByID func(ctx context.Context, id string) (model.GameTemplate, error) +} + +func (m *mockRegistryLister) ListGames(ctx context.Context) []model.Game { + if m == nil || m.listFn == nil { + return []model.Game{} + } + return m.listFn(ctx) +} + +func (m *mockRegistryLister) GetTemplate(ctx context.Context, id string) (model.GameTemplate, error) { + if m == nil || m.getTplByID == nil { + return model.GameTemplate{}, model.ErrGameNotFound + } + return m.getTplByID(ctx, id) +} + +func newTestRouter(m *mockService) http.Handler { + h := NewHandler(m) + gh := NewGameHandler(&mockRegistryLister{ + listFn: func(_ context.Context) []model.Game { + return []model.Game{} + }, + getTplByID: func(_ context.Context, _ string) (model.GameTemplate, error) { + return model.GameTemplate{}, model.ErrGameNotFound + }, + }) + return NewRouter(h, gh, nil, nil, nil) +} + +// --- GET /api/instances 场景 --- + +func TestGetInstances_Success(t *testing.T) { + router := newTestRouter(&mockService{ + listFn: func(_ context.Context) ([]model.Instance, error) { + return []model.Instance{ + {ContainerID: "c1", Name: "server-1", Status: "Running"}, + }, nil + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/instances", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var got []model.Instance + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 || got[0].ContainerID != "c1" { + t.Fatalf("unexpected response: %+v", got) + } +} + +func TestGetInstances_Error(t *testing.T) { + router := newTestRouter(&mockService{ + listFn: func(_ context.Context) ([]model.Instance, error) { + return nil, errTest + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/instances", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } +} + +func TestGetGames_Success(t *testing.T) { + h := NewHandler(&mockService{}) + gh := NewGameHandler(&mockRegistryLister{ + listFn: func(_ context.Context) []model.Game { + return []model.Game{{ID: "minecraft-java", Name: "Minecraft Java"}} + }, + getTplByID: func(_ context.Context, _ string) (model.GameTemplate, error) { + return model.GameTemplate{}, model.ErrGameNotFound + }, + }) + router := NewRouter(h, gh, nil, nil, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/games", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var got []model.Game + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 || got[0].ID != "minecraft-java" { + t.Fatalf("unexpected response: %+v", got) + } +} + +func TestGetGameTemplate_Success(t *testing.T) { + h := NewHandler(&mockService{}) + gh := NewGameHandler(&mockRegistryLister{ + listFn: func(_ context.Context) []model.Game { + return []model.Game{{ID: "minecraft-java", Name: "Minecraft Java"}} + }, + getTplByID: func(_ context.Context, id string) (model.GameTemplate, error) { + if id != "minecraft-java" { + return model.GameTemplate{}, model.ErrGameNotFound + } + return model.GameTemplate{Image: model.TemplateImage{Name: "itzg/minecraft-server", Tag: "latest"}}, nil + }, + }) + router := NewRouter(h, gh, nil, nil, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/games/minecraft-java/template", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var got model.GameTemplate + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Image.FullImageRef() != "itzg/minecraft-server:latest" { + t.Fatalf("unexpected template response: %+v", got) + } +} + +func TestGetGameTemplate_NotFound(t *testing.T) { + h := NewHandler(&mockService{}) + gh := NewGameHandler(&mockRegistryLister{ + listFn: func(_ context.Context) []model.Game { + return []model.Game{} + }, + getTplByID: func(_ context.Context, _ string) (model.GameTemplate, error) { + return model.GameTemplate{}, model.ErrGameNotFound + }, + }) + router := NewRouter(h, gh, nil, nil, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/games/not-exists/template", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +// --- POST /api/instances 场景 --- + +func TestCreateInstance_Success(t *testing.T) { + router := newTestRouter(&mockService{ + createFn: func(_ context.Context, name, gameID string, params map[string]string, ports []model.PortMapping) (string, error) { + if name != "test-server" { + t.Fatalf("unexpected name: %s", name) + } + if gameID != "minecraft-java" { + t.Fatalf("unexpected game_id: %s", gameID) + } + if params["SERVER_TYPE"] != "PAPER" { + t.Fatalf("unexpected params: %+v", params) + } + if len(ports) != 1 || ports[0].Host != 25575 || ports[0].Container != 25565 || ports[0].Protocol != "tcp" { + t.Fatalf("unexpected ports: %+v", ports) + } + return "abc123", nil + }, + }) + + body := `{"name":"test-server","game_id":"minecraft-java","params":{"SERVER_TYPE":"PAPER"},"ports":[{"host":25575,"container":25565,"protocol":"tcp"}]}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp createResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Status != "success" || resp.ContainerID != "abc123" { + t.Fatalf("unexpected response: %+v", resp) + } +} + +func TestCreateInstance_InvalidJSON(t *testing.T) { + router := newTestRouter(&mockService{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader("{bad")) + router.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCreateInstance_EmptyName(t *testing.T) { + router := newTestRouter(&mockService{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(`{"name":" ","game_id":"minecraft-java"}`)) + router.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCreateInstance_EmptyGameID(t *testing.T) { + router := newTestRouter(&mockService{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(`{"name":"server","game_id":" "}`)) + router.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCreateInstance_NameConflict(t *testing.T) { + router := newTestRouter(&mockService{ + createFn: func(_ context.Context, _, _ string, _ map[string]string, _ []model.PortMapping) (string, error) { + return "", model.ErrNameExists + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(`{"name":"dup","game_id":"minecraft-java"}`)) + router.ServeHTTP(w, r) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", w.Code) + } +} + +func TestCreateInstance_GameNotFound(t *testing.T) { + router := newTestRouter(&mockService{ + createFn: func(_ context.Context, _, _ string, _ map[string]string, _ []model.PortMapping) (string, error) { + return "", model.ErrGameNotFound + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(`{"name":"dup","game_id":"not-exists"}`)) + router.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCreateInstance_InvalidParams(t *testing.T) { + router := newTestRouter(&mockService{ + createFn: func(_ context.Context, _, _ string, _ map[string]string, _ []model.PortMapping) (string, error) { + return "", fmtWrap(model.ErrInvalidParams) + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(`{"name":"dup","game_id":"minecraft-java","params":{"bad":"1"}}`)) + router.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +// --- POST /api/instances/{id}/start 场景 --- + +func TestStartInstance_Success(t *testing.T) { + router := newTestRouter(&mockService{ + startFn: func(_ context.Context, id string) error { + if id != "abc123" { + t.Fatalf("unexpected id: %s", id) + } + return nil + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances/abc123/start", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestStartInstance_Error(t *testing.T) { + router := newTestRouter(&mockService{ + startFn: func(_ context.Context, _ string) error { + return errTest + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances/abc123/start", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } +} + +// --- POST /api/instances/{id}/stop 场景 --- + +func TestStopInstance_Success(t *testing.T) { + router := newTestRouter(&mockService{ + stopFn: func(_ context.Context, id string) error { + if id != "abc123" { + t.Fatalf("unexpected id: %s", id) + } + return nil + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances/abc123/stop", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +// --- DELETE /api/instances/{id} 场景 --- + +func TestDeleteInstance_Success(t *testing.T) { + router := newTestRouter(&mockService{ + deleteFn: func(_ context.Context, id string) error { + if id != "abc123" { + t.Fatalf("unexpected id: %s", id) + } + return nil + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/api/instances/abc123", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestDeleteInstance_Running(t *testing.T) { + router := newTestRouter(&mockService{ + deleteFn: func(_ context.Context, _ string) error { + return model.ErrInstanceRunning + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/api/instances/abc123", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", w.Code) + } +} + +// errTest 是测试桩使用的通用错误。 +var errTest = errorString("test error") + +type errorString string + +func (e errorString) Error() string { return string(e) } + +func fmtWrap(err error) error { + if err == nil { + return nil + } + return wrappedError{err: err} +} + +type wrappedError struct { + err error +} + +func (e wrappedError) Error() string { return "wrapped: " + e.err.Error() } + +func (e wrappedError) Unwrap() error { return e.err } + +func TestMapErrorCode_InvalidParamsWrapped(t *testing.T) { + if got := mapErrorCode(fmtWrap(model.ErrInvalidParams)); got != http.StatusBadRequest { + t.Fatalf("expected 400 for wrapped invalid params, got %d", got) + } + if !errors.Is(fmtWrap(model.ErrInvalidParams), model.ErrInvalidParams) { + t.Fatal("expected wrapped error to match ErrInvalidParams") + } +} + +func TestMapErrorCode_ContainerNotStopped(t *testing.T) { + if got := mapErrorCode(model.ErrContainerNotStopped); got != http.StatusConflict { + t.Fatalf("expected 409 for container-not-stopped, got %d", got) + } +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index e63422e..2795008 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -1,8 +1,12 @@ package api -import "net/http" +import ( + "net/http" + "strings" +) -func NewRouter(h *Handler) http.Handler { +// NewRouter 注册 API 路由并包装中间件。 +func NewRouter(h *Handler, games *GameHandler, ws *WsHandler, console *ConsoleHandler, config *ConfigHandler) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/instances", h.GetInstances) @@ -10,14 +14,34 @@ func NewRouter(h *Handler) http.Handler { mux.HandleFunc("POST /api/instances/{id}/start", h.StartInstance) mux.HandleFunc("POST /api/instances/{id}/stop", h.StopInstance) mux.HandleFunc("DELETE /api/instances/{id}", h.DeleteInstance) + if config != nil { + mux.HandleFunc("GET /api/instances/{id}/config", config.HandleGetConfig) + mux.HandleFunc("PUT /api/instances/{id}/config", config.HandleUpdateConfig) + } + if ws != nil { + mux.HandleFunc("GET /api/ws/events", ws.HandleEvents) + } + if console != nil { + mux.HandleFunc("GET /api/ws/console/{id}", console.HandleConsole) + } + if games != nil { + mux.HandleFunc("GET /api/games", games.GetGames) + mux.HandleFunc("GET /api/games/{id}/template", games.GetGameTemplate) + } return withCORS(mux) } +// withCORS 添加宽松的 CORS 响应头并处理 OPTIONS 预检请求。 func withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/ws/") { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if r.Method == http.MethodOptions { diff --git a/backend/internal/api/ws_handler.go b/backend/internal/api/ws_handler.go new file mode 100644 index 0000000..7c53a9d --- /dev/null +++ b/backend/internal/api/ws_handler.go @@ -0,0 +1,46 @@ +package api + +import ( + "net/http" + + "github.com/coder/websocket" +) + +// EventBroadcaster 定义 WebSocket Handler 依赖的事件广播操作。 +type EventBroadcaster interface { + AddClient(conn *websocket.Conn) + RemoveClient(conn *websocket.Conn) +} + +// WsHandler 暴露 WebSocket 相关 HTTP 处理器。 +type WsHandler struct { + hub EventBroadcaster +} + +// NewWsHandler 创建 WsHandler。 +func NewWsHandler(hub EventBroadcaster) *WsHandler { + return &WsHandler{hub: hub} +} + +// HandleEvents 处理 GET /api/ws/events,将 HTTP 连接升级为 WebSocket。 +func (h *WsHandler) HandleEvents(w http.ResponseWriter, r *http.Request) { + if h == nil || h.hub == nil { + writeJSON(w, http.StatusServiceUnavailable, statusResponse{Status: "error", Error: "event hub unavailable"}) + return + } + + // 仅支持同源 WebSocket,保持默认 Origin 校验行为。 + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{}) + if err != nil { + return + } + + h.hub.AddClient(conn) + defer h.hub.RemoveClient(conn) + + for { + if _, _, err := conn.Read(r.Context()); err != nil { + return + } + } +} diff --git a/backend/internal/api/ws_handler_test.go b/backend/internal/api/ws_handler_test.go new file mode 100644 index 0000000..504f44d --- /dev/null +++ b/backend/internal/api/ws_handler_test.go @@ -0,0 +1,85 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" +) + +type mockEventBroadcaster struct { + mu sync.Mutex + addCount int + removeCount int + addCh chan struct{} + removeCh chan struct{} +} + +func (m *mockEventBroadcaster) AddClient(_ *websocket.Conn) { + m.mu.Lock() + m.addCount++ + m.mu.Unlock() + select { + case m.addCh <- struct{}{}: + default: + } +} + +func (m *mockEventBroadcaster) RemoveClient(_ *websocket.Conn) { + m.mu.Lock() + m.removeCount++ + m.mu.Unlock() + select { + case m.removeCh <- struct{}{}: + default: + } +} + +func TestWsHandler_HubUnavailable(t *testing.T) { + h := NewWsHandler(nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/ws/events", nil) + + h.HandleEvents(w, r) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", w.Code) + } +} + +func TestWsHandler_HandleEvents_AddAndRemoveClient(t *testing.T) { + mockHub := &mockEventBroadcaster{ + addCh: make(chan struct{}, 1), + removeCh: make(chan struct{}, 1), + } + + server := httptest.NewServer(http.HandlerFunc(NewWsHandler(mockHub).HandleEvents)) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.Dial(context.Background(), wsURL, nil) + if err != nil { + t.Fatalf("dial websocket: %v", err) + } + + select { + case <-mockHub.addCh: + case <-time.After(time.Second): + t.Fatal("timeout waiting AddClient") + } + + if err := conn.Close(websocket.StatusNormalClosure, "test done"); err != nil { + t.Fatalf("close websocket: %v", err) + } + + select { + case <-mockHub.removeCh: + case <-time.After(time.Second): + t.Fatal("timeout waiting RemoveClient") + } +} diff --git a/backend/internal/model/errors.go b/backend/internal/model/errors.go new file mode 100644 index 0000000..1c05920 --- /dev/null +++ b/backend/internal/model/errors.go @@ -0,0 +1,24 @@ +package model + +import "errors" + +// ErrNameExists 表示实例名称已被占用。 +var ErrNameExists = errors.New("instance name already exists") + +// ErrInstanceRunning 表示实例正在运行,删除前必须先停止。 +var ErrInstanceRunning = errors.New("instance is running, stop it before delete") + +// ErrGameNotFound 表示请求的游戏 ID 不在目录中。 +var ErrGameNotFound = errors.New("game not found") + +// ErrTemplateNotFound 表示请求的模板文件不存在。 +var ErrTemplateNotFound = errors.New("template not found") + +// ErrTemplateInvalid 表示模板内容不合法。 +var ErrTemplateInvalid = errors.New("invalid template") + +// ErrInvalidParams 表示传入了不受模板定义约束的参数。 +var ErrInvalidParams = errors.New("invalid params") + +// ErrContainerNotStopped 表示容器必须先停止,才能执行配置更新。 +var ErrContainerNotStopped = errors.New("container must be stopped to update config") diff --git a/backend/internal/model/game.go b/backend/internal/model/game.go new file mode 100644 index 0000000..01d141e --- /dev/null +++ b/backend/internal/model/game.go @@ -0,0 +1,10 @@ +package model + +// Game 描述游戏目录中的一个条目(轻量展示信息)。 +type Game struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Icon string `json:"icon"` +} diff --git a/backend/internal/model/instance.go b/backend/internal/model/instance.go index 6adb1cc..77689db 100644 --- a/backend/internal/model/instance.go +++ b/backend/internal/model/instance.go @@ -4,5 +4,6 @@ package model type Instance struct { ContainerID string `json:"container_id"` Name string `json:"name"` + GameID string `json:"game_id"` Status string `json:"status"` } diff --git a/backend/internal/model/template.go b/backend/internal/model/template.go new file mode 100644 index 0000000..3ddf764 --- /dev/null +++ b/backend/internal/model/template.go @@ -0,0 +1,87 @@ +package model + +import "strings" + +// GameTemplate 描述一个游戏服务器的完整技术配置模板。 +type GameTemplate struct { + Image TemplateImage `json:"image" yaml:"image"` + Container ContainerConfig `json:"container" yaml:"container"` + Params []TemplateParam `json:"params" yaml:"params"` +} + +// TemplateImage Docker 镜像配置。 +type TemplateImage struct { + Name string `json:"name" yaml:"name"` + Tag string `json:"tag" yaml:"tag"` +} + +// FullImageRef 返回完整的镜像引用(name:tag)。 +func (i TemplateImage) FullImageRef() string { + name := strings.TrimSpace(i.Name) + if name == "" { + return "" + } + + tag := strings.TrimSpace(i.Tag) + if tag == "" { + tag = "latest" + } + + return name + ":" + tag +} + +// ContainerConfig 容器运行配置。 +type ContainerConfig struct { + Ports []PortMapping `json:"ports" yaml:"ports"` + Env map[string]string `json:"env" yaml:"env"` + Volumes []VolumeMount `json:"volumes" yaml:"volumes"` + Resources *ResourceLimits `json:"resources,omitempty" yaml:"resources"` + Command []string `json:"command,omitempty" yaml:"command"` + HealthCheck *HealthCheckConfig `json:"health_check,omitempty" yaml:"health_check"` +} + +// PortMapping 端口映射配置。 +type PortMapping struct { + Host int `json:"host" yaml:"host"` + Container int `json:"container" yaml:"container"` + Protocol string `json:"protocol" yaml:"protocol"` +} + +// VolumeMount 卷挂载配置。 +type VolumeMount struct { + Name string `json:"name" yaml:"name"` + ContainerPath string `json:"container_path" yaml:"container_path"` + ReadOnly bool `json:"readonly" yaml:"readonly"` +} + +// ResourceLimits 资源限制配置。 +type ResourceLimits struct { + Memory string `json:"memory" yaml:"memory"` + CPU float64 `json:"cpu" yaml:"cpu"` +} + +// HealthCheckConfig 健康检查配置。 +type HealthCheckConfig struct { + Test []string `json:"test" yaml:"test"` + Interval string `json:"interval" yaml:"interval"` + Timeout string `json:"timeout" yaml:"timeout"` + Retries int `json:"retries" yaml:"retries"` + StartPeriod string `json:"start_period" yaml:"start_period"` +} + +// TemplateParam 用户可定制参数定义。 +type TemplateParam struct { + Key string `json:"key" yaml:"key"` + Label string `json:"label" yaml:"label"` + Description string `json:"description" yaml:"description"` + Type string `json:"type" yaml:"type"` + Default any `json:"default" yaml:"default"` + Options []ParamOption `json:"options,omitempty" yaml:"options"` + EnvVar string `json:"env_var,omitempty" yaml:"env_var"` +} + +// ParamOption select 类型参数的可选项。 +type ParamOption struct { + Value string `json:"value" yaml:"value"` + Label string `json:"label" yaml:"label"` +} diff --git a/backend/internal/service/console_service.go b/backend/internal/service/console_service.go new file mode 100644 index 0000000..43c5e73 --- /dev/null +++ b/backend/internal/service/console_service.go @@ -0,0 +1,55 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" +) + +// ErrContainerNotRunning 表示容器当前不处于运行状态,无法建立控制台 Attach。 +var ErrContainerNotRunning = errors.New("container is not running") + +// consoleDockerClient 定义 ConsoleService 依赖的最小 Docker 能力集合。 +type consoleDockerClient interface { + ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) + ContainerAttach(ctx context.Context, containerID string, options container.AttachOptions) (types.HijackedResponse, error) +} + +// ConsoleService 封装容器控制台 Attach 的业务逻辑。 +type ConsoleService struct { + cli consoleDockerClient +} + +// NewConsoleService 创建 ConsoleService。 +func NewConsoleService(cli *client.Client) *ConsoleService { + return &ConsoleService{cli: cli} +} + +// Attach 连接到运行中容器的 stdin/stdout/stderr。 +func (s *ConsoleService) Attach(ctx context.Context, containerID string) (types.HijackedResponse, error) { + inspect, err := s.cli.ContainerInspect(ctx, containerID) + if err != nil { + return types.HijackedResponse{}, fmt.Errorf("inspect container: %w", err) + } + + if inspect.State == nil || !inspect.State.Running { + return types.HijackedResponse{}, ErrContainerNotRunning + } + + hijacked, err := s.cli.ContainerAttach(ctx, containerID, container.AttachOptions{ + Logs: true, + Stream: true, + Stdin: true, + Stdout: true, + Stderr: true, + }) + if err != nil { + return types.HijackedResponse{}, fmt.Errorf("attach container: %w", err) + } + + return hijacked, nil +} diff --git a/backend/internal/service/console_service_test.go b/backend/internal/service/console_service_test.go new file mode 100644 index 0000000..73b4bdc --- /dev/null +++ b/backend/internal/service/console_service_test.go @@ -0,0 +1,101 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" +) + +type fakeConsoleDockerClient struct { + inspectResp container.InspectResponse + inspectErr error + + attachResp types.HijackedResponse + attachErr error + attachCalled bool + attachID string + attachOpts container.AttachOptions +} + +func (f *fakeConsoleDockerClient) ContainerInspect(_ context.Context, _ string) (container.InspectResponse, error) { + if f.inspectErr != nil { + return container.InspectResponse{}, f.inspectErr + } + return f.inspectResp, nil +} + +func (f *fakeConsoleDockerClient) ContainerAttach( + _ context.Context, + containerID string, + options container.AttachOptions, +) (types.HijackedResponse, error) { + f.attachCalled = true + f.attachID = containerID + f.attachOpts = options + + if f.attachErr != nil { + return types.HijackedResponse{}, f.attachErr + } + return f.attachResp, nil +} + +func TestConsoleServiceAttach_RunningContainer(t *testing.T) { + fake := &fakeConsoleDockerClient{ + inspectResp: container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{State: &container.State{Running: true}}, + Config: &container.Config{Tty: true}, + }, + attachResp: types.HijackedResponse{}, + } + + svc := &ConsoleService{cli: fake} + + _, err := svc.Attach(context.Background(), "c1") + if err != nil { + t.Fatalf("attach: %v", err) + } + if !fake.attachCalled { + t.Fatal("expected attach to be called") + } + if fake.attachID != "c1" { + t.Fatalf("unexpected attach id: %s", fake.attachID) + } + if !fake.attachOpts.Stream || !fake.attachOpts.Stdin || !fake.attachOpts.Stdout || !fake.attachOpts.Stderr { + t.Fatalf("unexpected attach options: %+v", fake.attachOpts) + } +} + +func TestConsoleServiceAttach_StoppedContainer(t *testing.T) { + fake := &fakeConsoleDockerClient{ + inspectResp: container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{State: &container.State{Running: false}}, + Config: &container.Config{Tty: false}, + }, + } + + svc := &ConsoleService{cli: fake} + + _, err := svc.Attach(context.Background(), "c1") + if !errors.Is(err, ErrContainerNotRunning) { + t.Fatalf("expected ErrContainerNotRunning, got %v", err) + } + if fake.attachCalled { + t.Fatal("attach should not be called for stopped container") + } +} + +func TestConsoleServiceAttach_ContainerNotFound(t *testing.T) { + fake := &fakeConsoleDockerClient{inspectErr: errors.New("no such container")} + svc := &ConsoleService{cli: fake} + + _, err := svc.Attach(context.Background(), "missing") + if err == nil { + t.Fatal("expected error") + } + if errors.Is(err, ErrContainerNotRunning) { + t.Fatalf("unexpected not running error: %v", err) + } +} diff --git a/backend/internal/service/docker_service.go b/backend/internal/service/docker_service.go index 3b16939..5769bb2 100644 --- a/backend/internal/service/docker_service.go +++ b/backend/internal/service/docker_service.go @@ -2,69 +2,539 @@ package service import ( "context" - "errors" "fmt" "io" + "strconv" "strings" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" - "github.com/docker/docker/client" + "github.com/docker/docker/api/types/network" + "github.com/docker/go-connections/nat" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "minedock/backend/internal/model" - "minedock/backend/internal/store" ) -var ErrInstanceRunning = errors.New("instance is running, stop it before delete") - const ( managedLabelKey = "minedock.managed" managedLabelValue = "true" nameLabelKey = "minedock.name" - defaultImage = "alpine:latest" + gameIDLabelKey = "minedock.game_id" ) -// DockerService contains business logic for container management. +// dockerClient 定义 DockerService 依赖的 Docker API。 +type dockerClient interface { + ContainerCreate( + ctx context.Context, + config *container.Config, + hostConfig *container.HostConfig, + networkingConfig *network.NetworkingConfig, + platform *ocispec.Platform, + containerName string, + ) (container.CreateResponse, error) + ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) + ContainerRemove(ctx context.Context, containerID string, options container.RemoveOptions) error + ContainerStart(ctx context.Context, containerID string, options container.StartOptions) error + ContainerStop(ctx context.Context, containerID string, options container.StopOptions) error + ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) + ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error) + ImagePull(ctx context.Context, ref string, options image.PullOptions) (io.ReadCloser, error) +} + +// InstanceStore 定义 DockerService 依赖的持久化操作。 +type InstanceStore interface { + Save(ctx context.Context, inst model.Instance) error + Get(ctx context.Context, containerID string) (model.Instance, bool, error) + Delete(ctx context.Context, containerID string) error +} + +// GameRegistry 定义 DockerService 依赖的游戏与模板查询能力。 +type GameRegistry interface { + GetGame(ctx context.Context, id string) (model.Game, error) + GetTemplate(ctx context.Context, id string) (model.GameTemplate, error) +} + +// InstanceConfig 描述容器当前生效的可编辑配置。 +type InstanceConfig struct { + GameID string `json:"game_id"` + Status string `json:"status"` + Ports []model.PortMapping `json:"ports"` + Params map[string]string `json:"params"` +} + +// DockerService 封装容器管理相关业务逻辑。 type DockerService struct { - cli *client.Client - store *store.SQLiteStore - image string + cli dockerClient + store InstanceStore + registry GameRegistry } -func NewDockerService(cli *client.Client, sqliteStore *store.SQLiteStore, imageName string) *DockerService { - if strings.TrimSpace(imageName) == "" { - imageName = defaultImage - } - return &DockerService{cli: cli, store: sqliteStore, image: imageName} +// NewDockerService 使用依赖项创建 DockerService。 +func NewDockerService(cli dockerClient, s InstanceStore, registry GameRegistry) *DockerService { + return &DockerService{cli: cli, store: s, registry: registry} } -func (s *DockerService) CreateInstance(ctx context.Context, name string) (string, error) { - if err := s.ensureImage(ctx, s.image); err != nil { +// CreateInstance 创建托管容器并持久化实例元数据。 +// TODO: 让 Docker 创建与 SQLite 保存具备原子性。 +func (s *DockerService) CreateInstance( + ctx context.Context, + name, gameID string, + params map[string]string, + ports []model.PortMapping, +) (string, error) { + if s.registry == nil { + return "", fmt.Errorf("game registry is not configured") + } + + game, err := s.registry.GetGame(ctx, gameID) + if err != nil { return "", err } + tpl, err := s.registry.GetTemplate(ctx, game.ID) + if err != nil { + return "", err + } + + env, err := mergeTemplateEnv(tpl, params) + if err != nil { + return "", err + } + + imageRef := tpl.Image.FullImageRef() + if strings.TrimSpace(imageRef) == "" { + return "", model.ErrTemplateInvalid + } + + if err := s.ensureImage(ctx, imageRef); err != nil { + return "", err + } + + resolvedPorts, err := resolveConfigPorts(tpl.Container.Ports, nil, ports) + if err != nil { + return "", err + } + + exposedPorts, portBindings := buildPortBindings(resolvedPorts) + cmd := []string(nil) + if len(tpl.Container.Command) > 0 { + cmd = append(cmd, tpl.Container.Command...) + } + hostConfig := &container.HostConfig{ + PortBindings: portBindings, + Binds: buildVolumeBinds(name, tpl.Container.Volumes), + } + resp, err := s.cli.ContainerCreate(ctx, &container.Config{ - Image: s.image, - Cmd: []string{"sleep", "3600"}, + Image: imageRef, + Cmd: cmd, + Env: mapToDockerEnv(env), + ExposedPorts: exposedPorts, + Tty: true, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + OpenStdin: true, + StdinOnce: false, Labels: map[string]string{ managedLabelKey: managedLabelValue, nameLabelKey: name, + gameIDLabelKey: game.ID, }, - }, nil, nil, nil, "") + }, hostConfig, nil, nil, "") if err != nil { return "", fmt.Errorf("create container: %w", err) } - inst := model.Instance{ContainerID: resp.ID, Name: name, Status: "Stopped"} + inst := model.Instance{ContainerID: resp.ID, Name: name, GameID: game.ID, Status: "Stopped"} if err := s.store.Save(ctx, inst); err != nil { + // 说明:请求上下文取消时,清理逻辑会使用独立上下文做尽力回收。 _ = s.cli.ContainerRemove(context.Background(), resp.ID, container.RemoveOptions{Force: true}) + return "", fmt.Errorf("save instance record: %w", err) + } + + return resp.ID, nil +} + +// GetInstanceConfig 读取容器当前生效的用户可调参数。 +func (s *DockerService) GetInstanceConfig(ctx context.Context, containerID string) (*InstanceConfig, error) { + if s.registry == nil { + return nil, fmt.Errorf("game registry is not configured") + } + + inspect, err := s.cli.ContainerInspect(ctx, containerID) + if err != nil { + return nil, fmt.Errorf("inspect container: %w", err) + } + if inspect.Config == nil { + return nil, fmt.Errorf("inspect container config is empty") + } + + gameID := strings.TrimSpace(inspect.Config.Labels[gameIDLabelKey]) + if gameID == "" { + return nil, fmt.Errorf("container %q is missing %s label", containerID, gameIDLabelKey) + } + + tpl, err := s.registry.GetTemplate(ctx, gameID) + if err != nil { + return nil, err + } + + ports, err := resolveConfigPorts(tpl.Container.Ports, inspect.HostConfig, nil) + if err != nil { + return nil, err + } + + containerEnv := dockerEnvToMap(inspect.Config.Env) + params := make(map[string]string, len(tpl.Params)) + for _, param := range tpl.Params { + envKey := strings.TrimSpace(param.EnvVar) + if envKey == "" { + envKey = param.Key + } + + value, ok := containerEnv[envKey] + if !ok { + if defaultValue, hasDefault := stringifyTemplateDefault(param); hasDefault { + value = defaultValue + } + } + + params[param.Key] = value + } + + return &InstanceConfig{ + GameID: gameID, + Status: instanceStatusFromState(inspect.State), + Ports: ports, + Params: params, + }, nil +} + +// UpdateInstanceConfig 通过重建容器应用新的用户参数。 +func (s *DockerService) UpdateInstanceConfig( + ctx context.Context, + containerID string, + newParams map[string]string, + newPorts []model.PortMapping, +) (string, error) { + if s.registry == nil { + return "", fmt.Errorf("game registry is not configured") + } + + inspect, err := s.cli.ContainerInspect(ctx, containerID) + if err != nil { + return "", fmt.Errorf("inspect container: %w", err) + } + if inspect.Config == nil { + return "", fmt.Errorf("inspect container config is empty") + } + if inspect.State != nil && inspect.State.Running { + return "", model.ErrContainerNotStopped + } + + gameID := strings.TrimSpace(inspect.Config.Labels[gameIDLabelKey]) + if gameID == "" { + return "", fmt.Errorf("container %q is missing %s label", containerID, gameIDLabelKey) + } + + tpl, err := s.registry.GetTemplate(ctx, gameID) + if err != nil { + return "", err + } + + env, err := mergeTemplateEnv(tpl, newParams) + if err != nil { + return "", err + } + + ports, err := resolveConfigPorts(tpl.Container.Ports, inspect.HostConfig, newPorts) + if err != nil { return "", err } + exposedPorts, portBindings := buildPortBindings(ports) + + containerName := strings.TrimPrefix(inspect.Name, "/") + instanceName := strings.TrimSpace(inspect.Config.Labels[nameLabelKey]) + if instanceName == "" { + instanceName = containerName + } + if instanceName == "" { + instanceName = containerID + } + + hostConfig := &container.HostConfig{} + if inspect.HostConfig != nil { + hostConfig.Binds = append([]string(nil), inspect.HostConfig.Binds...) + } + hostConfig.PortBindings = portBindings + + labels := copyStringMap(inspect.Config.Labels) + labels[managedLabelKey] = managedLabelValue + labels[nameLabelKey] = instanceName + labels[gameIDLabelKey] = gameID + + if err := s.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: false}); err != nil { + return "", fmt.Errorf("remove old container: %w", err) + } + + resp, err := s.cli.ContainerCreate(ctx, &container.Config{ + Image: inspect.Config.Image, + Cmd: append([]string(nil), inspect.Config.Cmd...), + Env: mapToDockerEnv(env), + ExposedPorts: exposedPorts, + Tty: inspect.Config.Tty, + AttachStdin: inspect.Config.AttachStdin, + AttachStdout: inspect.Config.AttachStdout, + AttachStderr: inspect.Config.AttachStderr, + OpenStdin: inspect.Config.OpenStdin, + StdinOnce: inspect.Config.StdinOnce, + Labels: labels, + }, hostConfig, nil, nil, containerName) + if err != nil { + return "", fmt.Errorf("create replacement container: %w", err) + } + + newInst := model.Instance{ + ContainerID: resp.ID, + Name: instanceName, + GameID: gameID, + Status: "Stopped", + } + + if err := s.store.Delete(ctx, containerID); err != nil { + return "", fmt.Errorf("delete old instance record: %w", err) + } + if err := s.store.Save(ctx, newInst); err != nil { + return "", fmt.Errorf("save new instance record: %w", err) + } + return resp.ID, nil } +func mergeTemplateEnv(tpl model.GameTemplate, params map[string]string) (map[string]string, error) { + merged := make(map[string]string, len(tpl.Container.Env)+len(tpl.Params)) + for key, value := range tpl.Container.Env { + merged[key] = value + } + + paramDefs := make(map[string]model.TemplateParam, len(tpl.Params)) + for _, param := range tpl.Params { + paramDefs[param.Key] = param + + defaultValue, ok := stringifyTemplateDefault(param) + if !ok { + continue + } + envKey := strings.TrimSpace(param.EnvVar) + if envKey == "" { + envKey = param.Key + } + merged[envKey] = defaultValue + } + + for key, rawValue := range params { + paramKey := strings.TrimSpace(key) + paramDef, exists := paramDefs[paramKey] + if !exists { + return nil, fmt.Errorf("unknown param key %q: %w", paramKey, model.ErrInvalidParams) + } + + normalized, err := normalizeParamValue(paramDef, rawValue) + if err != nil { + return nil, err + } + + envKey := strings.TrimSpace(paramDef.EnvVar) + if envKey == "" { + envKey = paramDef.Key + } + merged[envKey] = normalized + } + + return merged, nil +} + +func normalizeParamValue(param model.TemplateParam, raw string) (string, error) { + switch param.Type { + case "string": + return raw, nil + case "number": + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", fmt.Errorf("param %q requires number value: %w", param.Key, model.ErrInvalidParams) + } + if _, err := strconv.ParseFloat(trimmed, 64); err != nil { + return "", fmt.Errorf("param %q has invalid number %q: %w", param.Key, raw, model.ErrInvalidParams) + } + return trimmed, nil + case "boolean": + trimmed := strings.TrimSpace(strings.ToLower(raw)) + if trimmed == "" { + return "", fmt.Errorf("param %q requires boolean value: %w", param.Key, model.ErrInvalidParams) + } + v, err := strconv.ParseBool(trimmed) + if err != nil { + return "", fmt.Errorf("param %q has invalid boolean %q: %w", param.Key, raw, model.ErrInvalidParams) + } + if v { + return "true", nil + } + return "false", nil + case "select": + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", fmt.Errorf("param %q requires selected value: %w", param.Key, model.ErrInvalidParams) + } + for _, option := range param.Options { + if option.Value == trimmed { + return trimmed, nil + } + } + return "", fmt.Errorf("param %q has unsupported value %q: %w", param.Key, raw, model.ErrInvalidParams) + default: + return "", fmt.Errorf("param %q has unsupported type %q: %w", param.Key, param.Type, model.ErrTemplateInvalid) + } +} + +func stringifyTemplateDefault(param model.TemplateParam) (string, bool) { + if param.Default == nil { + return "", false + } + + switch param.Type { + case "string", "select", "number": + value := strings.TrimSpace(fmt.Sprint(param.Default)) + if value == "" { + return "", false + } + return value, true + case "boolean": + v, err := strconv.ParseBool(strings.TrimSpace(strings.ToLower(fmt.Sprint(param.Default)))) + if err != nil { + return "", false + } + if v { + return "true", true + } + return "false", true + default: + return "", false + } +} + +func mapToDockerEnv(m map[string]string) []string { + if len(m) == 0 { + return nil + } + env := make([]string, 0, len(m)) + for key, value := range m { + env = append(env, fmt.Sprintf("%s=%s", key, value)) + } + return env +} + +func buildPortBindings(ports []model.PortMapping) (nat.PortSet, nat.PortMap) { + if len(ports) == 0 { + return nil, nil + } + + exposedPorts := make(nat.PortSet, len(ports)) + portBindings := make(nat.PortMap, len(ports)) + + for _, p := range ports { + protocol := strings.ToLower(strings.TrimSpace(p.Protocol)) + if protocol == "" { + protocol = "tcp" + } + + containerPort, err := nat.NewPort(protocol, strconv.Itoa(p.Container)) + if err != nil { + continue + } + + exposedPorts[containerPort] = struct{}{} + portBindings[containerPort] = append(portBindings[containerPort], nat.PortBinding{ + HostPort: strconv.Itoa(p.Host), + }) + } + + if len(exposedPorts) == 0 { + return nil, nil + } + + return exposedPorts, portBindings +} + +func buildVolumeBinds(instanceName string, volumes []model.VolumeMount) []string { + if len(volumes) == 0 { + return nil + } + + instanceToken := sanitizeVolumeNameToken(instanceName) + binds := make([]string, 0, len(volumes)) + for _, v := range volumes { + volumeToken := sanitizeVolumeNameToken(v.Name) + dockerVolName := fmt.Sprintf("minedock-%s-%s", instanceToken, volumeToken) + bind := fmt.Sprintf("%s:%s", dockerVolName, strings.TrimSpace(v.ContainerPath)) + if v.ReadOnly { + bind += ":ro" + } + binds = append(binds, bind) + } + + return binds +} + +func sanitizeVolumeNameToken(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + if s == "" { + return "default" + } + + var b strings.Builder + b.Grow(len(s)) + lastSeparator := false + + for _, r := range s { + isAlnum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if isAlnum { + b.WriteRune(r) + lastSeparator = false + continue + } + + if r == '-' || r == '_' || r == '.' { + if !lastSeparator { + b.WriteRune(r) + lastSeparator = true + } + continue + } + + if !lastSeparator { + b.WriteByte('-') + lastSeparator = true + } + } + + token := strings.Trim(b.String(), "-_.") + if token == "" { + return "default" + } + + first := token[0] + if !((first >= 'a' && first <= 'z') || (first >= '0' && first <= '9')) { + token = "v-" + token + } + + return token +} + +// StartInstance 启动托管容器并更新持久化状态。 func (s *DockerService) StartInstance(ctx context.Context, containerID string) error { if err := s.cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { return fmt.Errorf("start container: %w", err) @@ -75,12 +545,13 @@ func (s *DockerService) StartInstance(ctx context.Context, containerID string) e return err } if err := s.store.Save(ctx, inst); err != nil { - return err + return fmt.Errorf("save instance state: %w", err) } return nil } +// StopInstance 停止托管容器并更新持久化状态。 func (s *DockerService) StopInstance(ctx context.Context, containerID string) error { timeout := 10 if err := s.cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}); err != nil { @@ -92,12 +563,15 @@ func (s *DockerService) StopInstance(ctx context.Context, containerID string) er return err } if err := s.store.Save(ctx, inst); err != nil { - return err + return fmt.Errorf("save instance state: %w", err) } return nil } +// ListInstances 列出托管容器并将快照状态同步到存储层。 +// TODO: 将逐条 Save 改为批量或事务化同步路径。 +// TODO: 增加并发写保护,避免最后写入覆盖前写入。 func (s *DockerService) ListInstances(ctx context.Context) ([]model.Instance, error) { args := filters.NewArgs() args.Add("label", managedLabelKey+"="+managedLabelValue) @@ -109,10 +583,12 @@ func (s *DockerService) ListInstances(ctx context.Context) ([]model.Instance, er instances := make([]model.Instance, 0, len(containers)) for _, c := range containers { + // 说明:名称回退顺序是先 label,再 Docker 容器名。 name := c.Labels[nameLabelKey] if strings.TrimSpace(name) == "" && len(c.Names) > 0 { name = strings.TrimPrefix(c.Names[0], "/") } + gameID := strings.TrimSpace(c.Labels[gameIDLabelKey]) status := "Stopped" if strings.EqualFold(c.State, "running") { status = "Running" @@ -120,8 +596,11 @@ func (s *DockerService) ListInstances(ctx context.Context) ([]model.Instance, er inst := model.Instance{ ContainerID: c.ID, Name: name, + GameID: gameID, Status: status, } + // 说明:当前 Save 为尽力而为,避免影响列表返回。 + // TODO: 在不影响列表返回的前提下上报同步失败。 _ = s.store.Save(ctx, inst) instances = append(instances, inst) } @@ -129,6 +608,7 @@ func (s *DockerService) ListInstances(ctx context.Context) ([]model.Instance, er return instances, nil } +// DeleteInstance 删除已停止的托管容器及其持久化记录。 func (s *DockerService) DeleteInstance(ctx context.Context, containerID string) error { inspect, err := s.cli.ContainerInspect(ctx, containerID) if err != nil { @@ -136,24 +616,27 @@ func (s *DockerService) DeleteInstance(ctx context.Context, containerID string) } if inspect.State != nil && inspect.State.Running { - return ErrInstanceRunning + return model.ErrInstanceRunning } if err := s.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: false}); err != nil { return fmt.Errorf("remove container: %w", err) } if err := s.store.Delete(ctx, containerID); err != nil { - return err + return fmt.Errorf("delete instance record: %w", err) } return nil } +// readInstance 从存储层读取实例信息并与运行态进行对账。 +// TODO: 将该函数拆分为存储读取与 Docker 对账两个辅助函数。 func (s *DockerService) readInstance(ctx context.Context, containerID string, fallbackStatus string) (model.Instance, error) { inst, ok, err := s.store.Get(ctx, containerID) if err != nil { - return model.Instance{}, err + return model.Instance{}, fmt.Errorf("read instance: %w", err) } - if ok { + if ok && strings.TrimSpace(inst.GameID) != "" { + // 说明:命中存储后仍会应用调用方传入的兜底状态。 inst.Status = fallbackStatus return inst, nil } @@ -164,15 +647,21 @@ func (s *DockerService) readInstance(ctx context.Context, containerID string, fa } name := "" + gameID := "" if inspect.Config != nil && inspect.Config.Labels != nil { name = strings.TrimSpace(inspect.Config.Labels[nameLabelKey]) + gameID = strings.TrimSpace(inspect.Config.Labels[gameIDLabelKey]) } if name == "" { name = strings.TrimPrefix(inspect.Name, "/") } + if gameID == "" && ok { + gameID = strings.TrimSpace(inst.GameID) + } status := fallbackStatus if inspect.State != nil { + // 说明:当 inspect 有运行态数据时,以 Docker 真实状态覆盖兜底状态。 if inspect.State.Running { status = "Running" } else { @@ -180,9 +669,10 @@ func (s *DockerService) readInstance(ctx context.Context, containerID string, fa } } - return model.Instance{ContainerID: containerID, Name: name, Status: status}, nil + return model.Instance{ContainerID: containerID, Name: name, GameID: gameID, Status: status}, nil } +// ensureImage 确保本地存在目标镜像,缺失时自动拉取。 func (s *DockerService) ensureImage(ctx context.Context, imageName string) error { list, err := s.cli.ImageList(ctx, image.ListOptions{}) if err != nil { @@ -205,3 +695,128 @@ func (s *DockerService) ensureImage(ctx context.Context, imageName string) error _, _ = io.Copy(io.Discard, rc) return nil } + +func dockerEnvToMap(env []string) map[string]string { + out := make(map[string]string, len(env)) + for _, item := range env { + key, value, ok := strings.Cut(item, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + if key == "" { + continue + } + out[key] = value + } + return out +} + +func instanceStatusFromState(state *container.State) string { + if state != nil && state.Running { + return "Running" + } + return "Stopped" +} + +func copyStringMap(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func resolveConfigPorts( + templatePorts []model.PortMapping, + hostConfig *container.HostConfig, + requestedPorts []model.PortMapping, +) ([]model.PortMapping, error) { + if len(templatePorts) == 0 { + return []model.PortMapping{}, nil + } + + currentHosts := mapCurrentHostPorts(nil) + if hostConfig != nil { + currentHosts = mapCurrentHostPorts(hostConfig.PortBindings) + } + + requestedHosts := make(map[string]int, len(requestedPorts)) + for _, p := range requestedPorts { + if p.Container <= 0 || p.Host <= 0 { + return nil, fmt.Errorf("invalid port mapping: %w", model.ErrInvalidParams) + } + protocol := normalizePortProtocol(p.Protocol) + key := portConfigKey(p.Container, protocol) + if _, exists := requestedHosts[key]; exists { + return nil, fmt.Errorf("duplicate port mapping %q: %w", key, model.ErrInvalidParams) + } + requestedHosts[key] = p.Host + } + + resolved := make([]model.PortMapping, 0, len(templatePorts)) + for _, p := range templatePorts { + if p.Container <= 0 || p.Host <= 0 { + return nil, fmt.Errorf("template port is invalid: %w", model.ErrTemplateInvalid) + } + + protocol := normalizePortProtocol(p.Protocol) + key := portConfigKey(p.Container, protocol) + + host := p.Host + if currentHost, ok := currentHosts[key]; ok { + host = currentHost + } + if requestedHost, ok := requestedHosts[key]; ok { + host = requestedHost + delete(requestedHosts, key) + } + + resolved = append(resolved, model.PortMapping{ + Host: host, + Container: p.Container, + Protocol: protocol, + }) + } + + if len(requestedHosts) > 0 { + return nil, fmt.Errorf("unknown port mapping: %w", model.ErrInvalidParams) + } + + return resolved, nil +} + +func mapCurrentHostPorts(portBindings nat.PortMap) map[string]int { + out := make(map[string]int, len(portBindings)) + for containerPort, bindings := range portBindings { + if len(bindings) == 0 { + continue + } + + hostPort, err := strconv.Atoi(strings.TrimSpace(bindings[0].HostPort)) + if err != nil || hostPort <= 0 { + continue + } + + containerValue, err := strconv.Atoi(containerPort.Port()) + if err != nil || containerValue <= 0 { + continue + } + + key := portConfigKey(containerValue, normalizePortProtocol(containerPort.Proto())) + out[key] = hostPort + } + return out +} + +func normalizePortProtocol(protocol string) string { + normalized := strings.ToLower(strings.TrimSpace(protocol)) + if normalized == "" { + return "tcp" + } + return normalized +} + +func portConfigKey(containerPort int, protocol string) string { + return fmt.Sprintf("%d/%s", containerPort, normalizePortProtocol(protocol)) +} diff --git a/backend/internal/service/docker_service_test.go b/backend/internal/service/docker_service_test.go new file mode 100644 index 0000000..3817896 --- /dev/null +++ b/backend/internal/service/docker_service_test.go @@ -0,0 +1,561 @@ +package service + +import ( + "context" + "errors" + "io" + "slices" + "strings" + "testing" + + "github.com/docker/go-connections/nat" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + + "minedock/backend/internal/model" +) + +func TestBuildPortBindings(t *testing.T) { + t.Run("empty ports", func(t *testing.T) { + exposed, bindings := buildPortBindings(nil) + if len(exposed) != 0 { + t.Fatalf("expected empty exposed ports, got %d", len(exposed)) + } + if len(bindings) != 0 { + t.Fatalf("expected empty port bindings, got %d", len(bindings)) + } + }) + + t.Run("tcp mapping", func(t *testing.T) { + exposed, bindings := buildPortBindings([]model.PortMapping{{ + Host: 25565, + Container: 25565, + Protocol: "tcp", + }}) + + port, err := nat.NewPort("tcp", "25565") + if err != nil { + t.Fatalf("new port: %v", err) + } + + if _, ok := exposed[port]; !ok { + t.Fatalf("expected exposed port %q", port) + } + bound, ok := bindings[port] + if !ok { + t.Fatalf("expected binding for port %q", port) + } + if len(bound) != 1 { + t.Fatalf("expected 1 binding, got %d", len(bound)) + } + if bound[0].HostPort != "25565" { + t.Fatalf("expected host port 25565, got %s", bound[0].HostPort) + } + }) + + t.Run("udp mapping", func(t *testing.T) { + exposed, bindings := buildPortBindings([]model.PortMapping{{ + Host: 19132, + Container: 19132, + Protocol: "udp", + }}) + + port, err := nat.NewPort("udp", "19132") + if err != nil { + t.Fatalf("new port: %v", err) + } + + if _, ok := exposed[port]; !ok { + t.Fatalf("expected exposed port %q", port) + } + bound, ok := bindings[port] + if !ok { + t.Fatalf("expected binding for port %q", port) + } + if len(bound) != 1 { + t.Fatalf("expected 1 binding, got %d", len(bound)) + } + if bound[0].HostPort != "19132" { + t.Fatalf("expected host port 19132, got %s", bound[0].HostPort) + } + }) + + t.Run("default protocol is tcp", func(t *testing.T) { + exposed, bindings := buildPortBindings([]model.PortMapping{{ + Host: 8080, + Container: 8080, + Protocol: " ", + }}) + + port, err := nat.NewPort("tcp", "8080") + if err != nil { + t.Fatalf("new port: %v", err) + } + + if _, ok := exposed[port]; !ok { + t.Fatalf("expected exposed port %q", port) + } + if _, ok := bindings[port]; !ok { + t.Fatalf("expected binding for port %q", port) + } + }) +} + +func TestBuildVolumeBinds(t *testing.T) { + t.Run("empty volumes", func(t *testing.T) { + binds := buildVolumeBinds("my-server", nil) + if binds != nil { + t.Fatalf("expected nil binds, got %v", binds) + } + }) + + t.Run("normal volume", func(t *testing.T) { + binds := buildVolumeBinds("my-server", []model.VolumeMount{{ + Name: "server-data", + ContainerPath: "/data", + }}) + + if len(binds) != 1 { + t.Fatalf("expected 1 bind, got %d", len(binds)) + } + if binds[0] != "minedock-my-server-server-data:/data" { + t.Fatalf("unexpected bind: %s", binds[0]) + } + }) + + t.Run("readonly volume", func(t *testing.T) { + binds := buildVolumeBinds("my-server", []model.VolumeMount{{ + Name: "server-data", + ContainerPath: "/data", + ReadOnly: true, + }}) + + if len(binds) != 1 { + t.Fatalf("expected 1 bind, got %d", len(binds)) + } + if binds[0] != "minedock-my-server-server-data:/data:ro" { + t.Fatalf("unexpected bind: %s", binds[0]) + } + }) + + t.Run("instance name with special chars", func(t *testing.T) { + binds := buildVolumeBinds("My Server (US)#1", []model.VolumeMount{{ + Name: "World Data@Prod", + ContainerPath: "/data", + }}) + + if len(binds) != 1 { + t.Fatalf("expected 1 bind, got %d", len(binds)) + } + if binds[0] != "minedock-my-server-us-1-world-data-prod:/data" { + t.Fatalf("unexpected bind: %s", binds[0]) + } + }) +} + +type fakeDockerClient struct { + inspectResp container.InspectResponse + inspectErr error + + createResp container.CreateResponse + createErr error + createCfg *container.Config + createHost *container.HostConfig + createName string + removeErr error + removedIDs []string + listResp []container.Summary + listErr error + startErr error + stopErr error + imageList []image.Summary + imageListErr error + imagePullErr error +} + +func (f *fakeDockerClient) ContainerCreate( + _ context.Context, + config *container.Config, + hostConfig *container.HostConfig, + _ *network.NetworkingConfig, + _ *ocispec.Platform, + containerName string, +) (container.CreateResponse, error) { + f.createCfg = config + f.createHost = hostConfig + f.createName = containerName + if f.createErr != nil { + return container.CreateResponse{}, f.createErr + } + if f.createResp.ID == "" { + f.createResp.ID = "new-container-id" + } + return f.createResp, nil +} + +func (f *fakeDockerClient) ContainerInspect(_ context.Context, _ string) (container.InspectResponse, error) { + if f.inspectErr != nil { + return container.InspectResponse{}, f.inspectErr + } + return f.inspectResp, nil +} + +func (f *fakeDockerClient) ContainerRemove(_ context.Context, containerID string, _ container.RemoveOptions) error { + f.removedIDs = append(f.removedIDs, containerID) + if f.removeErr != nil { + return f.removeErr + } + return nil +} + +func (f *fakeDockerClient) ContainerStart(_ context.Context, _ string, _ container.StartOptions) error { + return f.startErr +} + +func (f *fakeDockerClient) ContainerStop(_ context.Context, _ string, _ container.StopOptions) error { + return f.stopErr +} + +func (f *fakeDockerClient) ContainerList(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + if f.listErr != nil { + return nil, f.listErr + } + return f.listResp, nil +} + +func (f *fakeDockerClient) ImageList(_ context.Context, _ image.ListOptions) ([]image.Summary, error) { + if f.imageListErr != nil { + return nil, f.imageListErr + } + return f.imageList, nil +} + +func (f *fakeDockerClient) ImagePull(_ context.Context, _ string, _ image.PullOptions) (io.ReadCloser, error) { + if f.imagePullErr != nil { + return nil, f.imagePullErr + } + return io.NopCloser(strings.NewReader("")), nil +} + +type fakeInstanceStore struct { + instances map[string]model.Instance + + saveErr error + getErr error + deleteErr error +} + +func newFakeInstanceStore(initial ...model.Instance) *fakeInstanceStore { + instances := make(map[string]model.Instance, len(initial)) + for _, inst := range initial { + instances[inst.ContainerID] = inst + } + return &fakeInstanceStore{instances: instances} +} + +func (f *fakeInstanceStore) Save(_ context.Context, inst model.Instance) error { + if f.saveErr != nil { + return f.saveErr + } + if f.instances == nil { + f.instances = map[string]model.Instance{} + } + f.instances[inst.ContainerID] = inst + return nil +} + +func (f *fakeInstanceStore) Get(_ context.Context, containerID string) (model.Instance, bool, error) { + if f.getErr != nil { + return model.Instance{}, false, f.getErr + } + inst, ok := f.instances[containerID] + return inst, ok, nil +} + +func (f *fakeInstanceStore) Delete(_ context.Context, containerID string) error { + if f.deleteErr != nil { + return f.deleteErr + } + delete(f.instances, containerID) + return nil +} + +type fakeRegistry struct { + game model.Game + gameErr error + template model.GameTemplate + tplErr error +} + +func (f *fakeRegistry) GetGame(_ context.Context, _ string) (model.Game, error) { + if f.gameErr != nil { + return model.Game{}, f.gameErr + } + return f.game, nil +} + +func (f *fakeRegistry) GetTemplate(_ context.Context, _ string) (model.GameTemplate, error) { + if f.tplErr != nil { + return model.GameTemplate{}, f.tplErr + } + return f.template, nil +} + +func TestGetInstanceConfig_Success(t *testing.T) { + port, err := nat.NewPort("tcp", "25565") + if err != nil { + t.Fatalf("new port: %v", err) + } + + cli := &fakeDockerClient{ + inspectResp: container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + State: &container.State{Running: true}, + HostConfig: &container.HostConfig{PortBindings: nat.PortMap{port: []nat.PortBinding{{HostPort: "25575"}}}}, + }, + Config: &container.Config{ + Labels: map[string]string{ + gameIDLabelKey: "minecraft-java", + }, + Env: []string{ + "EULA=TRUE", + "TYPE=FABRIC", + "MAX_PLAYERS=50", + }, + }, + }, + } + registry := &fakeRegistry{ + template: model.GameTemplate{ + Container: model.ContainerConfig{ + Ports: []model.PortMapping{{Host: 25565, Container: 25565, Protocol: "tcp"}}, + Env: map[string]string{"EULA": "TRUE"}, + }, + Params: []model.TemplateParam{ + {Key: "SERVER_TYPE", Type: "select", Default: "PAPER", EnvVar: "TYPE", Options: []model.ParamOption{{Value: "PAPER", Label: "Paper"}, {Value: "FABRIC", Label: "Fabric"}}}, + {Key: "MAX_PLAYERS", Type: "number", Default: 20}, + }, + }, + } + + svc := NewDockerService(cli, newFakeInstanceStore(), registry) + + cfg, err := svc.GetInstanceConfig(context.Background(), "c1") + if err != nil { + t.Fatalf("GetInstanceConfig: %v", err) + } + if cfg.GameID != "minecraft-java" { + t.Fatalf("unexpected game id: %s", cfg.GameID) + } + if cfg.Status != "Running" { + t.Fatalf("unexpected status: %s", cfg.Status) + } + if cfg.Params["SERVER_TYPE"] != "FABRIC" { + t.Fatalf("unexpected SERVER_TYPE: %s", cfg.Params["SERVER_TYPE"]) + } + if cfg.Params["MAX_PLAYERS"] != "50" { + t.Fatalf("unexpected MAX_PLAYERS: %s", cfg.Params["MAX_PLAYERS"]) + } + if len(cfg.Ports) != 1 { + t.Fatalf("expected 1 port mapping, got %+v", cfg.Ports) + } + if cfg.Ports[0].Host != 25575 || cfg.Ports[0].Container != 25565 || cfg.Ports[0].Protocol != "tcp" { + t.Fatalf("unexpected config ports: %+v", cfg.Ports) + } +} + +func TestUpdateInstanceConfig_RejectRunning(t *testing.T) { + cli := &fakeDockerClient{ + inspectResp: container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{State: &container.State{Running: true}}, + Config: &container.Config{Labels: map[string]string{ + gameIDLabelKey: "minecraft-java", + }}, + }, + } + + svc := NewDockerService(cli, newFakeInstanceStore(), &fakeRegistry{}) + + _, err := svc.UpdateInstanceConfig(context.Background(), "c1", map[string]string{"MAX_PLAYERS": "50"}, nil) + if !errors.Is(err, model.ErrContainerNotStopped) { + t.Fatalf("expected ErrContainerNotStopped, got %v", err) + } +} + +func TestUpdateInstanceConfig_Success(t *testing.T) { + port, err := nat.NewPort("tcp", "25565") + if err != nil { + t.Fatalf("new port: %v", err) + } + + cli := &fakeDockerClient{ + inspectResp: container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + Name: "/docker-name", + HostConfig: &container.HostConfig{Binds: []string{"minedock-server-data:/data"}, PortBindings: nat.PortMap{port: []nat.PortBinding{{HostPort: "25565"}}}}, + State: &container.State{Running: false}, + }, + Config: &container.Config{ + Image: "itzg/minecraft-server:latest", + Cmd: []string{"/start"}, + Env: []string{"EULA=TRUE", "TYPE=PAPER", "MAX_PLAYERS=20"}, + ExposedPorts: nat.PortSet{port: struct{}{}}, + Labels: map[string]string{ + managedLabelKey: "true", + nameLabelKey: "server-1", + gameIDLabelKey: "minecraft-java", + }, + }, + }, + createResp: container.CreateResponse{ID: "new-id"}, + } + registry := &fakeRegistry{ + template: model.GameTemplate{ + Container: model.ContainerConfig{ + Ports: []model.PortMapping{{Host: 25565, Container: 25565, Protocol: "tcp"}}, + Env: map[string]string{"EULA": "TRUE"}, + }, + Params: []model.TemplateParam{ + {Key: "SERVER_TYPE", Type: "select", Default: "PAPER", EnvVar: "TYPE", Options: []model.ParamOption{{Value: "PAPER", Label: "Paper"}, {Value: "FABRIC", Label: "Fabric"}}}, + {Key: "MAX_PLAYERS", Type: "number", Default: 20}, + }, + }, + } + store := newFakeInstanceStore(model.Instance{ContainerID: "old-id", Name: "server-1", GameID: "minecraft-java", Status: "Stopped"}) + + svc := NewDockerService(cli, store, registry) + + newID, err := svc.UpdateInstanceConfig(context.Background(), "old-id", map[string]string{ + "SERVER_TYPE": "FABRIC", + "MAX_PLAYERS": "50", + }, []model.PortMapping{{ + Host: 25575, + Container: 25565, + Protocol: "tcp", + }}) + if err != nil { + t.Fatalf("UpdateInstanceConfig: %v", err) + } + if newID != "new-id" { + t.Fatalf("unexpected new container id: %s", newID) + } + if !slices.Contains(cli.removedIDs, "old-id") { + t.Fatalf("expected old container removed, got %+v", cli.removedIDs) + } + if cli.createName != "docker-name" { + t.Fatalf("unexpected recreated docker name: %s", cli.createName) + } + + env := dockerEnvToMap(cli.createCfg.Env) + if env["TYPE"] != "FABRIC" { + t.Fatalf("unexpected TYPE env: %s", env["TYPE"]) + } + if env["MAX_PLAYERS"] != "50" { + t.Fatalf("unexpected MAX_PLAYERS env: %s", env["MAX_PLAYERS"]) + } + if env["EULA"] != "TRUE" { + t.Fatalf("unexpected EULA env: %s", env["EULA"]) + } + + bindings := cli.createHost.PortBindings[port] + if len(bindings) != 1 || bindings[0].HostPort != "25575" { + t.Fatalf("unexpected recreated host bindings: %+v", cli.createHost.PortBindings) + } + if _, ok := cli.createCfg.ExposedPorts[port]; !ok { + t.Fatalf("expected exposed port %q, got %+v", port, cli.createCfg.ExposedPorts) + } + + if _, ok := store.instances["old-id"]; ok { + t.Fatal("expected old instance to be deleted from store") + } + newInst, ok := store.instances["new-id"] + if !ok { + t.Fatal("expected new instance in store") + } + if newInst.GameID != "minecraft-java" || newInst.Name != "server-1" { + t.Fatalf("unexpected stored instance: %+v", newInst) + } +} + +func TestUpdateInstanceConfig_InvalidParams(t *testing.T) { + cli := &fakeDockerClient{ + inspectResp: container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{State: &container.State{Running: false}}, + Config: &container.Config{ + Labels: map[string]string{gameIDLabelKey: "minecraft-java", nameLabelKey: "server-1"}, + }, + }, + } + registry := &fakeRegistry{ + template: model.GameTemplate{ + Params: []model.TemplateParam{{Key: "MAX_PLAYERS", Type: "number", Default: 20}}, + }, + } + + svc := NewDockerService(cli, newFakeInstanceStore(), registry) + + _, err := svc.UpdateInstanceConfig(context.Background(), "c1", map[string]string{"MAX_PLAYERS": "NaN??"}, nil) + if !errors.Is(err, model.ErrInvalidParams) { + t.Fatalf("expected ErrInvalidParams, got %v", err) + } +} + +func TestUpdateInstanceConfig_InvalidPorts(t *testing.T) { + cli := &fakeDockerClient{ + inspectResp: container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{State: &container.State{Running: false}}, + Config: &container.Config{ + Labels: map[string]string{gameIDLabelKey: "minecraft-java", nameLabelKey: "server-1"}, + }, + }, + } + registry := &fakeRegistry{ + template: model.GameTemplate{ + Container: model.ContainerConfig{Ports: []model.PortMapping{{Host: 25565, Container: 25565, Protocol: "tcp"}}, Env: map[string]string{}}, + }, + } + + svc := NewDockerService(cli, newFakeInstanceStore(), registry) + + _, err := svc.UpdateInstanceConfig(context.Background(), "c1", map[string]string{}, []model.PortMapping{{ + Host: 19132, + Container: 19132, + Protocol: "udp", + }}) + if !errors.Is(err, model.ErrInvalidParams) { + t.Fatalf("expected ErrInvalidParams for unknown ports, got %v", err) + } +} + +func TestListInstances_IncludesGameID(t *testing.T) { + cli := &fakeDockerClient{ + listResp: []container.Summary{{ + ID: "c1", + State: "running", + Names: []string{"/server-1"}, + Labels: map[string]string{ + nameLabelKey: "server-1", + gameIDLabelKey: "minecraft-java", + }, + }}, + } + store := newFakeInstanceStore() + svc := NewDockerService(cli, store, &fakeRegistry{}) + + instances, err := svc.ListInstances(context.Background()) + if err != nil { + t.Fatalf("ListInstances: %v", err) + } + if len(instances) != 1 { + t.Fatalf("expected 1 instance, got %d", len(instances)) + } + if instances[0].GameID != "minecraft-java" { + t.Fatalf("expected game id minecraft-java, got %s", instances[0].GameID) + } + if saved := store.instances["c1"]; saved.GameID != "minecraft-java" { + t.Fatalf("expected saved game id minecraft-java, got %s", saved.GameID) + } +} diff --git a/backend/internal/service/event_hub.go b/backend/internal/service/event_hub.go new file mode 100644 index 0000000..24dc292 --- /dev/null +++ b/backend/internal/service/event_hub.go @@ -0,0 +1,317 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "sort" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/docker/docker/api/types/events" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/client" + + "minedock/backend/internal/model" +) + +const ( + eventHubDebounceWindow = 150 * time.Millisecond + eventHubWriteTimeout = 3 * time.Second + eventHubMaxBackoff = 30 * time.Second +) + +// dockerEventClient 定义 EventHub 依赖的 Docker Events 能力。 +type dockerEventClient interface { + Events(ctx context.Context, options events.ListOptions) (<-chan events.Message, <-chan error) +} + +type instancesUpdatedMessage struct { + Type string `json:"type"` + Data []model.Instance `json:"data"` +} + +// EventHub 管理 WebSocket 连接并将 Docker 事件广播给所有客户端。 +type EventHub struct { + cli dockerEventClient + listFn func(ctx context.Context) ([]model.Instance, error) + + mu sync.RWMutex + clients map[*websocket.Conn]struct{} + lastSnapshot []byte + lastPayload []byte + + debounceWindow time.Duration + writeTimeout time.Duration +} + +// NewEventHub 创建 EventHub。 +// listFn 是获取完整实例列表的回调(注入 DockerService.ListInstances)。 +func NewEventHub(cli *client.Client, listFn func(ctx context.Context) ([]model.Instance, error)) *EventHub { + return newEventHub(cli, listFn) +} + +func newEventHub(cli dockerEventClient, listFn func(ctx context.Context) ([]model.Instance, error)) *EventHub { + return &EventHub{ + cli: cli, + listFn: listFn, + clients: make(map[*websocket.Conn]struct{}), + debounceWindow: eventHubDebounceWindow, + writeTimeout: eventHubWriteTimeout, + } +} + +// Run 启动 Docker Events 监听循环,ctx 取消时退出。 +func (h *EventHub) Run(ctx context.Context) { + if h == nil { + return + } + defer h.closeAllClients(websocket.StatusGoingAway, "server shutdown") + + backoff := time.Second + for { + healthy, err := h.runOnce(ctx) + if ctx.Err() != nil { + return + } + if healthy { + backoff = time.Second + } + if err != nil { + log.Printf("event hub run once: %v", err) + } + + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + + if backoff < eventHubMaxBackoff { + backoff *= 2 + if backoff > eventHubMaxBackoff { + backoff = eventHubMaxBackoff + } + } + } +} + +func (h *EventHub) runOnce(ctx context.Context) (bool, error) { + if h.cli == nil { + return false, fmt.Errorf("docker client is nil") + } + if h.listFn == nil { + return false, fmt.Errorf("list function is nil") + } + + healthy := false + + args := filters.NewArgs() + args.Add("type", "container") + args.Add("label", managedLabelKey+"="+managedLabelValue) + for _, action := range []string{"start", "stop", "die", "destroy", "kill"} { + args.Add("event", action) + } + + msgCh, errCh := h.cli.Events(ctx, events.ListOptions{Filters: args}) + + // 每次连接(含重连)成功后立即推送一次全量快照。 + if err := h.refreshAndBroadcast(ctx); err != nil && ctx.Err() == nil { + log.Printf("event hub initial snapshot: %v", err) + } else if err == nil { + healthy = true + } + + timer := time.NewTimer(time.Hour) + if !timer.Stop() { + <-timer.C + } + timerCh := (<-chan time.Time)(nil) + pending := false + + for { + select { + case <-ctx.Done(): + if timerCh != nil { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + } + return healthy, nil + case _, ok := <-msgCh: + if !ok { + return healthy, fmt.Errorf("docker events channel closed") + } + pending = true + healthy = true + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(h.debounceWindow) + timerCh = timer.C + case err, ok := <-errCh: + if !ok { + return healthy, fmt.Errorf("docker events error channel closed") + } + if err != nil { + return healthy, fmt.Errorf("docker events stream error: %w", err) + } + case <-timerCh: + timerCh = nil + if !pending { + continue + } + pending = false + if err := h.refreshAndBroadcast(ctx); err != nil && ctx.Err() == nil { + log.Printf("event hub broadcast snapshot: %v", err) + } else if err == nil { + healthy = true + } + } + } +} + +func (h *EventHub) refreshAndBroadcast(ctx context.Context) error { + instances, err := h.listFn(ctx) + if err != nil { + return fmt.Errorf("list instances: %w", err) + } + + snapshot, payload, err := buildEventPayload(instances) + if err != nil { + return fmt.Errorf("marshal snapshot: %w", err) + } + + if h.shouldSkipSnapshot(snapshot) { + return nil + } + + h.updateSnapshot(snapshot, payload) + h.broadcastPayload(payload) + return nil +} + +func buildEventPayload(instances []model.Instance) ([]byte, []byte, error) { + ordered := append([]model.Instance(nil), instances...) + sort.Slice(ordered, func(i, j int) bool { + if ordered[i].ContainerID != ordered[j].ContainerID { + return ordered[i].ContainerID < ordered[j].ContainerID + } + return ordered[i].Name < ordered[j].Name + }) + + snapshot, err := json.Marshal(ordered) + if err != nil { + return nil, nil, err + } + + payload, err := json.Marshal(instancesUpdatedMessage{Type: "instances_updated", Data: ordered}) + if err != nil { + return nil, nil, err + } + + return snapshot, payload, nil +} + +func (h *EventHub) shouldSkipSnapshot(snapshot []byte) bool { + h.mu.RLock() + defer h.mu.RUnlock() + return bytes.Equal(h.lastSnapshot, snapshot) +} + +func (h *EventHub) updateSnapshot(snapshot []byte, payload []byte) { + h.mu.Lock() + h.lastSnapshot = append([]byte(nil), snapshot...) + h.lastPayload = append([]byte(nil), payload...) + h.mu.Unlock() +} + +// AddClient 注册一个 WebSocket 客户端连接。 +func (h *EventHub) AddClient(conn *websocket.Conn) { + if h == nil || conn == nil { + return + } + + h.mu.Lock() + h.clients[conn] = struct{}{} + payload := append([]byte(nil), h.lastPayload...) + h.mu.Unlock() + + if len(payload) == 0 { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), h.writeTimeout) + defer cancel() + if err := conn.Write(ctx, websocket.MessageText, payload); err != nil { + h.removeClient(conn, websocket.StatusInternalError, "send initial snapshot failed") + } +} + +// RemoveClient 移除一个 WebSocket 客户端连接。 +func (h *EventHub) RemoveClient(conn *websocket.Conn) { + if h == nil || conn == nil { + return + } + h.removeClient(conn, websocket.StatusNormalClosure, "client disconnected") +} + +func (h *EventHub) removeClient(conn *websocket.Conn, code websocket.StatusCode, reason string) { + h.mu.Lock() + _, exists := h.clients[conn] + if exists { + delete(h.clients, conn) + } + h.mu.Unlock() + + if exists { + _ = conn.Close(code, reason) + } +} + +func (h *EventHub) broadcastPayload(payload []byte) { + clients := h.snapshotClients() + for _, conn := range clients { + ctx, cancel := context.WithTimeout(context.Background(), h.writeTimeout) + err := conn.Write(ctx, websocket.MessageText, payload) + cancel() + if err != nil { + h.removeClient(conn, websocket.StatusInternalError, "write failed") + } + } +} + +func (h *EventHub) snapshotClients() []*websocket.Conn { + h.mu.RLock() + defer h.mu.RUnlock() + clients := make([]*websocket.Conn, 0, len(h.clients)) + for conn := range h.clients { + clients = append(clients, conn) + } + return clients +} + +func (h *EventHub) closeAllClients(code websocket.StatusCode, reason string) { + h.mu.Lock() + clients := make([]*websocket.Conn, 0, len(h.clients)) + for conn := range h.clients { + clients = append(clients, conn) + } + h.clients = make(map[*websocket.Conn]struct{}) + h.mu.Unlock() + + for _, conn := range clients { + _ = conn.Close(code, reason) + } +} diff --git a/backend/internal/service/event_hub_test.go b/backend/internal/service/event_hub_test.go new file mode 100644 index 0000000..58e1c60 --- /dev/null +++ b/backend/internal/service/event_hub_test.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "minedock/backend/internal/model" +) + +func TestEventHub_AddRemoveClient(t *testing.T) { + hub := newEventHub(nil, nil) + serverConn, clientConn, cleanup := newWebSocketPair(t) + defer cleanup() + + hub.AddClient(serverConn) + if got := len(hub.snapshotClients()); got != 1 { + t.Fatalf("expected 1 client, got %d", got) + } + + hub.RemoveClient(serverConn) + if got := len(hub.snapshotClients()); got != 0 { + t.Fatalf("expected 0 clients, got %d", got) + } + + readCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + _, _, err := clientConn.Read(readCtx) + if websocket.CloseStatus(err) != websocket.StatusNormalClosure { + t.Fatalf("expected normal closure, got %v", err) + } +} + +func TestEventHub_RefreshAndBroadcast_DeduplicateSnapshot(t *testing.T) { + instances := []model.Instance{{ContainerID: "c1", Name: "alpha", Status: "Running"}} + hub := newEventHub(nil, func(context.Context) ([]model.Instance, error) { + return append([]model.Instance(nil), instances...), nil + }) + + serverConn, clientConn, cleanup := newWebSocketPair(t) + defer cleanup() + hub.AddClient(serverConn) + + if err := hub.refreshAndBroadcast(context.Background()); err != nil { + t.Fatalf("first refresh: %v", err) + } + + first := readWSMessage(t, clientConn) + assertInstancesUpdatedPayload(t, first, "Running") + + nextMessageCh := readWSMessageAsync(clientConn) + + if err := hub.refreshAndBroadcast(context.Background()); err != nil { + t.Fatalf("duplicate refresh: %v", err) + } + + select { + case result := <-nextMessageCh: + if result.err != nil { + t.Fatalf("unexpected websocket error: %v", result.err) + } + t.Fatalf("expected no message for duplicate snapshot, got %s", string(result.payload)) + case <-time.After(120 * time.Millisecond): + } + + instances[0].Status = "Stopped" + if err := hub.refreshAndBroadcast(context.Background()); err != nil { + t.Fatalf("third refresh: %v", err) + } + + select { + case result := <-nextMessageCh: + if result.err != nil { + t.Fatalf("read websocket payload: %v", result.err) + } + assertInstancesUpdatedPayload(t, result.payload, "Stopped") + case <-time.After(time.Second): + t.Fatal("timeout waiting message after snapshot change") + } +} + +func newWebSocketPair(t *testing.T) (*websocket.Conn, *websocket.Conn, func()) { + t.Helper() + + serverConnCh := make(chan *websocket.Conn, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + t.Errorf("accept websocket: %v", err) + return + } + serverConnCh <- conn + for { + if _, _, err := conn.Read(r.Context()); err != nil { + return + } + } + })) + + clientURL := "ws" + strings.TrimPrefix(server.URL, "http") + clientConn, _, err := websocket.Dial(context.Background(), clientURL, nil) + if err != nil { + server.Close() + t.Fatalf("dial websocket: %v", err) + } + + var serverConn *websocket.Conn + select { + case serverConn = <-serverConnCh: + case <-time.After(time.Second): + _ = clientConn.Close(websocket.StatusAbnormalClosure, "timeout") + server.Close() + t.Fatal("timeout waiting for server websocket connection") + } + + cleanup := func() { + _ = clientConn.Close(websocket.StatusNormalClosure, "test cleanup") + _ = serverConn.Close(websocket.StatusNormalClosure, "test cleanup") + server.Close() + } + + return serverConn, clientConn, cleanup +} + +func readWSMessage(t *testing.T, conn *websocket.Conn) []byte { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, payload, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read websocket payload: %v", err) + } + return payload +} + +type wsReadResult struct { + payload []byte + err error +} + +func readWSMessageAsync(conn *websocket.Conn) <-chan wsReadResult { + ch := make(chan wsReadResult, 1) + go func() { + _, payload, err := conn.Read(context.Background()) + ch <- wsReadResult{payload: payload, err: err} + }() + return ch +} + +func assertInstancesUpdatedPayload(t *testing.T, payload []byte, wantStatus string) { + t.Helper() + + var msg struct { + Type string `json:"type"` + Data []model.Instance `json:"data"` + } + if err := json.Unmarshal(payload, &msg); err != nil { + t.Fatalf("decode websocket payload: %v", err) + } + if msg.Type != "instances_updated" { + t.Fatalf("unexpected message type: %s", msg.Type) + } + if len(msg.Data) != 1 { + t.Fatalf("unexpected data size: %d", len(msg.Data)) + } + if msg.Data[0].Status != wantStatus { + t.Fatalf("unexpected status: %s", msg.Data[0].Status) + } +} diff --git a/backend/internal/service/game_service.go b/backend/internal/service/game_service.go new file mode 100644 index 0000000..21c0051 --- /dev/null +++ b/backend/internal/service/game_service.go @@ -0,0 +1,294 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "minedock/backend/internal/model" +) + +var allowedParamTypes = map[string]struct{}{ + "string": {}, + "number": {}, + "boolean": {}, + "select": {}, +} + +// GameService 提供游戏目录查询和模板按需加载能力。 +type GameService struct { + games []model.Game + gameMap map[string]model.Game + templateDir string +} + +// NewGameService 从 games.json 加载游戏目录,并记录模板目录路径。 +func NewGameService(gamesFilePath string, templateDirPath string) (*GameService, error) { + gamesPath := strings.TrimSpace(gamesFilePath) + if gamesPath == "" { + return nil, fmt.Errorf("games path is required") + } + + templateDir := strings.TrimSpace(templateDirPath) + if templateDir == "" { + return nil, fmt.Errorf("template dir path is required") + } + + content, err := os.ReadFile(gamesPath) + if err != nil { + return nil, fmt.Errorf("read games file: %w", err) + } + + var games []model.Game + if err := json.Unmarshal(content, &games); err != nil { + return nil, fmt.Errorf("decode games file: %w", err) + } + + gameMap := make(map[string]model.Game, len(games)) + for i, game := range games { + normalized, err := normalizeGame(game) + if err != nil { + return nil, fmt.Errorf("invalid game at index %d: %w", i, err) + } + + if _, exists := gameMap[normalized.ID]; exists { + return nil, fmt.Errorf("duplicate game id: %s", normalized.ID) + } + + templatePath := filepath.Join(templateDir, normalized.ID+".yaml") + if _, err := os.Stat(templatePath); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("template for game %q: %w", normalized.ID, model.ErrTemplateNotFound) + } + return nil, fmt.Errorf("stat template for game %q: %w", normalized.ID, err) + } + + games[i] = normalized + gameMap[normalized.ID] = normalized + } + + return &GameService{games: games, gameMap: gameMap, templateDir: templateDir}, nil +} + +// ListGames 返回游戏目录(轻量列表)。 +func (s *GameService) ListGames(_ context.Context) []model.Game { + if s == nil || len(s.games) == 0 { + return []model.Game{} + } + out := make([]model.Game, len(s.games)) + copy(out, s.games) + return out +} + +// GetGame 按 ID 查找游戏条目。 +func (s *GameService) GetGame(_ context.Context, id string) (model.Game, error) { + if s == nil { + return model.Game{}, model.ErrGameNotFound + } + + key := strings.TrimSpace(id) + if key == "" { + return model.Game{}, model.ErrGameNotFound + } + + game, ok := s.gameMap[key] + if !ok { + return model.Game{}, model.ErrGameNotFound + } + return game, nil +} + +// GetTemplate 按游戏 ID 加载并返回对应的 YAML 模板。 +func (s *GameService) GetTemplate(ctx context.Context, id string) (model.GameTemplate, error) { + if s == nil { + return model.GameTemplate{}, model.ErrGameNotFound + } + + game, err := s.GetGame(ctx, id) + if err != nil { + return model.GameTemplate{}, err + } + + templatePath := filepath.Join(s.templateDir, game.ID+".yaml") + content, err := os.ReadFile(templatePath) + if err != nil { + if os.IsNotExist(err) { + return model.GameTemplate{}, model.ErrTemplateNotFound + } + return model.GameTemplate{}, fmt.Errorf("read template %q: %w", game.ID, err) + } + + var tpl model.GameTemplate + if err := yaml.Unmarshal(content, &tpl); err != nil { + return model.GameTemplate{}, fmt.Errorf("parse template %q: %w: %v", game.ID, model.ErrTemplateInvalid, err) + } + + if err := normalizeTemplate(game.ID, &tpl); err != nil { + return model.GameTemplate{}, err + } + + return tpl, nil +} + +func normalizeGame(game model.Game) (model.Game, error) { + game.ID = strings.TrimSpace(game.ID) + game.Name = strings.TrimSpace(game.Name) + game.Description = strings.TrimSpace(game.Description) + game.Category = strings.TrimSpace(game.Category) + game.Icon = strings.TrimSpace(game.Icon) + + if game.ID == "" { + return model.Game{}, fmt.Errorf("id is required") + } + if game.Name == "" { + return model.Game{}, fmt.Errorf("name is required") + } + if game.Description == "" { + return model.Game{}, fmt.Errorf("description is required") + } + if game.Category == "" { + return model.Game{}, fmt.Errorf("category is required") + } + if game.Icon == "" { + return model.Game{}, fmt.Errorf("icon is required") + } + + return game, nil +} + +func normalizeTemplate(gameID string, tpl *model.GameTemplate) error { + tpl.Image.Name = strings.TrimSpace(tpl.Image.Name) + tpl.Image.Tag = strings.TrimSpace(tpl.Image.Tag) + if tpl.Image.Name == "" { + return fmt.Errorf("template %q image.name is required: %w", gameID, model.ErrTemplateInvalid) + } + if tpl.Image.Tag == "" { + tpl.Image.Tag = "latest" + } + + normalizedEnv := make(map[string]string, len(tpl.Container.Env)) + for key, value := range tpl.Container.Env { + envKey := strings.TrimSpace(key) + if envKey == "" { + return fmt.Errorf("template %q contains empty env key: %w", gameID, model.ErrTemplateInvalid) + } + normalizedEnv[envKey] = value + } + tpl.Container.Env = normalizedEnv + + for i := range tpl.Container.Ports { + if tpl.Container.Ports[i].Host <= 0 || tpl.Container.Ports[i].Container <= 0 { + return fmt.Errorf("template %q port index %d is invalid: %w", gameID, i, model.ErrTemplateInvalid) + } + + protocol := strings.ToLower(strings.TrimSpace(tpl.Container.Ports[i].Protocol)) + if protocol == "" { + protocol = "tcp" + } + if protocol != "tcp" && protocol != "udp" { + return fmt.Errorf("template %q port index %d has unsupported protocol: %w", gameID, i, model.ErrTemplateInvalid) + } + tpl.Container.Ports[i].Protocol = protocol + } + + for i := range tpl.Container.Volumes { + tpl.Container.Volumes[i].Name = strings.TrimSpace(tpl.Container.Volumes[i].Name) + tpl.Container.Volumes[i].ContainerPath = strings.TrimSpace(tpl.Container.Volumes[i].ContainerPath) + if tpl.Container.Volumes[i].ContainerPath == "" { + return fmt.Errorf("template %q volume index %d container_path is required: %w", gameID, i, model.ErrTemplateInvalid) + } + } + + params := make([]model.TemplateParam, 0, len(tpl.Params)) + seenKeys := make(map[string]struct{}, len(tpl.Params)) + for i := range tpl.Params { + param := tpl.Params[i] + param.Key = strings.TrimSpace(param.Key) + param.Label = strings.TrimSpace(param.Label) + param.Description = strings.TrimSpace(param.Description) + param.Type = strings.ToLower(strings.TrimSpace(param.Type)) + param.EnvVar = strings.TrimSpace(param.EnvVar) + + if param.Key == "" { + return fmt.Errorf("template %q param index %d key is required: %w", gameID, i, model.ErrTemplateInvalid) + } + if _, exists := seenKeys[param.Key]; exists { + return fmt.Errorf("template %q has duplicate param key %q: %w", gameID, param.Key, model.ErrTemplateInvalid) + } + seenKeys[param.Key] = struct{}{} + + if _, ok := allowedParamTypes[param.Type]; !ok { + return fmt.Errorf("template %q param %q has unsupported type %q: %w", gameID, param.Key, param.Type, model.ErrTemplateInvalid) + } + + if param.Label == "" { + param.Label = param.Key + } + if param.EnvVar == "" { + param.EnvVar = param.Key + } + + if param.Type == "select" { + if len(param.Options) == 0 { + return fmt.Errorf("template %q param %q options are required for select type: %w", gameID, param.Key, model.ErrTemplateInvalid) + } + if err := validateSelectParam(gameID, param); err != nil { + return err + } + } + + params = append(params, param) + } + if params == nil { + params = []model.TemplateParam{} + } + tpl.Params = params + + if tpl.Container.Ports == nil { + tpl.Container.Ports = []model.PortMapping{} + } + if tpl.Container.Volumes == nil { + tpl.Container.Volumes = []model.VolumeMount{} + } + + return nil +} + +func validateSelectParam(gameID string, param model.TemplateParam) error { + options := make(map[string]struct{}, len(param.Options)) + for i := range param.Options { + value := strings.TrimSpace(param.Options[i].Value) + label := strings.TrimSpace(param.Options[i].Label) + if value == "" { + return fmt.Errorf("template %q param %q option index %d value is required: %w", gameID, param.Key, i, model.ErrTemplateInvalid) + } + if label == "" { + label = value + } + if _, exists := options[value]; exists { + return fmt.Errorf("template %q param %q has duplicate option %q: %w", gameID, param.Key, value, model.ErrTemplateInvalid) + } + param.Options[i].Value = value + param.Options[i].Label = label + options[value] = struct{}{} + } + + if param.Default == nil { + return nil + } + + defaultValue := strings.TrimSpace(fmt.Sprint(param.Default)) + if defaultValue == "" { + return nil + } + if _, ok := options[defaultValue]; !ok { + return fmt.Errorf("template %q param %q default value %q not in options: %w", gameID, param.Key, defaultValue, model.ErrTemplateInvalid) + } + + return nil +} diff --git a/backend/internal/service/game_service_test.go b/backend/internal/service/game_service_test.go new file mode 100644 index 0000000..3b7672f --- /dev/null +++ b/backend/internal/service/game_service_test.go @@ -0,0 +1,222 @@ +package service + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "minedock/backend/internal/model" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write file %s: %v", path, err) + } +} + +func TestNewGameService_ListGamesSuccess(t *testing.T) { + tempDir := t.TempDir() + templatesDir := filepath.Join(tempDir, "templates") + if err := os.Mkdir(templatesDir, 0o755); err != nil { + t.Fatalf("mkdir templates: %v", err) + } + + writeFile(t, filepath.Join(tempDir, "games.json"), `[ + { + "id": "minecraft-java", + "name": "Minecraft Java", + "description": "desc", + "category": "minecraft", + "icon": "minecraft-java" + } +]`) + writeFile(t, filepath.Join(templatesDir, "minecraft-java.yaml"), `image: + name: "itzg/minecraft-server" +`) + + svc, err := NewGameService(filepath.Join(tempDir, "games.json"), templatesDir) + if err != nil { + t.Fatalf("new game service: %v", err) + } + + games := svc.ListGames(context.Background()) + if len(games) != 1 { + t.Fatalf("expected 1 game, got %d", len(games)) + } + if games[0].ID != "minecraft-java" { + t.Fatalf("unexpected game id: %s", games[0].ID) + } + + games[0].Name = "changed" + games2 := svc.ListGames(context.Background()) + if games2[0].Name != "Minecraft Java" { + t.Fatalf("expected cloned result, got %s", games2[0].Name) + } +} + +func TestNewGameService_EmptyAndInvalidGamesFile(t *testing.T) { + t.Run("empty list", func(t *testing.T) { + tempDir := t.TempDir() + templatesDir := filepath.Join(tempDir, "templates") + if err := os.Mkdir(templatesDir, 0o755); err != nil { + t.Fatalf("mkdir templates: %v", err) + } + writeFile(t, filepath.Join(tempDir, "games.json"), `[]`) + + svc, err := NewGameService(filepath.Join(tempDir, "games.json"), templatesDir) + if err != nil { + t.Fatalf("new game service: %v", err) + } + if got := svc.ListGames(context.Background()); len(got) != 0 { + t.Fatalf("expected empty list, got %d", len(got)) + } + }) + + t.Run("invalid json", func(t *testing.T) { + tempDir := t.TempDir() + templatesDir := filepath.Join(tempDir, "templates") + if err := os.Mkdir(templatesDir, 0o755); err != nil { + t.Fatalf("mkdir templates: %v", err) + } + writeFile(t, filepath.Join(tempDir, "games.json"), `{`) + + if _, err := NewGameService(filepath.Join(tempDir, "games.json"), templatesDir); err == nil { + t.Fatal("expected error") + } + }) +} + +func TestNewGameService_MissingTemplateOnStartup(t *testing.T) { + tempDir := t.TempDir() + templatesDir := filepath.Join(tempDir, "templates") + if err := os.Mkdir(templatesDir, 0o755); err != nil { + t.Fatalf("mkdir templates: %v", err) + } + + writeFile(t, filepath.Join(tempDir, "games.json"), `[ + { + "id": "minecraft-java", + "name": "Minecraft Java", + "description": "desc", + "category": "minecraft", + "icon": "minecraft-java" + } +]`) + + _, err := NewGameService(filepath.Join(tempDir, "games.json"), templatesDir) + if !errors.Is(err, model.ErrTemplateNotFound) { + t.Fatalf("expected ErrTemplateNotFound, got %v", err) + } +} + +func TestGameService_GetTemplateSuccess(t *testing.T) { + tempDir := t.TempDir() + templatesDir := filepath.Join(tempDir, "templates") + if err := os.Mkdir(templatesDir, 0o755); err != nil { + t.Fatalf("mkdir templates: %v", err) + } + + writeFile(t, filepath.Join(tempDir, "games.json"), `[ + { + "id": "minecraft-java", + "name": "Minecraft Java", + "description": "desc", + "category": "minecraft", + "icon": "minecraft-java" + } +]`) + writeFile(t, filepath.Join(templatesDir, "minecraft-java.yaml"), `image: + name: "itzg/minecraft-server" +container: + ports: + - host: 25565 + container: 25565 + env: + EULA: "TRUE" +params: + - key: "SERVER_TYPE" + type: "select" + default: "PAPER" + options: + - value: "PAPER" +`) + + svc, err := NewGameService(filepath.Join(tempDir, "games.json"), templatesDir) + if err != nil { + t.Fatalf("new game service: %v", err) + } + + tpl, err := svc.GetTemplate(context.Background(), "minecraft-java") + if err != nil { + t.Fatalf("get template: %v", err) + } + if tpl.Image.FullImageRef() != "itzg/minecraft-server:latest" { + t.Fatalf("unexpected image ref: %s", tpl.Image.FullImageRef()) + } + if len(tpl.Container.Ports) != 1 || tpl.Container.Ports[0].Protocol != "tcp" { + t.Fatalf("unexpected port protocol: %+v", tpl.Container.Ports) + } + if len(tpl.Params) != 1 { + t.Fatalf("expected 1 param, got %d", len(tpl.Params)) + } + if tpl.Params[0].EnvVar != "SERVER_TYPE" { + t.Fatalf("expected default env var, got %s", tpl.Params[0].EnvVar) + } + if tpl.Params[0].Label != "SERVER_TYPE" { + t.Fatalf("expected default label, got %s", tpl.Params[0].Label) + } +} + +func TestGameService_GetTemplateErrors(t *testing.T) { + tempDir := t.TempDir() + templatesDir := filepath.Join(tempDir, "templates") + if err := os.Mkdir(templatesDir, 0o755); err != nil { + t.Fatalf("mkdir templates: %v", err) + } + + gamesPath := filepath.Join(tempDir, "games.json") + templatePath := filepath.Join(templatesDir, "minecraft-java.yaml") + writeFile(t, gamesPath, `[ + { + "id": "minecraft-java", + "name": "Minecraft Java", + "description": "desc", + "category": "minecraft", + "icon": "minecraft-java" + } +]`) + writeFile(t, templatePath, `image: + name: "itzg/minecraft-server" +`) + + svc, err := NewGameService(gamesPath, templatesDir) + if err != nil { + t.Fatalf("new game service: %v", err) + } + + if _, err := svc.GetTemplate(context.Background(), "not-exists"); !errors.Is(err, model.ErrGameNotFound) { + t.Fatalf("expected ErrGameNotFound, got %v", err) + } + + if err := os.Remove(templatePath); err != nil { + t.Fatalf("remove template: %v", err) + } + if _, err := svc.GetTemplate(context.Background(), "minecraft-java"); !errors.Is(err, model.ErrTemplateNotFound) { + t.Fatalf("expected ErrTemplateNotFound, got %v", err) + } + + writeFile(t, templatePath, `[`) // invalid yaml + if _, err := svc.GetTemplate(context.Background(), "minecraft-java"); !errors.Is(err, model.ErrTemplateInvalid) { + t.Fatalf("expected ErrTemplateInvalid for yaml parse error, got %v", err) + } + + writeFile(t, templatePath, `image: + tag: "latest" +`) + if _, err := svc.GetTemplate(context.Background(), "minecraft-java"); !errors.Is(err, model.ErrTemplateInvalid) { + t.Fatalf("expected ErrTemplateInvalid for missing image name, got %v", err) + } +} diff --git a/backend/internal/store/memory.go b/backend/internal/store/sqlite.go similarity index 61% rename from backend/internal/store/memory.go rename to backend/internal/store/sqlite.go index af0e566..fb62670 100644 --- a/backend/internal/store/memory.go +++ b/backend/internal/store/sqlite.go @@ -11,16 +11,15 @@ import ( "minedock/backend/internal/model" - _ "modernc.org/sqlite" + _ "modernc.org/sqlite" // 注册 SQLite 驱动 ) -var ErrNameExists = errors.New("instance name already exists") - -// SQLiteStore persists instance state in a local SQLite database. +// SQLiteStore 在本地 SQLite 数据库中持久化实例状态。 type SQLiteStore struct { db *sql.DB } +// NewSQLiteStore 打开 dbPath 指向的 SQLite 并初始化所需表结构。 func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { cleanPath := strings.TrimSpace(dbPath) if cleanPath == "" { @@ -36,7 +35,8 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { return nil, fmt.Errorf("open sqlite db: %w", err) } - // SQLite works best with a single writer connection in this MVP. + // 说明:当前 MVP 阶段使用单写连接更贴合 SQLite 的并发模型。 + // TODO: 当写入吞吐成为瓶颈时,重新评估连接池策略。 db.SetMaxOpenConns(1) if err := db.Ping(); err != nil { @@ -53,6 +53,7 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { return s, nil } +// Close 释放底层 SQLite 数据库连接。 func (s *SQLiteStore) Close() error { if s == nil || s.db == nil { return nil @@ -60,11 +61,13 @@ func (s *SQLiteStore) Close() error { return s.db.Close() } +// InitSchema 在表不存在时创建存储表结构。 func (s *SQLiteStore) InitSchema(ctx context.Context) error { const schema = ` CREATE TABLE IF NOT EXISTS instances ( container_id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, + game_id TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -73,23 +76,44 @@ CREATE TABLE IF NOT EXISTS instances ( if _, err := s.db.ExecContext(ctx, schema); err != nil { return fmt.Errorf("init instances table: %w", err) } + if err := s.ensureGameIDColumn(ctx); err != nil { + return err + } return nil } +func (s *SQLiteStore) ensureGameIDColumn(ctx context.Context) error { + const addGameIDColumn = ` +ALTER TABLE instances +ADD COLUMN game_id TEXT NOT NULL DEFAULT ''; +` + + if _, err := s.db.ExecContext(ctx, addGameIDColumn); err != nil { + if isDuplicateColumnErr(err) { + return nil + } + return fmt.Errorf("ensure instances.game_id column: %w", err) + } + + return nil +} + +// Save 按容器 ID 执行实例记录的 upsert。 func (s *SQLiteStore) Save(ctx context.Context, instance model.Instance) error { const upsert = ` -INSERT INTO instances(container_id, name, status) -VALUES(?, ?, ?) +INSERT INTO instances(container_id, name, game_id, status) +VALUES(?, ?, ?, ?) ON CONFLICT(container_id) DO UPDATE SET name = excluded.name, + game_id = excluded.game_id, status = excluded.status; ` - _, err := s.db.ExecContext(ctx, upsert, instance.ContainerID, instance.Name, instance.Status) + _, err := s.db.ExecContext(ctx, upsert, instance.ContainerID, instance.Name, instance.GameID, instance.Status) if err != nil { if isUniqueNameErr(err) { - return ErrNameExists + return model.ErrNameExists } return fmt.Errorf("save instance: %w", err) } @@ -97,6 +121,7 @@ DO UPDATE SET return nil } +// Delete 按容器 ID 删除一条实例记录。 func (s *SQLiteStore) Delete(ctx context.Context, containerID string) error { if _, err := s.db.ExecContext(ctx, "DELETE FROM instances WHERE container_id = ?", containerID); err != nil { return fmt.Errorf("delete instance: %w", err) @@ -104,15 +129,16 @@ func (s *SQLiteStore) Delete(ctx context.Context, containerID string) error { return nil } +// Get 按容器 ID 获取一条实例记录。 func (s *SQLiteStore) Get(ctx context.Context, containerID string) (model.Instance, bool, error) { const q = ` -SELECT container_id, name, status +SELECT container_id, name, game_id, status FROM instances WHERE container_id = ?; ` var inst model.Instance - err := s.db.QueryRowContext(ctx, q, containerID).Scan(&inst.ContainerID, &inst.Name, &inst.Status) + err := s.db.QueryRowContext(ctx, q, containerID).Scan(&inst.ContainerID, &inst.Name, &inst.GameID, &inst.Status) if errors.Is(err, sql.ErrNoRows) { return model.Instance{}, false, nil } @@ -122,9 +148,10 @@ WHERE container_id = ?; return inst, true, nil } +// List 返回所有实例记录,并按创建时间倒序排列。 func (s *SQLiteStore) List(ctx context.Context) ([]model.Instance, error) { const q = ` -SELECT container_id, name, status +SELECT container_id, name, game_id, status FROM instances ORDER BY created_at DESC; ` @@ -138,7 +165,7 @@ ORDER BY created_at DESC; out := make([]model.Instance, 0) for rows.Next() { var inst model.Instance - if err := rows.Scan(&inst.ContainerID, &inst.Name, &inst.Status); err != nil { + if err := rows.Scan(&inst.ContainerID, &inst.Name, &inst.GameID, &inst.Status); err != nil { return nil, fmt.Errorf("scan instance: %w", err) } out = append(out, inst) @@ -151,6 +178,9 @@ ORDER BY created_at DESC; return out, nil } +// isUniqueNameErr 判断是否为 instances.name 的唯一约束冲突。 +// 说明:当前实现依赖 SQLite 错误文本匹配。 +// TODO: 用 SQLite 错误码替代文本匹配判断。 func isUniqueNameErr(err error) bool { if err == nil { return false @@ -158,3 +188,11 @@ func isUniqueNameErr(err error) bool { errStr := err.Error() return strings.Contains(errStr, "UNIQUE constraint failed: instances.name") } + +func isDuplicateColumnErr(err error) bool { + if err == nil { + return false + } + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "duplicate column name") && strings.Contains(errStr, "game_id") +} diff --git a/backend/internal/store/sqlite_test.go b/backend/internal/store/sqlite_test.go new file mode 100644 index 0000000..3b89ba9 --- /dev/null +++ b/backend/internal/store/sqlite_test.go @@ -0,0 +1,172 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "minedock/backend/internal/model" +) + +func newTestStore(t *testing.T) *SQLiteStore { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + s, err := NewSQLiteStore(path) + if err != nil { + t.Fatalf("new test store: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestNewSQLiteStore_EmptyPath(t *testing.T) { + _, err := NewSQLiteStore("") + if err == nil { + t.Fatal("expected error for empty path") + } +} + +func TestSaveAndGet(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + inst := model.Instance{ContainerID: "c1", Name: "server-1", GameID: "minecraft-java", Status: "Stopped"} + if err := s.Save(ctx, inst); err != nil { + t.Fatalf("save: %v", err) + } + + got, ok, err := s.Get(ctx, "c1") + if err != nil { + t.Fatalf("get: %v", err) + } + if !ok { + t.Fatal("expected instance to exist") + } + if got.ContainerID != "c1" || got.Name != "server-1" || got.GameID != "minecraft-java" || got.Status != "Stopped" { + t.Fatalf("unexpected instance: %+v", got) + } +} + +func TestSave_Upsert(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + inst := model.Instance{ContainerID: "c1", Name: "server-1", GameID: "minecraft-java", Status: "Stopped"} + if err := s.Save(ctx, inst); err != nil { + t.Fatalf("first save: %v", err) + } + + inst.GameID = "minecraft-bedrock" + inst.Status = "Running" + if err := s.Save(ctx, inst); err != nil { + t.Fatalf("upsert save: %v", err) + } + + got, _, err := s.Get(ctx, "c1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Status != "Running" { + t.Fatalf("expected Running, got %s", got.Status) + } + if got.GameID != "minecraft-bedrock" { + t.Fatalf("expected minecraft-bedrock, got %s", got.GameID) + } +} + +func TestSave_DuplicateName(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + if err := s.Save(ctx, model.Instance{ContainerID: "c1", Name: "dup", GameID: "minecraft-java", Status: "Stopped"}); err != nil { + t.Fatalf("first save: %v", err) + } + + err := s.Save(ctx, model.Instance{ContainerID: "c2", Name: "dup", GameID: "minecraft-java", Status: "Stopped"}) + if !errors.Is(err, model.ErrNameExists) { + t.Fatalf("expected ErrNameExists, got %v", err) + } +} + +func TestGet_NotFound(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + _, ok, err := s.Get(ctx, "nonexistent") + if err != nil { + t.Fatalf("get: %v", err) + } + if ok { + t.Fatal("expected not found") + } +} + +func TestDelete(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + inst := model.Instance{ContainerID: "c1", Name: "server-1", GameID: "minecraft-java", Status: "Stopped"} + if err := s.Save(ctx, inst); err != nil { + t.Fatalf("save: %v", err) + } + + if err := s.Delete(ctx, "c1"); err != nil { + t.Fatalf("delete: %v", err) + } + + _, ok, err := s.Get(ctx, "c1") + if err != nil { + t.Fatalf("get after delete: %v", err) + } + if ok { + t.Fatal("expected instance to be deleted") + } +} + +func TestDelete_NonExistent(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + // 删除不存在的记录不应返回错误。 + if err := s.Delete(ctx, "nonexistent"); err != nil { + t.Fatalf("delete non-existent: %v", err) + } +} + +func TestList(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + instances := []model.Instance{ + {ContainerID: "c1", Name: "alpha", GameID: "minecraft-java", Status: "Running"}, + {ContainerID: "c2", Name: "beta", GameID: "minecraft-bedrock", Status: "Stopped"}, + {ContainerID: "c3", Name: "gamma", GameID: "terraria", Status: "Running"}, + } + for _, inst := range instances { + if err := s.Save(ctx, inst); err != nil { + t.Fatalf("save %s: %v", inst.Name, err) + } + } + + got, err := s.List(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 3 { + t.Fatalf("expected 3 instances, got %d", len(got)) + } +} + +func TestList_Empty(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + got, err := s.List(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 instances, got %d", len(got)) + } +} diff --git a/backend/main.go b/backend/main.go index 3062f86..0f4884c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "log" "net/http" "os" @@ -30,10 +31,35 @@ func main() { } defer sqliteStore.Close() - imageName := os.Getenv("MINEDOCK_IMAGE") - svc := service.NewDockerService(cli, sqliteStore, imageName) + gamesPath := os.Getenv("MINEDOCK_GAMES_PATH") + if gamesPath == "" { + gamesPath = "games.json" + } + + templatesDir := os.Getenv("MINEDOCK_TEMPLATES_DIR") + if templatesDir == "" { + templatesDir = "templates" + } + + gameSvc, err := service.NewGameService(gamesPath, templatesDir) + if err != nil { + log.Fatalf("init game service: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + svc := service.NewDockerService(cli, sqliteStore, gameSvc) + hub := service.NewEventHub(cli, svc.ListInstances) + go hub.Run(ctx) + h := api.NewHandler(svc) - router := api.NewRouter(h) + gameHandler := api.NewGameHandler(gameSvc) + wsHandler := api.NewWsHandler(hub) + consoleSvc := service.NewConsoleService(cli) + consoleHandler := api.NewConsoleHandler(consoleSvc) + configHandler := api.NewConfigHandler(svc) + router := api.NewRouter(h, gameHandler, wsHandler, consoleHandler, configHandler) addr := ":8080" log.Printf("MineDock backend listening on %s", addr) diff --git a/backend/templates/minecraft-bedrock.yaml b/backend/templates/minecraft-bedrock.yaml new file mode 100644 index 0000000..ff5e303 --- /dev/null +++ b/backend/templates/minecraft-bedrock.yaml @@ -0,0 +1,47 @@ +image: + name: "itzg/minecraft-bedrock-server" + tag: "latest" + +container: + ports: + - host: 19132 + container: 19132 + protocol: "udp" + env: + EULA: "TRUE" + volumes: + - name: "server-data" + container_path: "/data" + resources: + memory: "1g" + cpu: 1.0 + +params: + - key: "GAMEMODE" + label: "游戏模式" + description: "服务器默认游戏模式" + type: "select" + default: "survival" + options: + - value: "survival" + label: "生存" + - value: "creative" + label: "创造" + - value: "adventure" + label: "冒险" + env_var: "GAMEMODE" + - key: "DIFFICULTY" + label: "难度" + description: "服务器难度" + type: "select" + default: "normal" + options: + - value: "peaceful" + label: "和平" + - value: "easy" + label: "简单" + - value: "normal" + label: "普通" + - value: "hard" + label: "困难" + env_var: "DIFFICULTY" diff --git a/backend/templates/minecraft-java.yaml b/backend/templates/minecraft-java.yaml new file mode 100644 index 0000000..7c890a0 --- /dev/null +++ b/backend/templates/minecraft-java.yaml @@ -0,0 +1,59 @@ +image: + name: "itzg/minecraft-server" + tag: "latest" + +container: + ports: + - host: 25565 + container: 25565 + protocol: "tcp" + env: + EULA: "TRUE" + TYPE: "PAPER" + volumes: + - name: "server-data" + container_path: "/data" + resources: + memory: "2g" + cpu: 2.0 + health_check: + test: ["CMD-SHELL", "mc-health"] + interval: "30s" + timeout: "10s" + retries: 3 + start_period: "120s" + +params: + - key: "SERVER_TYPE" + label: "服务器类型" + description: "选择 Minecraft 服务器内核" + type: "select" + default: "PAPER" + options: + - value: "PAPER" + label: "Paper" + - value: "FABRIC" + label: "Fabric" + - value: "FORGE" + label: "Forge" + - value: "VANILLA" + label: "Vanilla" + env_var: "TYPE" + - key: "MC_VERSION" + label: "游戏版本" + description: "指定 Minecraft 版本号" + type: "string" + default: "LATEST" + env_var: "VERSION" + - key: "MAX_PLAYERS" + label: "最大玩家数" + description: "服务器最大在线人数" + type: "number" + default: 20 + env_var: "MAX_PLAYERS" + - key: "ONLINE_MODE" + label: "正版验证" + description: "是否启用 Mojang 正版验证" + type: "boolean" + default: true + env_var: "ONLINE_MODE" diff --git a/backend/templates/terraria.yaml b/backend/templates/terraria.yaml new file mode 100644 index 0000000..a96034e --- /dev/null +++ b/backend/templates/terraria.yaml @@ -0,0 +1,34 @@ +image: + name: "ryshe/terraria" + tag: "latest" + +container: + ports: + - host: 7777 + container: 7777 + protocol: "tcp" + volumes: + - name: "world-data" + container_path: "/root/.local/share/Terraria/Worlds" + resources: + memory: "1g" + cpu: 1.0 + +params: + - key: "WORLD_SIZE" + label: "世界大小" + description: "新建世界的尺寸" + type: "select" + default: "2" + options: + - value: "1" + label: "小" + - value: "2" + label: "中" + - value: "3" + label: "大" + - key: "MAX_PLAYERS" + label: "最大玩家数" + description: "服务器最大在线人数" + type: "number" + default: 8 diff --git a/docs/00_Root_Context.md b/docs/00_Root_Context.md deleted file mode 100644 index 8f98311..0000000 --- a/docs/00_Root_Context.md +++ /dev/null @@ -1,67 +0,0 @@ -# MineDock Root Context - -## 1. 说明 -MineDock 是一个用于管理 Docker 容器实例的系统 - -## 2. 目标 -实现一个支持易配置,功能强的游戏服务器容器化管理平台 - -## 3. 当前系统边界 -### 所有权边界 -#### 边界内 -- 本系统创建的容器 -- 本系统的数据库及其数据 -- 本系统的工作目录及容器挂载配置的数据卷目录 -#### 边界外 -- 非本系统创建的容器 -- 宿主机的其他进程 -- 除上述工作与挂载目录外的其他宿主机文件系统 -### 功能边界 -#### 边界内 -- 容器的生命周期管理与资源配额调度 -- 容器内游戏服务器的快速安装与运行 -- 在线终端交互与控制指令下发 -- 游戏数据的持久化与灾灾备 (快照、备份与回档) -#### 边界外 -- 游戏服务端程序本身的更新与热修复 -### 数据边界 -#### 边界内 -- Docker SDK返回的运行时信息 -- 容器的标准输出日志流 -- 容器的端口暴露配置 -#### 边界外 -- Docker Daemon的集群状态 -- 主机底层的网络状态(如防火墙配置) -### 用户边界 -#### 边界内 -- 单管理员环境结构下的全局容器管控 -#### 边界外 -- 复杂的多租户资源隔离与细粒度角色控制 (RBAC) - -## 4. 目录规范 -```text -MineDock/ -├── backend/ # 后端 Go 服务 -│ ├── data/ # 数据存储目录(如 SQLite 数据库文件) -│ ├── internal/ # 内部私有代码 -│ │ ├── api/ # 路由与 HTTP 处理层 (Handlers, Routers) -│ │ ├── model/ # 领域数据模型定义实体 (如 Instance) -│ │ ├── service/ # 核心业务逻辑服务 (如调用 Docker CLI/SDK) -│ │ └── store/ # 数据持久化/存储交互层 -│ ├── main.go # 后端程序入口 -│ └── go.mod # Go 依赖配置 -├── frontend/ # 前端 Vue 项目 -│ ├── src/ -│ │ ├── api/ # 统一管理后端接口定义与请求封装 -│ │ ├── components/ # 全局复用组件(非全局使用的应当就近存放业务内) -│ │ ├── composables/ # 复用的组合式 API (如 useInstances) -│ │ ├── locales/ # 多语言 i18n 配置文件 -│ │ ├── router/ # 路由配置与全局路由守卫 -│ │ ├── stores/ # 全局跨组件状态管理 (Pinia) -│ │ └── views/ # 路由级别的业务页面组件 -│ ├── package.json # Npm 依赖配置 -│ └── vite.config.js # Vite 构建配置 -├── docs/ # 相关架构规范与领域文档 -├── Taskfile.yml # 自动化任务构建配置 -└── Readme.md # 项目文档与入口 -``` diff --git a/docs/01_Backend_Standards.md b/docs/01_Backend_Standards.md deleted file mode 100644 index fca6c3a..0000000 --- a/docs/01_Backend_Standards.md +++ /dev/null @@ -1,21 +0,0 @@ -# 后端规范 - -后端基于以下核心选型构建: -- **开发语言**:Go -- **持久化存储**:SQLite -- **核心依赖**:Docker SDK - -## 架构依赖规则 -为保证代码的可测试性与可维护性,`internal` 目录下的四层架构必须遵循单向依赖规则,严禁循环引用: -- **`api` 层**:作为程序的入口与防腐层,负责依赖注入 `service`,并处理 HTTP 相关的解析与返回。 -- **`service` 层**:作为纯粹的业务中枢,内部调用 `store` 进行持久化获取或通过外部组件交互。 -- **`store` 层**:负责底层数据落地设计(如 SQLite 语句),对外暴露 Interface 或直接的数据操作方法。 -- **`model` 层**:最为底层,被上述三层共同引用,不应依赖 `api`/`service`/`store` 的任何代码。 - -## 编码质量与规范 -- **代码格式化与校验**:`go fmt` 与 `go vet` -- **错误处理**: - - 核心逻辑层要求显式返回 `error`,并在合适的网络层封装为统一规范的 JSON HTTP 响应体(如 `{"status": "error", "message": "..."}`)。 - - 透传错误时,应使用 `%w` (如 `fmt.Errorf("操作容器失败: %w", err)`)保留错误堆栈及上下文。 -- **Panic 处理**:业务处理流程中严禁主动触发 `panic`。仅系统引导阶段(如 SQLite 数据源加载失败、配置文件解析失败等无法提供服务的场景)被允许 `panic` -- **无状态设计**:作为管理平台,除了 `store` 与外部环境,`api` 层与 `service` 层应保持无状态,以防发生状态数据不一致的异常 diff --git a/docs/01_Frontend_Standards.md b/docs/01_Frontend_Standards.md deleted file mode 100644 index d963cbc..0000000 --- a/docs/01_Frontend_Standards.md +++ /dev/null @@ -1,25 +0,0 @@ -# 前端规范 - -前端基于以下核心选型构建: -- **构建工具**:Vite -- **核心框架**:Vue 3 (Composition API) -- **开发语言**: TypeScript -- **状态管理**:Pinia -- **路由控制**:Vue Router - -## 代码质量 -- **代码格式化与校验**: 使用 ESLint + Prettier - -## 模块化与主题化 -前端 UI 必须具备高度的模块化,以支持动态主题(如浅色/暗色模式切换)和未来可能的皮肤更换 -- **禁止硬编码样式**:所有颜色、间距、字体大小必须使用 CSS 变量引入 -- **变量分层**: - - 基础变量:如 `--blue-500`, `--gray-900` - - 语义变量:如 `--bg-primary`, `--text-danger`, `--border-color` -- **主题切换实现**:通过动态修改 `` 或 `` 的 `data-theme` 属性(如 `data-theme="dark"`),结合 CSS 变量覆盖实现低成本主题切换 - -## 国际化规范 -提供多语言支持 -- **文案分离**:所有的界面静态文本禁止在 `.vue` 或 `.js` 文件中硬编码,必须通过 Vue I18n 等插件引入 -- **文件组织**:语言包统一存放于 `src/locales/`,按语言划分(如 `zh-CN.json`, `en-US.json`) -- **动态变量插值**:涉及动态数据的文案使用插值符,例如:"成功启动容器 {containerName}" diff --git a/docs/02_API_Contracts.md b/docs/02_API_Contracts.md deleted file mode 100644 index e9c73e9..0000000 --- a/docs/02_API_Contracts.md +++ /dev/null @@ -1,13 +0,0 @@ -# MineDock API Contracts - -前后端接口定义,后端与Docker Daemon的交互流等 - -## 1. 容器实例生命周期接口 (Instance Lifecycle) - -| 方法 | 路径 | 说明 | 请求参数 | 返回结果 | -| --- | --- | --- | --- | --- | -| GET | `/api/instances` | 获取当前所有容器的列表 | 无 | `[{"container_id":"xxx", "name":"xxx", "status":"xxx"}]` | -| POST | `/api/instances` | 创建一个新容器(初始为 Stopped) | `{"name": "测试服1号"}` | `{"status": "success", "container_id": "xxx"}` | -| POST | `/api/instances/:id/start` | 启动指定容器实例 | 无(ID 在路径中) | `{"status": "success"}` | -| POST | `/api/instances/:id/stop` | 停止指定容器实例 | 无(ID 在路径中) | `{"status": "success"}` | -| DELETE | `/api/instances/:id` | 彻底删除指定容器实例 | 无(ID 在路径中) | `{"status": "success"}` | diff --git a/docs/api/contracts.md b/docs/api/contracts.md new file mode 100644 index 0000000..7033eda --- /dev/null +++ b/docs/api/contracts.md @@ -0,0 +1,238 @@ +# MineDock API Contracts + +## 命名规则 + +## 约定 + +- Base URL: `/api` +- CORS: 允许任意来源,允许方法 `GET,POST,PUT,DELETE,OPTIONS` + +## HTTP接口 + +### GET /api/instances + +- 说明:获取当前所有容器的列表 +- 状态码: + - 成功:`200` + - 失败:`500` +- 请求参数:无 +- 返回结果: + +```json +[{ "container_id": "xxx", "name": "xxx", "status": "xxx" }] +``` + +### GET /api/games + +- 说明:获取游戏目录轻量列表(用于市场页快速加载) +- 状态码: + - 成功:`200` + - 失败:`500` +- 请求参数:无 +- 返回结果: + +```json +[ + { + "id": "minecraft-java", + "name": "Minecraft Java Edition", + "description": "...", + "category": "minecraft", + "icon": "minecraft-java" + } +] +``` + +### GET /api/games/:id/template + +- 说明:按游戏 ID 加载完整模板详情(YAML 解析结果) +- 状态码: + - 成功:`200` + - 失败:`400`(ID 非法)、`404`(game 不存在)、`500`(模板不存在/模板非法) +- 请求参数: + - 路径参数:`id`(game ID) +- 返回结果: + +```json +{ + "image": { + "name": "itzg/minecraft-server", + "tag": "latest" + }, + "container": { + "ports": [{ "host": 25565, "container": 25565, "protocol": "tcp" }], + "env": { "EULA": "TRUE", "TYPE": "PAPER" }, + "volumes": [ + { "name": "server-data", "container_path": "/data", "readonly": false } + ], + "resources": { "memory": "2g", "cpu": 2 }, + "health_check": { + "test": ["CMD-SHELL", "mc-health"], + "interval": "30s", + "timeout": "10s", + "retries": 3, + "start_period": "120s" + } + }, + "params": [ + { + "key": "SERVER_TYPE", + "label": "服务器类型", + "description": "选择 Minecraft 服务器内核", + "type": "select", + "default": "PAPER", + "options": [ + { "value": "PAPER", "label": "Paper" }, + { "value": "FABRIC", "label": "Fabric" } + ], + "env_var": "TYPE" + } + ] +} +``` + +### POST /api/instances + +- 说明:创建一个新容器(初始为 Stopped),并应用模板中的端口映射与卷挂载配置 +- 状态码: + - 成功:`200` + - 失败:`400`(JSON非法/空名称/缺失 game_id/game_id 不合法/params 非法)、`409`(名称冲突)、`500`(模板不存在或模板非法/容器创建失败) +- 行为说明: + - 端口映射来源:模板 `container.ports`,可在请求体 `ports` 中覆盖 host 端口 + - 卷挂载来源:模板 `container.volumes`,卷名规则为 `minedock-{instanceName}-{volumeName}` + - 若宿主机端口冲突,返回 Docker 原生错误并映射为 `500` +- 请求参数: + +```json +{ + "name": "容器1号", + "game_id": "minecraft-java", + "ports": [{ "host": 25575, "container": 25565, "protocol": "tcp" }], + "params": { + "SERVER_TYPE": "PAPER", + "ONLINE_MODE": "true" + } +} +``` + +- 返回结果: + +```json +{ "status": "success", "container_id": "xxx" } +``` + +### POST /api/instances/:id/start + +- 说明:启动指定容器实例 +- 状态码: + - 成功:`200` + - 失败:`400`(ID非法)、`500` +- 请求参数:无(ID 在路径中) +- 返回结果: + +```json +{ "status": "success" } +``` + +### POST /api/instances/:id/stop + +- 说明:停止指定容器实例 +- 状态码: + - 成功:`200` + - 失败:`400`(ID非法)、`500` +- 请求参数:无(ID 在路径中) +- 返回结果: + +```json +{ "status": "success" } +``` + +### DELETE /api/instances/:id + +- 说明:彻底删除指定容器实例 +- 状态码: + - 成功:`200` + - 失败:`400`(ID非法)、`409`(实例运行中)、`500` +- 请求参数:无(ID 在路径中) +- 返回结果: + +```json +{ "status": "success" } +``` + +### GET /api/instances/:id/config + +- 说明:获取容器当前生效的可编辑配置(包含模板 `params` 定义参数与可编辑端口映射) +- 状态码: + - 成功:`200` + - 失败:`400`(ID非法)、`500` +- 请求参数:无(ID 在路径中) +- 返回结果: + +```json +{ + "game_id": "minecraft-java", + "status": "Stopped", + "ports": [{ "host": 25565, "container": 25565, "protocol": "tcp" }], + "params": { + "SERVER_TYPE": "PAPER", + "MAX_PLAYERS": "20" + } +} +``` + +### PUT /api/instances/:id/config + +- 说明:更新容器配置(参数 + 端口映射,通过重建容器实现,容器必须处于 Stopped) +- 状态码: + - 成功:`200` + - 失败:`400`(ID非法/参数非法)、`409`(容器未停止)、`500` +- 请求参数: + +```json +{ + "ports": [{ "host": 25575, "container": 25565, "protocol": "tcp" }], + "params": { + "SERVER_TYPE": "FABRIC", + "MAX_PLAYERS": "50" + } +} +``` + +- 返回结果: + +```json +{ "status": "success", "container_id": "new_container_id" } +``` + +### GET /api/ws/events (WebSocket) + +- 说明:建立 WebSocket 连接,实时接收容器状态变更推送 +- 协议:WebSocket(HTTP Upgrade) +- 同源限制:仅支持同源连接(`Origin` 必须与请求 `Host` 一致),当前版本不支持跨域 WebSocket +- 消息格式(服务端 -> 客户端): + +```json +{ + "type": "instances_updated", + "data": [{ "container_id": "xxx", "name": "xxx", "status": "Running" }] +} +``` + +- 触发时机:任一托管容器状态发生变化(`start` / `stop` / `die` / `destroy` / `kill`) +- 降级方案:客户端连接失败时应回退到轮询 `GET /api/instances` + +### GET /api/ws/console/:id (WebSocket) + +- 说明:建立 WebSocket 连接,双向桥接容器主进程的 stdin/stdout/stderr +- 协议:WebSocket(HTTP Upgrade) +- 前置条件:容器必须处于 Running 状态 +- 路径参数:`id`(容器 ID) +- 数据流向: + - 服务端 -> 客户端:容器 stdout/stderr 输出(Binary 帧) + - 客户端 -> 服务端:用户输入命令(Text/Binary 帧,原样写入容器 stdin) +- TTY 自适应: + - TTY 容器:直接转发输出流 + - 非 TTY 容器:服务端使用 Docker 多路复用解复用后再转发 +- 连接关闭时机:客户端断开、容器退出、服务端关闭连接 +- 失败行为:容器不存在或未运行时,服务端在升级后主动关闭连接并返回原因 diff --git a/docs/design-docs/instance_lifecycle.md b/docs/design-docs/instance_lifecycle.md new file mode 100644 index 0000000..8d85f7a --- /dev/null +++ b/docs/design-docs/instance_lifecycle.md @@ -0,0 +1,78 @@ +# 容器实例生命周期 + +## 数据结构 + +### 后端 + +```go +type Instance struct { + // 映射的 Docker 容器唯一标识 + ContainerID string `json:"container_id"` + // 服务端名称 + Name string `json:"name"` + // 来源游戏模板 ID(持久化在 instances 表,用于配置编辑时回查模板) + GameID string `json:"game_id"` + // 当前运行态 + Status string `json:"status"` +} +``` + +### 数据库 + +- `instances` 表字段: +- `container_id`(主键) +- `name`(唯一) +- `status` +- `game_id` +- `created_at` + +## 接口 + +[../api/contracts.md](../api/contracts.md) + +## 状态流转 + +```mermaid +stateDiagram-v2 + [*] --> Stopped: Create + Stopped --> Running: Start + Running --> Stopped: Stop + Stopped --> [*]: Delete +``` + +## 创建配置注入 + +- 端口映射来源:模板 `container.ports`,创建容器时写入 Docker `ExposedPorts` 与 `PortBindings`。 +- 卷挂载来源:模板 `container.volumes`,卷名规则为 `minedock-{instanceName}-{volumeName}`。 +- 启动命令来源:若模板配置了 `container.command` 则覆盖镜像命令;否则使用镜像默认 `ENTRYPOINT/CMD`。 + +## 在线配置修改 + +- 修改范围:允许编辑模板 `params` 定义的用户参数,以及模板端口映射对应的宿主机端口;模板固定环境变量 `container.env` 不可直接修改。 +- 变更方式:通过重建容器应用新配置,流程为 `Inspect -> Remove(old) -> Create(new env + port bindings)`。 +- 状态约束:仅允许在 Stopped 状态下执行配置更新,运行中返回冲突错误。 +- 生效方式:更新完成后容器保持 Stopped,需用户手动启动使配置生效。 +- 保留策略:复用原有卷挂载与端口映射,避免卷数据丢失。 +- 标识变化:重建后 `container_id` 会变化,前端需跳转到新的详情路由。 + +## 一致性策略 + +- 事实来源:Docker Daemon。 +- 缓存来源:SQLite(用于实例名、状态缓存和恢复)。 +- 收敛机制(主路径):监听 Docker Events,在容器状态变化后通过 WebSocket 推送最新实例快照。 +- 收敛机制(降级路径):`ListInstances` 按需对账 Docker -> SQLite;当前端 WebSocket 不可用时回退到轮询接口。 + +## 控制台交互 + +- 路由:前端通过 `/instances/:id` 进入容器详情页,页面建立 `GET /api/ws/console/:id` WebSocket。 +- 输入链路:浏览器 WebSocket 输入 -> 后端 ConsoleHandler -> Docker Attach stdin。 +- 输出链路:Docker Attach stdout/stderr -> 后端 ConsoleHandler -> 浏览器 WebSocket -> xterm.js。 +- 运行态约束:仅 Running 容器允许 Attach;容器停止后连接会断开,页面提示用户重新启动后再连接。 +- TTY 差异:TTY 容器输出直接透传;非 TTY 容器需先做 stdout/stderr 解复用再推送。 + +## 失败与回滚策略 + +- 创建流程:若数据库保存失败,立即强制删除刚创建容器。 +- 启停流程:Docker 操作成功但 DB 更新失败时,接口返回错误;后续列表刷新会将状态重新收敛。 +- 删除流程:Docker 删除成功后若 DB 删除失败,后续列表会因 Docker 不存在而清理状态。 +- 数据卷策略:删除实例不会自动清理 Docker 卷,卷数据默认保留,需手动回收或后续能力支持。 diff --git a/docs/domain_instance_lifecycle.md b/docs/domain_instance_lifecycle.md deleted file mode 100644 index e8de601..0000000 --- a/docs/domain_instance_lifecycle.md +++ /dev/null @@ -1,35 +0,0 @@ -# 容器实例生命周期 - -## 1. 说明 -该领域负责容器实例的完整生命周期管理: -- 查询当前实例列表 -- 创建新实例(不自动启动) -- 启动指定实例 -- 停止指定实例 -- 彻底删除指定实例(要求先停止) - -该领域是当前系统的核心业务能力。 - -## 2. 数据结构 - -### Instance(后端) -```go -type Instance struct { - // 映射的 Docker 容器唯一标识 - ContainerID string `json:"container_id"` - // 服务端名称 (比如 "测试服1号") - Name string `json:"name"` - // 当前运行态 (Running, Stopped) - Status string `json:"status"` -} -``` - -## 3. HTTP 接口 -具体的接口定义请参考 [02_API_Contracts.md](02_API_Contracts.md#1-容器实例生命周期接口-instance-lifecycle)。 - -## 4. 状态流转(当前实现语义) -- 创建成功后:实例进入 `Stopped`,并写入 SQLite 持久化存储。 -- 启动成功后:实例状态更新为 `Running`。 -- 停止成功后:实例状态更新为 `Stopped`。 -- 删除成功后:销毁 Docker 容器,同时从 SQLite 中删除该实例记录;若实例仍为 `Running`,删除请求会被拒绝(需先停止)。 -- 列表查询:以后端从 Docker 引擎读取的受管容器为准,并同步更新 SQLite 中的实例状态信息。 diff --git a/docs/exec-plans/.markdownlint.json b/docs/exec-plans/.markdownlint.json new file mode 100644 index 0000000..66621ea --- /dev/null +++ b/docs/exec-plans/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "MD013": false, + "MD028": false +} \ No newline at end of file diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md new file mode 100644 index 0000000..4419b2f --- /dev/null +++ b/docs/exec-plans/README.md @@ -0,0 +1,82 @@ +# 执行计划目录说明 + +本目录用于存放计划文档 + +- `completed` 存放已经完成的计划 +- `active` 存放当前正在进行的计划 + +## 命名规范 + +`YYYYMMDD-主题关键字.md` + +示例: + +- `20260329-instance-restart.md` +- `20260330-backup-snapshot.md` + +## 内容格式 + +执行计划内容建议遵循以下标准结构模块: + +```markdown +# [目标描述 / Goal Description] + +提供问题的简要描述,相关背景上下文,以及此更改要实现的目标。 + +## 需要评审的内容 (User Review Required) + +记录任何需要用户审查或反馈的内容,例如破坏性更改或重大且重要的设计决策。可以使用 GitHub 提示块(如 `> [!IMPORTANT]` 或 `> [!WARNING]`)高亮强调。 + +## 拟定更改 (Proposed Changes) + +按组件名称(如具体的 package、功能区或依赖层)将要修改的文件分组归类,并按逻辑顺序(例如先依赖后实现)展开描述。建议使用水平分割线来区分不同组件。 + +### [组件名称 / Component Name] + +简述该组件的变化,并按具体文件拆分说明。 +对于具体的变更文件,使用 `[NEW]` 和 `[DELETE]` 标签标明文件的新旧状态,例如: + +#### [MODIFY] 修改的文件名.go (及文件相对路径) + +#### [NEW] 新增的文件名.go + +#### [DELETE] 删除的文件名.go + +## 执行步骤 (Execution Steps) + +将完整的计划拆分为可执行的、细化的步骤列表,使用复选框来追踪进度状态: + +- `[ ]` 待办 (Uncompleted) +- `[/]` 进行中 (In Progress) +- `[x]` 已完成 (Completed) + +推荐使用嵌套列表来拆分复杂的步骤,例如: + +- [ ] 核心模型层设计 + - [ ] 定义 `User` 结构体 + - [ ] 编写对应的单元测试 +- [ ] API 路由注册 +- [/] 当前正在开发的功能 +- [x] 已经处理完毕的事项 + +## 待确认的疑问 (Open Questions) + +记录任何需要澄清的业务逻辑或者架构设计疑问,这些疑问通常会直接影响执行计划的方案。可以使用 GitHub 提示块强调。 + +## 验证计划 (Verification Plan) + +简述你将如何验证上述更改达到了预期效果。 + +### 自动化测试 (Automated Tests) + +- 将要执行的确切测试命令 (如 `go test ./...`)。 + +### 手动验证 (Manual Verification) + +- 如果涉及无法自动化测试的部分(如 UI 调整),提供手动的步骤和期望结果的说明。 +``` + +## TODO 索引生成 + +- 运行 `task docs:todo` 可从仓库注释中的 `TODO: 描述` 自动生成 `docs/exec-plans/TODO.md`。 +- 生成文件用于集中追踪技术债与改进事项,建议在提交前执行一次。 diff --git a/docs/exec-plans/TODO.md b/docs/exec-plans/TODO.md new file mode 100644 index 0000000..a763c60 --- /dev/null +++ b/docs/exec-plans/TODO.md @@ -0,0 +1,20 @@ +# TODO Index + +Generated date: 2026-03-30 +Source pattern: TODO: description + +## Summary + +- Total TODO items: 9 + +## Items + +- [ ] 抽取 start/stop/delete 的公共处理流程。 (backend/internal/api/handlers.go:82) +- [ ] 增加统一的编码错误日志,提升可观测性。 (backend/internal/api/handlers.go:156) +- [ ] 让 Docker 创建与 SQLite 保存具备原子性。 (backend/internal/service/docker_service.go:47) +- [ ] 将逐条 Save 改为批量或事务化同步路径。 (backend/internal/service/docker_service.go:111) +- [ ] 增加并发写保护,避免最后写入覆盖前写入。 (backend/internal/service/docker_service.go:112) +- [ ] 在不影响列表返回的前提下上报同步失败。 (backend/internal/service/docker_service.go:139) +- [ ] 将该函数拆分为存储读取与 Docker 对账两个辅助函数。 (backend/internal/service/docker_service.go:168) +- [ ] 当写入吞吐成为瓶颈时,重新评估连接池策略。 (backend/internal/store/sqlite.go:39) +- [ ] 用 SQLite 错误码替代文本匹配判断。 (backend/internal/store/sqlite.go:162) diff --git a/docs/exec-plans/active/20260402-container-console.md b/docs/exec-plans/active/20260402-container-console.md new file mode 100644 index 0000000..7bcdea0 --- /dev/null +++ b/docs/exec-plans/active/20260402-container-console.md @@ -0,0 +1,444 @@ +# 容器详情页 Web 控制台(实时日志 + 交互式命令) + +## 背景 + +当前 MineDock 的容器列表页仅展示容器卡片(名称、状态、启停按钮),用户无法查看容器的实时输出日志,也无法向运行中的容器发送命令(如 Minecraft 的 `/op`、`/whitelist` 等服务器指令)。对于游戏服务器管理场景,控制台是核心运维入口。 + +本计划实现: + +1. 点击容器卡片后进入 **容器详情页**(新路由 `/instances/:id`) +2. 详情页包含一个 **Web 终端**(基于 xterm.js),实时展示容器主进程的 stdout/stderr 输出 +3. 终端下方支持 **交互式命令输入**,用户输入的内容通过 WebSocket 写入容器的 stdin +4. 后端通过 Docker SDK `ContainerAttach` 将容器的 stdin/stdout 双向桥接到 WebSocket + +## 需要评审的内容 + +> [!IMPORTANT] +> **Attach vs Exec 方案选型** +> +> Docker 提供两种方式与容器交互: +> +> | 方案 | 说明 | 适用场景 | +> | ------------------------------------ | ---------------------------------- | -------------------------------------------------- | +> | **ContainerAttach** | 连接到容器主进程的 stdin/stdout | 游戏服务器控制台(如 Minecraft 的 server console) | +> | **ContainerExecCreate + ExecAttach** | 在容器中创建新进程(如 `/bin/sh`) | 调试、执行一次性命令 | +> +> 本计划采用 **ContainerAttach** 方式。原因: +> +> - 游戏服务器(如 itzg/minecraft-server)的主进程本身就是服务端控制台,接受 stdin 命令 +> - Attach 可以看到服务器启动以来的完整日志流,不需要二次查询历史日志 +> - 无需容器内安装 shell,兼容精简镜像 + +> [!IMPORTANT] +> **前端终端库选型** +> +> 采用 **xterm.js**(`@xterm/xterm`),这是 VS Code 内置终端使用的库: +> +> - 支持 ANSI 转义序列(颜色、光标控制) +> - 内置 WebSocket attach addon(`@xterm/addon-attach`) +> - `@xterm/addon-fit` 自适应容器尺寸 +> - NPM 生态成熟,与 Vue 3 集成简单 + +> [!WARNING] +> **仅运行中的容器可以 Attach** +> +> Docker `ContainerAttach` 要求容器处于 Running 状态。如果容器已停止,后端返回错误,前端展示"容器未运行"提示。用户需先启动容器再查看控制台。 + +> [!IMPORTANT] +> **WebSocket 路径设计** +> +> 新增 WebSocket 端点 `GET /api/ws/console/{id}`,其中 `{id}` 为容器 ID。每个 WebSocket 连接对应一个 Docker Attach 会话。连接关闭时自动释放 Docker Attach 资源。 +> +> 该端点与现有的 `GET /api/ws/events`(事件广播)相互独立。 + +## 前端交互流程 + +```mermaid +sequenceDiagram + participant U as 用户 + participant FE as 前端 + participant BE as 后端 + participant D as Docker Daemon + + Note over U: 在容器列表点击卡片 + U->>FE: 点击容器卡片 + FE->>FE: 路由跳转 /instances/:id + + Note over FE: 详情页挂载 + FE->>BE: WebSocket /api/ws/console/{id} + BE->>D: ContainerAttach(stdin + stdout + stream) + D-->>BE: HijackedResponse (双向流) + BE-->>FE: WebSocket 连接建立 + + Note over D,FE: 实时日志推流 + D-->>BE: 容器 stdout 输出 + BE-->>FE: WebSocket 转发 + FE->>FE: xterm.js 渲染日志 + + Note over U: 输入命令 + U->>FE: 在终端输入 "/op player1" + FE->>BE: WebSocket 发送命令文本 + BE->>D: 写入容器 stdin + D-->>BE: 命令执行结果 stdout + BE-->>FE: WebSocket 转发 + FE->>FE: xterm.js 渲染输出 + + Note over U: 离开页面 + FE->>BE: WebSocket 关闭 + BE->>D: 关闭 Attach 流 +``` + +## 拟定更改 + +### 后端 Service 层 + +#### [NEW] console_service.go (`backend/internal/service/console_service.go`) + +新增 `ConsoleService`,封装 Docker Attach 的双向流桥接逻辑: + +```go +// ConsoleService 封装容器控制台的 Attach 逻辑。 +type ConsoleService struct { + cli *client.Client +} + +// NewConsoleService 创建 ConsoleService。 +func NewConsoleService(cli *client.Client) *ConsoleService { ... } + +// Attach 连接到运行中容器的主进程 stdin/stdout/stderr。 +// 返回的 HijackedResponse 包含双向流,调用方负责关闭。 +// 容器未运行时返回错误。 +func (s *ConsoleService) Attach(ctx context.Context, containerID string) (types.HijackedResponse, error) { + // 1. ContainerInspect 检查容器是否 Running + // 2. ContainerAttach(ctx, containerID, container.AttachOptions{ + // Stream: true, + // Stdin: true, + // Stdout: true, + // Stderr: true, + // }) +} +``` + +> [!NOTE] +> `ConsoleService` 故意不依赖 `InstanceStore`。Attach 操作只需要 Docker client 和容器 ID,不涉及业务状态变更。保持职责单一。 + +--- + +### 后端 API 层 + +#### [NEW] console_handler.go (`backend/internal/api/console_handler.go`) + +新增 WebSocket Handler,负责 HTTP → WebSocket 升级,然后在 WebSocket 和 Docker Attach 流之间做双向 pipe: + +```go +// ContainerConsole 定义控制台 Handler 依赖的 Attach 能力。 +type ContainerConsole interface { + Attach(ctx context.Context, containerID string) (types.HijackedResponse, error) +} + +// ConsoleHandler 暴露容器控制台 WebSocket 处理器。 +type ConsoleHandler struct { + console ContainerConsole +} + +// NewConsoleHandler 创建 ConsoleHandler。 +func NewConsoleHandler(c ContainerConsole) *ConsoleHandler { ... } + +// HandleConsole 处理 GET /api/ws/console/{id}。 +// 将 HTTP 升级为 WebSocket,然后在 WebSocket ↔ Docker Attach 之间双向转发数据。 +func (h *ConsoleHandler) HandleConsole(w http.ResponseWriter, r *http.Request) { + // 1. 解析路径中的容器 ID + // 2. websocket.Accept() 升级连接 + // 3. 调用 console.Attach() 获取 Docker 双向流 + // 4. 启动两个 goroutine: + // - Docker stdout → WebSocket write(容器输出推送给前端) + // - WebSocket read → Docker stdin(用户输入写入容器) + // 5. 任一方向断开时关闭另一方,清理资源 +} +``` + +**双向桥接核心逻辑:** + +```go +func (h *ConsoleHandler) bridgeLoop(ctx context.Context, conn *websocket.Conn, hijacked types.HijackedResponse) { + defer hijacked.Close() + + done := make(chan struct{}) + + // Docker stdout → WebSocket + go func() { + defer close(done) + buf := make([]byte, 4096) + for { + n, err := hijacked.Reader.Read(buf) + if n > 0 { + writeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + _ = conn.Write(writeCtx, websocket.MessageBinary, buf[:n]) + cancel() + } + if err != nil { + return + } + } + }() + + // WebSocket → Docker stdin + go func() { + for { + _, data, err := conn.Read(ctx) + if err != nil { + return + } + _, _ = hijacked.Conn.Write(data) + } + }() + + <-done +} +``` + +#### [MODIFY] router.go (`backend/internal/api/router.go`) + +- `NewRouter` 签名新增 `*ConsoleHandler` 参数 +- 注册新路由:`GET /api/ws/console/{id}`(与 `/api/ws/events` 同级,跳过 CORS 中间件) + +--- + +### 后端入口 + +#### [MODIFY] main.go + +- 创建 `ConsoleService`,注入 Docker client +- 创建 `ConsoleHandler`,注入 `ConsoleService` +- 更新 `NewRouter` 调用 + +```go +consoleSvc := service.NewConsoleService(cli) +consoleHandler := api.NewConsoleHandler(consoleSvc) +router := api.NewRouter(h, gameHandler, wsHandler, consoleHandler) +``` + +--- + +### 前端依赖 + +#### [MODIFY] package.json + +新增依赖: + +```json +{ + "dependencies": { + "@xterm/xterm": "^5.x", + "@xterm/addon-fit": "^0.x", + "@xterm/addon-attach": "^0.x" + } +} +``` + +--- + +### 前端 API 层 + +#### [MODIFY] index.ts (`frontend/src/api/index.ts`) + +新增 WebSocket URL 构造函数: + +```typescript +// 构造容器控制台 WebSocket URL +export function consoleWsUrl(containerId: string): string { + return `${WS_BASE_URL}/ws/console/${encodeURIComponent(containerId)}`; +} +``` + +--- + +### 前端 Composable 层 + +#### [NEW] useConsole.ts (`frontend/src/composables/useConsole.ts`) + +新增 Composable,封装 xterm.js + WebSocket 的生命周期管理: + +```typescript +// useConsole 管理容器控制台的 xterm.js 实例和 WebSocket 连接。 +export function useConsole(containerId: Ref): { + /** xterm.js 挂载目标元素 */ + terminalRef: Ref; + /** WebSocket 连接状态 */ + connected: Ref; + /** 错误信息 */ + error: Ref; + /** 初始化终端和 WebSocket(在 onMounted 中调用) */ + init: () => void; + /** 销毁终端和 WebSocket(在 onUnmounted 中调用) */ + dispose: () => void; +}; +``` + +核心逻辑: + +- **init**:创建 `Terminal` 实例 → 调用 `term.open(el)` → `FitAddon.fit()` → 建立 WebSocket → 使用 `AttachAddon` 桥接 +- **dispose**:关闭 WebSocket → 销毁 Terminal +- **自适应尺寸**:监听 `ResizeObserver`,调用 `FitAddon.fit()` +- **连接断开**:设置 `connected = false`,显示断线提示(不自动重连——用户刷新或重新进入详情页即可) + +--- + +### 前端视图层 + +#### [NEW] InstanceDetail.vue (`frontend/src/views/InstanceDetail.vue`) + +新增容器详情页,主体结构: + +```text +┌─────────────────────────────────────────┐ +│ ← 返回 容器名称 状态指示器 │ ← 顶部导航栏 +├─────────────────────────────────────────┤ +│ │ +│ │ +│ xterm.js 终端区域 │ ← 实时日志 + 命令输出 +│ (黑底绿字/Create 主题风格) │ +│ │ +│ │ +├─────────────────────────────────────────┤ +│ 连接状态指示 (绿点/灰点) │ ← 底部状态栏 +└─────────────────────────────────────────┘ +``` + +组件职责: + +- 从路由参数获取 `containerId` +- 调用 `useConsole(containerId)` 管理终端 +- 展示容器名称和状态(从 `useContainerStore` 获取) +- 返回按钮跳回容器列表页 +- 容器未运行时展示提示信息,不初始化终端 + +--- + +### 前端路由 + +#### [MODIFY] router/index.ts + +新增路由: + +```typescript +{ + path: "/instances/:id", + name: "InstanceDetail", + component: () => import("../views/InstanceDetail.vue"), + props: true, +} +``` + +--- + +### 前端容器列表页 + +#### [MODIFY] ContainerList.vue (`frontend/src/views/ContainerList.vue`) + +- 容器卡片增加点击事件,点击后跳转 `/instances/:id` +- 使用 `router.push({ name: 'InstanceDetail', params: { id: container.container_id } })` + +--- + +### 文档 + +#### [MODIFY] contracts.md (`docs/api/contracts.md`) + +新增 WebSocket 接口文档: + +```markdown +### GET /api/ws/console/:id (WebSocket) + +- 说明:建立 WebSocket 连接,双向桥接容器主进程的 stdin/stdout +- 协议:WebSocket(HTTP Upgrade) +- 前置条件:容器必须处于 Running 状态 +- 数据流向: + - 服务端 → 客户端:容器 stdout/stderr 输出(二进制帧) + - 客户端 → 服务端:用户输入指令(文本帧,自动写入容器 stdin) +- 连接关闭时机:客户端断开 / 容器停止 / 服务端关闭 +- 错误情况:容器不存在或未运行时,WebSocket 升级后立即关闭并附带错误原因 +``` + +#### [MODIFY] instance_lifecycle.md (`docs/design-docs/instance_lifecycle.md`) + +补充控制台交互说明。 + +#### [MODIFY] frontend.md (`docs/standards/frontend.md`) + +在路由部分新增: + +```markdown +- `/instances/:id` 映射容器详情页(`InstanceDetail.vue`) +``` + +--- + +## 执行步骤 + +- [ ] 前端依赖 + - [ ] 安装 `@xterm/xterm`、`@xterm/addon-fit`、`@xterm/addon-attach` +- [ ] 后端 Service 层 + - [ ] 新建 `backend/internal/service/console_service.go`,实现 `ConsoleService` + - [ ] Attach 方法:检查容器运行状态 → 调用 Docker `ContainerAttach` +- [ ] 后端 API 层 + - [ ] 新建 `backend/internal/api/console_handler.go`,实现 `ConsoleHandler` + - [ ] WebSocket 升级 → Docker Attach → 双向 pipe + - [ ] 容器 stdout → WebSocket write goroutine + - [ ] WebSocket read → Docker stdin goroutine + - [ ] 连接断开清理逻辑 + - [ ] 修改 `router.go`:注册 `GET /api/ws/console/{id}`,更新 `NewRouter` 签名 +- [ ] 后端入口 + - [ ] 修改 `main.go`:创建 ConsoleService/ConsoleHandler、注入路由 +- [ ] 前端 API 层 + - [ ] 修改 `api/index.ts`:新增 `consoleWsUrl()` 函数 +- [ ] 前端 Composable 层 + - [ ] 新建 `composables/useConsole.ts`:xterm.js + WebSocket 生命周期管理 +- [ ] 前端视图层 + - [ ] 新建 `views/InstanceDetail.vue`:容器详情页 + xterm.js 终端 + - [ ] 修改 `views/ContainerList.vue`:容器卡片增加点击跳转 +- [ ] 前端路由 + - [ ] 修改 `router/index.ts`:注册 `/instances/:id` 路由 +- [ ] 后端测试 + - [ ] 编写 `ConsoleService.Attach` 单元测试(容器运行/容器停止/容器不存在) + - [ ] 运行 `task backend:test && task backend:vet && task backend:lint` +- [ ] 前端检查 + - [ ] 运行 `npm run lint` +- [ ] 文档更新 + - [ ] 修改 `docs/api/contracts.md`:新增控制台 WebSocket 文档 + - [ ] 修改 `docs/standards/frontend.md`:新增路由说明 + - [ ] 修改 `docs/design-docs/instance_lifecycle.md`:补充控制台说明 + +## 已确认的决策 + +- ✅ **历史日志回看**:本期不实现。用户进入详情页后只能看到实时输出流(attach 之后的内容)。历史日志回看(进入页面时先拉取最近 N 行)留作后续增强。 + +> [!NOTE] +> **TTY 模式自适应(设计说明)** +> +> 游戏服务器镜像(如 itzg/minecraft-server)通常以 TTY 模式运行。Attach 时需要匹配容器的 TTY 设置: +> +> - 如果容器有 TTY(`Config.Tty == true`):输出流不需要 `stdcopy` 解复用,直接转发 +> - 如果容器无 TTY:stdout/stderr 是 Docker 多路复用格式,需要用 `stdcopy.StdCopy` 解复用 +> +> 本计划在 Attach 前通过 `ContainerInspect` 检测 TTY 设置,自适应处理。 + +## 验证计划 + +### 自动化测试 + +- `task backend:test` — 覆盖: + - `ConsoleService.Attach`:容器运行时成功、容器停止时报错、容器不存在时报错 +- `task backend:vet && task backend:lint` +- 前端:`npm run lint` + +### 手动验证 + +- 创建并启动一个 Minecraft Java 实例 +- 在容器列表页点击容器卡片,验证路由跳转到 `/instances/:id` +- 验证 xterm.js 终端渲染,WebSocket 连接指示器显示绿色 +- 验证容器启动日志实时滚动输出到终端 +- 在终端输入 Minecraft 服务器命令(如 `list`),验证命令被发送且返回结果显示在终端 +- 停止容器,验证 WebSocket 自动断开,终端显示断线提示 +- 对已停止的容器进入详情页,验证展示"容器未运行"提示而非空白终端 +- 浏览器调整窗口大小,验证 xterm.js 终端自适应 resize diff --git a/docs/exec-plans/active/20260402-container-ports-volumes.md b/docs/exec-plans/active/20260402-container-ports-volumes.md new file mode 100644 index 0000000..799a72a --- /dev/null +++ b/docs/exec-plans/active/20260402-container-ports-volumes.md @@ -0,0 +1,229 @@ +# 创建容器时支持端口映射与卷挂载 + +## 背景 + +当前 MineDock 的 `CreateInstance` 虽然已经从 YAML 模板中加载了 `ports` 和 `volumes` 配置数据(数据结构 `PortMapping` / `VolumeMount` 已就绪),但在实际调用 Docker SDK `ContainerCreate` 时仅传入了 `Image`、`Env`、`Labels`(参见 `docker_service.go:81-90`),**端口映射和卷挂载均未生效**。 + +这意味着: + +- 创建的容器没有暴露端口,宿主机无法访问游戏服务器 +- 容器数据保存在匿名层,删除容器后世界存档等数据丢失 +- 同时 `Cmd` 字段硬编码为 `["sleep", "3600"]`(开发阶段占位),真正的游戏服务器镜像有自己的 `ENTRYPOINT`,不需要覆盖 `Cmd` + +本计划将模板中已定义的 `ports` 和 `volumes` 配置实际传入 Docker SDK,使创建的容器真正具备网络可达和数据持久化能力。 + +## 需要评审的内容 + +> [!IMPORTANT] +> **卷命名策略** +> +> 模板 YAML 中 `volumes[].name` 定义的是语义标识(如 `server-data`),需要转换为 Docker 卷名。 +> 采用的命名规则为:`minedock-{instanceName}-{volumeName}`。 +> +> 例如:实例名 `my-server`,模板卷名 `server-data` → Docker 卷名 `minedock-my-server-server-data`。 +> +> 这确保每个实例拥有独立卷,卷名可读且可追溯到实例。 + +> [!IMPORTANT] +> **移除硬编码 Cmd** +> +> 当前 `ContainerCreate` 传入 `Cmd: []string{"sleep", "3600"}`,这会覆盖镜像自身的 `ENTRYPOINT` / `CMD`,导致游戏服务器无法正常启动。本计划将移除该硬编码,让容器使用镜像默认的启动命令。 +> +> 如果模板中定义了 `container.command`,则使用模板的命令覆盖;否则不传 `Cmd`。 + +> [!WARNING] +> **端口冲突** +> +> 当宿主机端口已被占用时,Docker 会返回创建/启动错误。当前版本不做端口冲突的预检查,依赖 Docker 的原生错误返回。后续可考虑增加端口可用性预检和用户自定义端口覆盖。 + +## 拟定更改 + +### 后端 Service 层 + +#### [MODIFY] docker_service.go (`backend/internal/service/docker_service.go`) + +这是本次变更的核心文件。需要修改 `CreateInstance` 方法,将模板中的 `ports` 和 `volumes` 转换为 Docker SDK 的数据结构并传入 `ContainerCreate`。 + +##### 1. 端口映射 + +将模板 `[]PortMapping` 转换为 Docker SDK 所需的两个结构: + +- `container.Config.ExposedPorts`(`nat.PortSet`)— 声明容器暴露的端口 +- `container.HostConfig.PortBindings`(`nat.PortMap`)— 映射宿主机端口 + +```go +// buildPortBindings 将模板端口映射转换为 Docker 端口配置。 +func buildPortBindings(ports []model.PortMapping) (nat.PortSet, nat.PortMap) { + exposedPorts := nat.PortSet{} + portBindings := nat.PortMap{} + + for _, p := range ports { + protocol := strings.ToLower(strings.TrimSpace(p.Protocol)) + if protocol == "" { + protocol = "tcp" + } + containerPort, _ := nat.NewPort(protocol, strconv.Itoa(p.Container)) + exposedPorts[containerPort] = struct{}{} + portBindings[containerPort] = []nat.PortBinding{ + {HostPort: strconv.Itoa(p.Host)}, + } + } + + return exposedPorts, portBindings +} +``` + +##### 2. 卷挂载 + +将模板 `[]VolumeMount` 转换为 Docker SDK 所需的结构: + +- `container.HostConfig.Binds`(`[]string`)— 格式为 `volumeName:containerPath[:ro]` + +卷名使用 `minedock-{instanceName}-{volumeName}` 格式,确保每个实例的卷相互隔离。 + +```go +// buildVolumeBinds 将模板卷配置转换为 Docker Binds 列表。 +// 卷名格式:minedock-{instanceName}-{volumeName} +func buildVolumeBinds(instanceName string, volumes []model.VolumeMount) []string { + if len(volumes) == 0 { + return nil + } + + binds := make([]string, 0, len(volumes)) + for _, v := range volumes { + dockerVolName := fmt.Sprintf("minedock-%s-%s", instanceName, v.Name) + bind := fmt.Sprintf("%s:%s", dockerVolName, v.ContainerPath) + if v.ReadOnly { + bind += ":ro" + } + binds = append(binds, bind) + } + return binds +} +``` + +##### 3. 修改 `CreateInstance` 方法 + +```go +// 变更前 +resp, err := s.cli.ContainerCreate(ctx, &container.Config{ + Image: imageRef, + Cmd: []string{"sleep", "3600"}, + Env: mapToDockerEnv(env), + Labels: map[string]string{...}, +}, nil, nil, nil, "") + +// 变更后 +exposedPorts, portBindings := buildPortBindings(tpl.Container.Ports) + +var cmd []string +if len(tpl.Container.Command) > 0 { + cmd = tpl.Container.Command +} + +resp, err := s.cli.ContainerCreate(ctx, &container.Config{ + Image: imageRef, + Cmd: cmd, + Env: mapToDockerEnv(env), + ExposedPorts: exposedPorts, + Labels: map[string]string{...}, +}, &container.HostConfig{ + PortBindings: portBindings, + Binds: buildVolumeBinds(name, tpl.Container.Volumes), +}, nil, nil, "") +``` + +##### 4. 新增 import + +```go +import ( + "github.com/docker/go-connections/nat" +) +``` + +> [!NOTE] +> `github.com/docker/go-connections/nat` 是 Docker SDK 的已有传递依赖,无需额外 `go get`。可通过 `go list -m all | grep go-connections` 确认。 + +--- + +### 后端 Service 层测试 + +#### [MODIFY] docker_service_test.go(如存在)或新增端口/卷相关测试 + +新增以下单元测试覆盖新增的辅助函数: + +- `TestBuildPortBindings`: + - 空端口列表 → 返回空 PortSet/PortMap + - TCP 端口映射 → 验证 ExposedPorts 键和 PortBindings 映射正确 + - UDP 端口映射 → 验证 protocol 正确拼接 + - 协议为空时默认 tcp +- `TestBuildVolumeBinds`: + - 空卷列表 → 返回 nil + - 正常卷 → 验证格式 `minedock-{name}-{volName}:/path` + - 只读卷 → 验证 `:ro` 后缀 + - 实例名含特殊字符时的卷名安全性 + +--- + +### 文档 + +#### [MODIFY] contracts.md (`docs/api/contracts.md`) + +无 API 签名变更。但 `POST /api/instances` 的行为语义增强:创建的容器现在会应用模板中定义的端口映射和卷挂载。可在说明中补充端口冲突时的 `500` 错误行为。 + +#### [MODIFY] instance_lifecycle.md (`docs/design-docs/instance_lifecycle.md`) + +在创建流程部分补充: + +- 端口映射来源:模板 `container.ports` +- 卷命名规则:`minedock-{instanceName}-{volumeName}` +- 删除实例时 Docker 卷**不会自动清理**(需用户手动或后续计划实现卷清理功能) + +--- + +## 执行步骤 + +- [ ] 确认依赖 + - [ ] 运行 `go list -m all | grep go-connections` 确认 `nat` 包已在依赖树中 +- [ ] 后端 Service 层 + - [ ] 新增 `buildPortBindings` 辅助函数,将 `[]PortMapping` → `nat.PortSet` + `nat.PortMap` + - [ ] 新增 `buildVolumeBinds` 辅助函数,将 `[]VolumeMount` → `[]string`(Docker Binds 格式) + - [ ] 修改 `CreateInstance`: + - [ ] 移除 `Cmd: []string{"sleep", "3600"}` 硬编码 + - [ ] 传入 `ExposedPorts` 到 `container.Config` + - [ ] 构建 `container.HostConfig`,传入 `PortBindings` 和 `Binds` + - [ ] 支持模板 `container.command` 覆盖(有值时传入 `Cmd`) + - [ ] 添加 `nat` 包的 import +- [ ] 后端测试 + - [ ] 编写 `TestBuildPortBindings`(空/TCP/UDP/默认协议) + - [ ] 编写 `TestBuildVolumeBinds`(空/正常/只读) + - [ ] 运行 `task backend:test` 确认全部通过 + - [ ] 运行 `task backend:vet && task backend:lint` +- [ ] 文档更新 + - [ ] 更新 `docs/design-docs/instance_lifecycle.md`:补充端口映射和卷挂载说明 + - [ ] 更新 `docs/api/contracts.md`:补充端口冲突错误说明(可选) + +## 已确认的决策 + +- ✅ **删除实例时不清理卷**:本期 `DeleteInstance` 不处理关联 Docker Volume 的清理,卷数据在删除实例后保留。用户可通过 `docker volume rm` 手动回收。后续计划提供"删除实例及数据"选项。 +- ✅ **端口冲突直接透传 Docker 错误**:当宿主机端口被占用时,Docker 返回的错误直接传递给前端。端口可用性预检查和用户自定义端口覆盖留作后续增强。 + +## 验证计划 + +### 自动化测试 + +- `task backend:test` — 覆盖: + - `buildPortBindings`:空列表、TCP 映射、UDP 映射、默认协议 + - `buildVolumeBinds`:空列表、正常挂载、只读挂载 + - 现有 `CreateInstance` 相关测试(确保不被破坏) +- `task backend:vet && task backend:lint` + +### 手动验证 + +- 启动后端,创建一个 Minecraft Java 实例 +- 验证容器端口映射:`docker inspect ` 查看 `PortBindings` 包含 `25565/tcp -> 25565` +- 验证卷挂载:`docker inspect ` 查看 `Binds` 包含 `minedock--server-data:/data` +- 验证 `docker volume ls` 中出现 `minedock--server-data` 卷 +- 启动容器后,验证 `docker port ` 输出正确端口映射 +- 停止并删除实例后,验证 Docker 卷仍然存在(本期不清理卷) +- 创建另一个实例使用相同端口,验证 Docker 返回端口冲突错误并正确传递给前端 diff --git a/docs/exec-plans/active/20260403-detail-tabs-config.md b/docs/exec-plans/active/20260403-detail-tabs-config.md new file mode 100644 index 0000000..e0aee95 --- /dev/null +++ b/docs/exec-plans/active/20260403-detail-tabs-config.md @@ -0,0 +1,533 @@ +# 容器详情页 Tab 导航 + 在线配置修改 + +## 背景 + +当前容器详情页 (`/instances/:id`) 只有控制台终端视图。用户需要停止容器、通过命令行或外部工具才能修改运行参数(环境变量),操作门槛较高。 + +本计划实现: + +1. 在容器详情页顶部添加 **Tab 导航栏**,包含"控制台"和"配置"两个标签页 +2. 控制台标签页保持现有 xterm.js 终端功能不变 +3. 新增**配置页**,展示容器当前生效的用户参数与端口映射,支持在线编辑并保存 +4. 保存配置后,更新容器环境变量(需要容器处于 **Stopped** 状态,用户手动重启后生效,不提供一键重启) + +## 需要评审的内容 + +> [!IMPORTANT] +> **配置修改的实现方式选型** +> +> Docker 不支持在运行中修改容器的环境变量。修改配置的标准做法是 **重建容器**: +> +> | 方案 | 说明 | 优缺点 | +> | ------------------------------ | ------------------------------------------------------------ | -------------------------------------------------- | +> | **Commit + Recreate** | 将当前容器 commit 为临时镜像,用新环境变量重建容器 | 保留容器内文件改动,但 commit 成本高且污染镜像列表 | +> | **Remove + Create (保留卷)** | 删除旧容器,用新环境变量创建新容器,挂载相同的 Volume | 轻量、符合 Docker 最佳实践;卷数据完整保留 | +> | **仅允许 Stopped 修改 + 重建** | 只有容器停止后才能修改配置,修改后删除旧容器并用相同参数重建 | 最安全,无需担心运行态中断 | +> +> 本计划采用 **方案 3 (仅允许 Stopped 修改 + 重建)**。原因: +> +> - 当前容器创建时已经将卷以 `minedock-{instanceName}-{volumeName}` 命名挂载,重建后可继续使用 +> - Stopped 状态下修改避免用户在不知情的情况下丢失运行态数据 +> - 实现最简洁,不引入 commit 等额外 Docker 操作 + +> [!WARNING] +> **重建容器会导致 container_id 变化** +> +> 由于是删除旧容器再创建新容器,container_id 会改变。需要: +> +> - 后端完成重建后返回新的 container_id +> - 前端路由跳转到新的 `/instances/:newId` +> - SQLite 中旧记录删除、新记录插入 + +> [!IMPORTANT] +> **配置项的数据来源** +> +> 容器环境变量有两个来源: +> +> 1. **模板固定环境变量** (`container.env`):如 `EULA=TRUE`,这些是系统级必须值,**不允许用户修改** +> 2. **用户可调参数** (`params`):如 `SERVER_TYPE`、`MAX_PLAYERS`,这些**允许用户修改** +> +> 配置页只展示和编辑 `params` 定义的参数,使用模板的 `params` 定义来渲染合适的输入控件(文本框、数字框、下拉框、开关等)。 +> +> 为了实现这一点,需要: +> +> - 后端提供一个接口,返回容器当前生效的用户参数值 +> - 后端提供一个接口,接收新参数值与端口映射并重建容器 + +> [!IMPORTANT] +> **端口映射编辑范围** +> +> 配置页允许编辑模板 `container.ports` 中定义端口对应的宿主机端口(host)。 +> +> - 容器端口(container)和协议(protocol)来自模板定义,不允许在页面中新增或删除 +> - 保存时后端将使用新端口映射重建容器 + +## 前端交互流程 + +```mermaid +sequenceDiagram + participant U as 用户 + participant FE as 前端 + participant BE as 后端 + participant D as Docker Daemon + + Note over U: 在详情页切换到"配置"标签 + + FE->>BE: GET /api/instances/:id/config + BE->>D: ContainerInspect (读取环境变量) + D-->>BE: 容器配置 + BE-->>FE: { game_id, params: {...}, status } + + Note over U: 修改参数并点击保存 + U->>FE: 修改 MAX_PLAYERS = 50 + FE->>BE: PUT /api/instances/:id/config { params: { MAX_PLAYERS: "50", ... } } + + Note over BE: 容器必须为 Stopped + BE->>D: ContainerInspect (获取旧配置) + BE->>D: ContainerRemove (删除旧容器) + BE->>D: ContainerCreate (新环境变量) + D-->>BE: 新 container_id + BE-->>FE: { status: "success", container_id: "new_id" } + FE->>FE: 路由跳转 /instances/new_id +``` + +## 拟定更改 + +### 后端 Model / 错误定义 + +#### [MODIFY] errors.go (`backend/internal/model/errors.go`) + +新增领域错误: + +```go +// ErrContainerNotStopped 表示容器必须处于停止状态才能执行此操作。 +var ErrContainerNotStopped = errors.New("container must be stopped to update config") +``` + +#### [MODIFY] instance.go (`backend/internal/model/instance.go`) + +扩展 Instance 结构体,增加 `GameID` 字段(用于查找原始模板): + +```go +type Instance struct { + ContainerID string `json:"container_id"` + Name string `json:"name"` + GameID string `json:"game_id"` + Status string `json:"status"` +} +``` + +> [!NOTE] +> `GameID` 当前已在创建时通过 Docker Label (`minedock.game_id`) 存储,但未持久化到 SQLite 的 `instances` 表。本计划需要在读取实例时从 Docker Label 中回填此字段。 + +--- + +### 后端 Store 层 + +#### [MODIFY] sqlite.go (`backend/internal/store/sqlite.go`) + +- `instances` 表新增 `game_id` 列(可选,允许空字符串兜底) +- `Save` 方法写入 `game_id` +- `Get` 方法读出 `game_id` + +--- + +### 后端 Service 层 + +#### [MODIFY] docker_service.go (`backend/internal/service/docker_service.go`) + +新增两个方法: + +```go +// GetInstanceConfig 读取容器当前生效的用户可调参数。 +// 通过 ContainerInspect 读取环境变量,结合模板 params 定义进行反向映射。 +func (s *DockerService) GetInstanceConfig(ctx context.Context, containerID string) (*InstanceConfig, error) { + // 1. ContainerInspect 获取容器 labels 和环境变量 + // 2. 从 labels 中取 game_id + // 3. 加载对应模板,获取 params 定义 + // 4. 遍历 params 定义,从容器环境变量中提取当前值 + // 5. 返回 InstanceConfig +} + +// UpdateInstanceConfig 通过重建容器来应用新的用户参数。 +// 要求容器处于 Stopped 状态。 +func (s *DockerService) UpdateInstanceConfig(ctx context.Context, containerID string, newParams map[string]string) (string, error) { + // 1. ContainerInspect 确认容器为 Stopped + // 2. 从 labels 取 game_id、name + // 3. 加载模板并 mergeTemplateEnv(tpl, newParams) 生成新环境变量 + // 4. 记录旧容器的 HostConfig (端口映射、卷挂载) ← 直接从 inspect 拿 + // 5. ContainerRemove 删除旧容器 + // 6. ContainerCreate 创建新容器(相同镜像、名称 label、卷、端口,新环境变量) + // 7. Store.Delete(oldID) + Store.Save(newInst) + // 8. 返回新的 container_id +} +``` + +新增返回结构体: + +```go +// InstanceConfig 描述容器当前生效的可编辑配置。 +type InstanceConfig struct { + GameID string `json:"game_id"` + Status string `json:"status"` + Params map[string]string `json:"params"` +} +``` + +> [!NOTE] +> `InstanceConfig.Params` 的 key 是模板 `params[].key`(如 `SERVER_TYPE`),value 是当前容器中该参数的实际值。这样前端拿到模板定义 + 当前值即可渲染编辑表单。 + +--- + +### 后端 API 层 + +#### [NEW] config_handler.go (`backend/internal/api/config_handler.go`) + +新增 ConfigHandler: + +```go +// InstanceConfigurator 定义配置 Handler 依赖的业务能力。 +type InstanceConfigurator interface { + GetInstanceConfig(ctx context.Context, containerID string) (*service.InstanceConfig, error) + UpdateInstanceConfig(ctx context.Context, containerID string, params map[string]string) (string, error) +} + +// ConfigHandler 暴露容器配置的 HTTP 处理器。 +type ConfigHandler struct { + cfg InstanceConfigurator +} + +// NewConfigHandler 创建 ConfigHandler。 +func NewConfigHandler(cfg InstanceConfigurator) *ConfigHandler { ... } + +// HandleGetConfig 处理 GET /api/instances/{id}/config。 +func (h *ConfigHandler) HandleGetConfig(w http.ResponseWriter, r *http.Request) { + // 1. 解析容器 ID + // 2. 调用 cfg.GetInstanceConfig() + // 3. JSON 返回 InstanceConfig +} + +// HandleUpdateConfig 处理 PUT /api/instances/{id}/config。 +func (h *ConfigHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Request) { + // 1. 解析容器 ID + JSON body { params: {...} } + // 2. 调用 cfg.UpdateInstanceConfig() + // 3. 返回 { status: "success", container_id: "new_id" } +} +``` + +#### [MODIFY] router.go (`backend/internal/api/router.go`) + +- `NewRouter` 签名新增 `*ConfigHandler` 参数 +- 注册路由: + - `GET /api/instances/{id}/config` + - `PUT /api/instances/{id}/config` + +--- + +### 后端入口 + +#### [MODIFY] main.go + +- 创建 `ConfigHandler`,注入 `DockerService`(它已实现 `InstanceConfigurator`) +- 更新 `NewRouter` 调用 + +```go +configHandler := api.NewConfigHandler(svc) +router := api.NewRouter(h, gameHandler, wsHandler, consoleHandler, configHandler) +``` + +--- + +### 前端 API 层 + +#### [MODIFY] index.ts (`frontend/src/api/index.ts`) + +新增接口: + +```typescript +export interface InstanceConfig { + game_id: string; + status: string; + params: Record; +} + +export interface UpdateConfigResponse { + status: string; + container_id: string; +} + +// 获取容器当前生效配置 +export function getInstanceConfig( + containerId: string, +): Promise { + return request( + `/instances/${encodeURIComponent(containerId)}/config`, + { method: "GET" }, + ); +} + +// 更新容器配置(重建容器) +export function updateInstanceConfig( + containerId: string, + params: Record, +): Promise { + return request( + `/instances/${encodeURIComponent(containerId)}/config`, + { + method: "PUT", + body: { params }, + }, + ); +} +``` + +--- + +### 前端视图层 + +#### [MODIFY] InstanceDetail.vue (`frontend/src/views/InstanceDetail.vue`) + +这是本次改动最大的文件。主要变更: + +1. 新增 Tab 导航栏,包含"控制台"和"配置"两个标签 +2. 根据当前 Tab 显示对应的内容区域 +3. Tab 切换时控制台的 xterm.js 连接保持/销毁逻辑 + +改造后的页面布局: + +```text +┌─────────────────────────────────────────────┐ +│ ← 返回 容器名称 状态指示器 │ ← 顶部导航栏 +├──────────┬──────────┬───────────────────────┤ +│ 控制台 │ 配置 │ │ ← Tab 导航栏 +├──────────┴──────────┴───────────────────────┤ +│ │ +│ Tab 内容区域 │ +│ (控制台: xterm.js / 配置: 参数编辑表单) │ +│ │ +└─────────────────────────────────────────────┘ +``` + +Tab 导航使用 `