Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .markdownlint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"default": true
}
48 changes: 31 additions & 17 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,42 @@ task dev # 一键启动前后端开发服务

## 待办清单

### 实例生命周期与资源调度
### 镜像与模板

- [x] 开服与停服
- [x] 实例的创建与删除
- [ ] 实例重启与强制结束进程
- [ ] 基于 Cgroups 的 CPU 与内存资源配额可视化配置
- [x] 静态镜像注册表(后端 JSON 配置)
- [x] 镜像市场前端页面(浏览、筛选、选用镜像)
- [x] 基于 YAML 的游戏模板规范设计
- [ ] 模板解析引擎(根据模板自动生成 Docker 启动参数)

### 实时交互与性能监控
### 实例生命周期

- [ ] Web 控制台标准输出日志实时推流
- [ ] 网页端在线交互指令无缝下发
- [ ] 容器 CPU、内存、网络 I/O 运行态数据实时图表展示
- [x] 容器基础生命周期:创建、删除、开启、停止
- [x] 容器运行状态实时同步
- [x] 创建容器时支持镜像选择
- [x] 创建容器时支持环境变量注入
- [x] 创建容器时支持 Volume 持久化目录挂载
- [ ] 创建容器时支持动态端口映射与防冲突检测
- [ ] 实例重启与强制终止
- [ ] 实例详情页(镜像信息、端口、环境变量、运行时长等)

### 数据灾备与持久化
### 资源管控与监控

- [ ] 游戏容器 Volume 持久化目录挂载映射
- [ ] 游戏存档/数据一键手动快照备份
- [ ] 基于 Cron 表达式的自动化定时备份任务
- [ ] 一键回档功能
- [ ] 基于 Cgroups 的 CPU 与内存资源配额配置
- [ ] 容器 CPU、内存、网络 I/O 运行态数据采集
- [ ] 前端实时折线图表展示硬件运行态数据

### 实时交互

### 在线文件管理与差异化配置
- [ ] Web 控制台标准输出日志实时推流
- [ ] 网页端在线交互指令下发
- [ ] 前端终端高亮渲染及日志自动滚动

### 运维与文件管理

- [ ] 可视化 Web 文件浏览器
- [ ] 游戏基础配置文件的在线文本编辑器
- [ ] 大文件 (Mod/插件) 上传及在线解压缩
- [ ] 游戏配置文件在线文本编辑器
- [ ] 大文件上传及在线解压缩
- [ ] 对接第三方社区 API,实现整合包一键解析与下载
- [ ] 游戏存档一键手动快照备份
- [ ] 基于 Cron 表达式的自动化定时备份
- [ ] 一键回档功能
23 changes: 23 additions & 0 deletions backend/games.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
2 changes: 2 additions & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
94 changes: 94 additions & 0 deletions backend/internal/api/config_handler.go
Original file line number Diff line number Diff line change
@@ -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})
}
163 changes: 163 additions & 0 deletions backend/internal/api/config_handler_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading