From 199f7b348aa539c1d701820ac1f17063d58cd7d0 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 28 Aug 2026 19:46:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E9=95=9C=E5=83=8F=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=AE=A1=E7=90=86=E4=B8=8E=E7=BD=91=E5=85=B3=E5=8C=96?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E7=BC=96=E6=8E=92=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +- AGENTS.md | 2 + README.md | 3 +- cmd/control-plane/hub.go | 492 ++++++++++++++++++ cmd/control-plane/hub_test.go | 672 +++++++++++++++++++++++++ cmd/control-plane/main.go | 57 +-- cmd/control-plane/main_test.go | 102 ++-- cmd/docker-gateway/main.go | 194 +++++-- cmd/docker-gateway/main_test.go | 298 ++++++++--- compose.yaml | 2 +- docs/architecture/container-control.md | 40 +- docs/deployment.md | 34 +- internal/hub/fingerprint.go | 135 +++++ internal/hub/migrations/002_hub.sql | 25 + internal/hub/store.go | 404 +++++++++++++++ internal/hub/store_test.go | 227 +++++++++ web/src/BrowserImageList.jsx | 176 +++++++ web/src/BrowserImageList.test.jsx | 67 +++ web/src/BrowserList.jsx | 276 ++++++++-- web/src/BrowserList.test.jsx | 66 ++- web/src/GatewayList.jsx | 145 ++++++ web/src/GatewayList.test.jsx | 54 ++ web/src/dataProvider.js | 76 ++- web/src/layout.jsx | 10 + web/src/main.jsx | 4 + web/tests/responsive.e2e.js | 5 + 26 files changed, 3243 insertions(+), 328 deletions(-) create mode 100644 cmd/control-plane/hub.go create mode 100644 cmd/control-plane/hub_test.go create mode 100644 internal/hub/fingerprint.go create mode 100644 internal/hub/migrations/002_hub.sql create mode 100644 internal/hub/store.go create mode 100644 internal/hub/store_test.go create mode 100644 web/src/BrowserImageList.jsx create mode 100644 web/src/BrowserImageList.test.jsx create mode 100644 web/src/GatewayList.jsx create mode 100644 web/src/GatewayList.test.jsx diff --git a/.env.example b/.env.example index 63ff781..cac55fe 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,3 @@ -# docker-gateway 容器以该 GID 加入容器内组,需与宿主机 docker 组 GID 一致,用于访问挂载的 /var/run/docker.sock -# 查询宿主机 GID: getent group docker | cut -d: -f3 DOCKER_GID=989 - -# 控制面宿主机端口;8080 被其它服务占用时改为其它端口 CREATORHUB_PORT=8082 +GATEWAY_TOKEN=dev-creatorhub-gateway-token diff --git a/AGENTS.md b/AGENTS.md index ac20a5a..3e09b2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ - 开发阶段仅关注业务功能:不实现访问限制、认证、网络隔离等安全策略,安全由用户自行把控。 +- 开发阶段不做数据兼容:数据库 schema 可随时破坏性重建,不编写迁移兼容、存量回填或双写代码;网关与环境数据均可删除重来。 + - 保持变更小而独立可评审,并附带覆盖该变更的最小相关检查。 - 使用下方已批准的技术栈;在栈内优先复用现有代码、标准库和平台原生能力,而非新增依赖或抽象。 - 在集成边界保持幂等性和向后兼容;文档化重试与失败行为。 diff --git a/README.md b/README.md index fd719a6..be6944b 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,10 @@ 需要 Docker Compose: ```bash -docker pull git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7 DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build ``` -打开 ;局域网内用宿主机 IP 访问同一端口。架构、API 契约、失败语义和 `docker.sock` 风险边界见 +打开 ;局域网内用宿主机 IP 访问同一端口。首次使用:在「网关管理」用 Compose 里的 `GATEWAY_TOKEN` 注册 `http://docker-gateway:8081`,在「镜像版本」添加可用的指纹浏览器镜像引用,即可创建环境;网关会在镜像缺失时自动拉取。架构、API 契约、失败语义和 `docker.sock` 风险边界见 [《浏览器容器控制面》](docs/architecture/container-control.md)。 最小验证: diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go new file mode 100644 index 0000000..e34b8ff --- /dev/null +++ b/cmd/control-plane/hub.go @@ -0,0 +1,492 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "git.ipao.vip/rogee/creator-hub/internal/hub" + "github.com/gofiber/fiber/v3" +) + +// hubStore 是控制面编排所需的存储能力;生产实现为 *hub.Store,测试使用内存桩。 +type hubStore interface { + CreateGateway(ctx context.Context, name, endpoint, token string) (hub.Gateway, error) + ListGateways(ctx context.Context) ([]hub.Gateway, error) + GetGateway(ctx context.Context, name string) (hub.Gateway, error) + DeleteGateway(ctx context.Context, name string) error + CreateImage(ctx context.Context, image hub.Image) error + UpdateImage(ctx context.Context, image hub.Image) error + ListImages(ctx context.Context, enabledOnly bool) ([]hub.Image, error) + DeleteImage(ctx context.Context, version string) error + ImageRef(ctx context.Context, version string) (string, error) + CreateEnv(ctx context.Context, env hub.Env) error + ListEnvs(ctx context.Context) ([]hub.Env, error) + GetEnv(ctx context.Context, alias string) (hub.Env, error) + UpgradeEnv(ctx context.Context, alias, version string) error + DeleteEnv(ctx context.Context, alias string) error +} + +const ( + gatewayLongTimeout = 11 * time.Minute // 覆盖网关侧最长 10 分钟的镜像拉取 + gatewayReconcileDelay = 100 * time.Millisecond + gatewayReconcileAttempts = 10 +) + +// gatewayCall 调用某个网关的 /v1 路由;ok 为 false 时 status/body 携带网关错误。 +func gatewayCall(ctx context.Context, target hub.Gateway, method, path string, body any, timeout time.Duration) (status int, responseBody []byte, err error) { + callCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var payload io.Reader + if body != nil { + encoded, marshalErr := json.Marshal(body) + if marshalErr != nil { + return 0, nil, marshalErr + } + payload = bytes.NewReader(encoded) + } + request, requestErr := http.NewRequestWithContext(callCtx, method, target.Endpoint+path, payload) + if requestErr != nil { + return 0, nil, requestErr + } + request.Header.Set("Authorization", "Bearer "+target.Token) + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + response, requestErr := http.DefaultClient.Do(request) + if requestErr != nil { + return 0, nil, requestErr + } + defer response.Body.Close() + responseBody, _ = io.ReadAll(io.LimitReader(response.Body, 1<<20)) + return response.StatusCode, responseBody, nil +} + +func gatewayCreatePayload(env hub.Env, imageRef string) map[string]any { + cmd := append(env.Fingerprint.Args(), "about:blank") + return map[string]any{ + "alias": env.Alias, + "name": env.Name, + "image": imageRef, + "cmd": cmd, + "volume": "creatorhub-profile-" + env.Alias, + } +} + +// gatewayFailure 将网关错误转换为对调用方可读的失败;4xx 保留原状态,其余一律 502。 +type gatewayFailure struct { + status int + message string +} + +func (e gatewayFailure) Error() string { return e.message } + +func gatewayRejected(status int, body []byte) error { + if status >= 400 && status < 500 { + return gatewayFailure{status: status, message: "gateway rejected: " + errorFromBody(body, status)} + } + return gatewayFailure{status: http.StatusBadGateway, message: fmt.Sprintf("gateway call failed with status %d", status)} +} + +func gatewayUnreachable(err error) error { + return gatewayFailure{status: http.StatusBadGateway, message: fmt.Sprintf("gateway unreachable: %v", err)} +} + +func reconcileGatewayBrowser(ctx context.Context, target hub.Gateway, alias string) (bool, error) { + var lastErr error + for attempt := 0; attempt < gatewayReconcileAttempts; attempt++ { + if attempt > 0 { + timer := time.NewTimer(gatewayReconcileDelay) + select { + case <-ctx.Done(): + timer.Stop() + return false, ctx.Err() + case <-timer.C: + } + } + status, body, err := gatewayCall(ctx, target, http.MethodGet, "/v1/browsers", nil, 30*time.Second) + if err != nil { + lastErr = err + continue + } + if status != http.StatusOK { + lastErr = gatewayRejected(status, body) + continue + } + lastErr = nil + var browsers []containerStatus + if err := json.Unmarshal(body, &browsers); err != nil { + lastErr = fmt.Errorf("decode gateway browser list: %w", err) + continue + } + for _, browser := range browsers { + if browser.Alias == alias { + return true, nil + } + } + } + return false, lastErr +} + +func errorFromBody(body []byte, status int) string { + var envelope struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &envelope) == nil && envelope.Error != "" { + return envelope.Error + } + return http.StatusText(status) +} + +type containerStatus struct { + ID string `json:"id"` + Alias string `json:"alias"` + Name string `json:"name"` + State string `json:"state"` + Status string `json:"status"` + Endpoint string `json:"endpoint"` +} + +type envView struct { + hub.Env + State string `json:"state"` + Status string `json:"status"` + ContainerID string `json:"container_id"` + Endpoint string `json:"endpoint"` +} + +func registerHub(app *fiber.App, store hubStore) { + var operations sync.Mutex + // ponytail: one control-plane instance is serialized globally; use keyed/distributed locks if replicas or throughput require it. + serialized := func(handler fiber.Handler) fiber.Handler { + return func(c fiber.Ctx) error { + operations.Lock() + defer operations.Unlock() + return handler(c) + } + } + app.Get("/api/browsers", listBrowsers(store)) + app.Post("/api/browsers", serialized(createBrowser(store))) + app.Post("/api/browsers/:alias/:action", serialized(browserAction(store))) + app.Delete("/api/browsers/:alias", serialized(deleteBrowser(store))) + + app.Get("/api/browser-images", func(c fiber.Ctx) error { + images, err := store.ListImages(c.Context(), false) + if err != nil { + return hubError(c, err) + } + return c.JSON(images) + }) + app.Post("/api/browser-images", func(c fiber.Ctx) error { + input := struct { + Version string `json:"version"` + ImageRef string `json:"image_ref"` + Note string `json:"note"` + Enabled *bool `json:"enabled"` + }{} + if err := decodeHubJSON(c, &input); err != nil { + return hubError(c, err) + } + enabled := true + if input.Enabled != nil { + enabled = *input.Enabled + } + if err := store.CreateImage(c.Context(), hub.Image{Version: input.Version, ImageRef: input.ImageRef, Note: input.Note, Enabled: enabled}); err != nil { + return hubError(c, err) + } + return c.Status(fiber.StatusCreated).JSON(map[string]any{ + "version": input.Version, "image_ref": input.ImageRef, "note": input.Note, "enabled": enabled, + }) + }) + app.Put("/api/browser-images/:version", serialized(func(c fiber.Ctx) error { + input := struct { + ImageRef string `json:"image_ref"` + Note string `json:"note"` + Enabled *bool `json:"enabled"` + }{} + if err := decodeHubJSON(c, &input); err != nil { + return hubError(c, err) + } + enabled := true + if input.Enabled != nil { + enabled = *input.Enabled + } + if err := store.UpdateImage(c.Context(), hub.Image{Version: c.Params("version"), ImageRef: input.ImageRef, Note: input.Note, Enabled: enabled}); err != nil { + return hubError(c, err) + } + return c.SendStatus(fiber.StatusNoContent) + })) + app.Delete("/api/browser-images/:version", serialized(func(c fiber.Ctx) error { + if err := store.DeleteImage(c.Context(), c.Params("version")); err != nil { + return hubError(c, err) + } + return c.SendStatus(fiber.StatusNoContent) + })) + + app.Get("/api/gateways", func(c fiber.Ctx) error { + gateways, err := store.ListGateways(c.Context()) + if err != nil { + return hubError(c, err) + } + return c.JSON(gateways) + }) + app.Post("/api/gateways", func(c fiber.Ctx) error { + input := struct { + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Token string `json:"token"` + }{} + if err := decodeHubJSON(c, &input); err != nil { + return hubError(c, err) + } + gateway, err := store.CreateGateway(c.Context(), input.Name, input.Endpoint, input.Token) + if err != nil { + return hubError(c, err) + } + return c.Status(fiber.StatusCreated).JSON(gateway) + }) + app.Delete("/api/gateways/:name", func(c fiber.Ctx) error { + if err := store.DeleteGateway(c.Context(), c.Params("name")); err != nil { + return hubError(c, err) + } + return c.SendStatus(fiber.StatusNoContent) + }) +} + +func listBrowsers(store hubStore) fiber.Handler { + return func(c fiber.Ctx) error { + envs, err := store.ListEnvs(c.Context()) + if err != nil { + return hubError(c, err) + } + gateways := map[string]hub.Gateway{} + containers := map[string]map[string]containerStatus{} + for _, env := range envs { + if _, ok := gateways[env.Gateway]; ok { + continue + } + gateway, err := store.GetGateway(c.Context(), env.Gateway) + if err != nil { + continue // 环境引用的网关被删除属于异常状态,列表仍要展示该环境 + } + gateways[env.Gateway] = gateway + status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodGet, "/v1/browsers", nil, 30*time.Second) + if callErr != nil || status != http.StatusOK { + continue + } + var list []containerStatus + if json.Unmarshal(body, &list) == nil { + byAlias := map[string]containerStatus{} + for _, container := range list { + byAlias[container.Alias] = container + } + containers[env.Gateway] = byAlias + } + } + views := make([]envView, 0, len(envs)) + for _, env := range envs { + view := envView{Env: env, State: "missing", Status: "网关上不存在容器"} + if container, ok := containers[env.Gateway][env.Alias]; ok { + view.State, view.Status = container.State, container.Status + view.ContainerID, view.Endpoint = container.ID, container.Endpoint + } + views = append(views, view) + } + return c.JSON(views) + } +} + +func createBrowser(store hubStore) fiber.Handler { + return func(c fiber.Ctx) error { + input := struct { + Alias string `json:"alias"` + Name string `json:"name"` + Gateway string `json:"gateway"` + ImageVersion string `json:"image_version"` + Fingerprint hub.Fingerprint `json:"fingerprint"` + }{} + if err := decodeHubJSON(c, &input); err != nil { + return hubError(c, err) + } + env := hub.Env{Alias: input.Alias, Name: input.Name, Gateway: input.Gateway, ImageVersion: input.ImageVersion, Fingerprint: input.Fingerprint} + if err := env.Fingerprint.Validate(); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": err.Error()}) + } + gateway, err := store.GetGateway(c.Context(), env.Gateway) + if err != nil { + return hubError(c, err) + } + imageRef, err := store.ImageRef(c.Context(), env.ImageVersion) + if err != nil { + return hubError(c, err) + } + if err := store.CreateEnv(c.Context(), env); err != nil { + return hubError(c, err) + } + status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers", gatewayCreatePayload(env, imageRef), gatewayLongTimeout) + if callErr != nil || status >= http.StatusInternalServerError { + createErr := gatewayRejected(status, body) + if callErr != nil { + createErr = gatewayUnreachable(callErr) + } + exists, reconcileErr := reconcileGatewayBrowser(c.Context(), gateway, env.Alias) + if reconcileErr == nil && exists { + return c.Status(fiber.StatusCreated).JSON(map[string]string{"alias": env.Alias}) + } + if reconcileErr != nil { + return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: fmt.Sprintf( + "gateway create result unknown; environment retained for reconciliation: %v", createErr)}) + } + _ = store.DeleteEnv(c.Context(), env.Alias) + return hubError(c, createErr) + } + if status != http.StatusCreated { + _ = store.DeleteEnv(c.Context(), env.Alias) + return hubError(c, gatewayRejected(status, body)) + } + return c.Status(fiber.StatusCreated).JSON(map[string]string{"alias": env.Alias}) + } +} + +func browserAction(store hubStore) fiber.Handler { + return func(c fiber.Ctx) error { + switch c.Params("action") { + case "start", "stop": + return lifecycleAction(store, c) + case "upgrade": + return upgradeBrowser(store, c) + default: + return hubError(c, hub.ErrInvalid) + } + } +} + +func lifecycleAction(store hubStore, c fiber.Ctx) error { + env, gateway, err := envWithGateway(c, store, c.Params("alias")) + if err != nil { + return hubError(c, err) + } + status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers/"+env.Alias+"/"+c.Params("action"), nil, 30*time.Second) + if callErr != nil { + return hubError(c, gatewayUnreachable(callErr)) + } + if status != http.StatusNoContent && status != http.StatusNotModified { + return hubError(c, gatewayRejected(status, body)) + } + return c.SendStatus(fiber.StatusNoContent) +} + +func upgradeBrowser(store hubStore, c fiber.Ctx) error { + var input struct { + Version string `json:"version"` + } + if err := decodeHubJSON(c, &input); err != nil { + return hubError(c, err) + } + env, gateway, err := envWithGateway(c, store, c.Params("alias")) + if err != nil { + return hubError(c, err) + } + imageRef, err := store.ImageRef(c.Context(), input.Version) + if err != nil { + return hubError(c, err) + } + // 先删容器(保留卷);404 视为已删除,保证升级可重试。 + status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodDelete, "/v1/browsers/"+env.Alias, nil, 30*time.Second) + if callErr != nil { + return hubError(c, gatewayUnreachable(callErr)) + } + if status != http.StatusNoContent && status != http.StatusNotFound { + return hubError(c, gatewayRejected(status, body)) + } + env.ImageVersion = input.Version + status, body, callErr = gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers", gatewayCreatePayload(env, imageRef), gatewayLongTimeout) + if callErr != nil { + return hubError(c, gatewayUnreachable(callErr)) + } + if status != http.StatusCreated { + return hubError(c, gatewayRejected(status, body)) + } + if err := store.UpgradeEnv(c.Context(), env.Alias, input.Version); err != nil { + cleanupStatus, cleanupBody, cleanupErr := gatewayCall(c.Context(), gateway, http.MethodDelete, "/v1/browsers/"+env.Alias, nil, 30*time.Second) + if cleanupErr != nil || (cleanupStatus != http.StatusNoContent && cleanupStatus != http.StatusNotFound) { + if cleanupErr == nil { + cleanupErr = gatewayRejected(cleanupStatus, cleanupBody) + } + return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: fmt.Sprintf( + "persist upgrade: %v; remove uncommitted container: %v", err, cleanupErr)}) + } + return hubError(c, err) + } + return c.SendStatus(fiber.StatusNoContent) +} + +func deleteBrowser(store hubStore) fiber.Handler { + return func(c fiber.Ctx) error { + env, gateway, err := envWithGateway(c, store, c.Params("alias")) + if err != nil { + return hubError(c, err) + } + status, _, callErr := gatewayCall(c.Context(), gateway, http.MethodDelete, "/v1/browsers/"+env.Alias, nil, 30*time.Second) + if callErr != nil { + return hubError(c, gatewayUnreachable(callErr)) + } + if status != http.StatusNoContent && status != http.StatusNotFound { + return hubError(c, errors.New("gateway refused container removal")) + } + if err := store.DeleteEnv(c.Context(), env.Alias); err != nil { + return hubError(c, err) + } + return c.SendStatus(fiber.StatusNoContent) + } +} + +func envWithGateway(c fiber.Ctx, store hubStore, alias string) (hub.Env, hub.Gateway, error) { + env, err := store.GetEnv(c.Context(), alias) + if err != nil { + return hub.Env{}, hub.Gateway{}, err + } + gateway, err := store.GetGateway(c.Context(), env.Gateway) + if err != nil { + return hub.Env{}, hub.Gateway{}, err + } + return env, gateway, nil +} + +func decodeHubJSON(c fiber.Ctx, destination any) error { + decoder := json.NewDecoder(bytes.NewReader(c.Body())) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return hub.ErrInvalid + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return hub.ErrInvalid + } + return nil +} + +func hubError(c fiber.Ctx, err error) error { + var failure gatewayFailure + if errors.As(err, &failure) { + return c.Status(failure.status).JSON(map[string]string{"error": failure.message}) + } + status := fiber.StatusInternalServerError + message := "hub operation failed" + switch { + case errors.Is(err, hub.ErrInvalid): + status, message = fiber.StatusBadRequest, hub.ErrInvalid.Error() + case errors.Is(err, hub.ErrConflict): + status, message = fiber.StatusConflict, hub.ErrConflict.Error() + case errors.Is(err, hub.ErrNotFound): + status, message = fiber.StatusNotFound, hub.ErrNotFound.Error() + default: + return c.Status(status).JSON(map[string]string{"error": err.Error()}) + } + return c.Status(status).JSON(map[string]string{"error": message}) +} diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go new file mode 100644 index 0000000..ba3e9d3 --- /dev/null +++ b/cmd/control-plane/hub_test.go @@ -0,0 +1,672 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "git.ipao.vip/rogee/creator-hub/internal/hub" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/adaptor" +) + +// memoryStore 是 hubStore 的内存桩,记录写入以便断言编排副作用。 +type memoryStore struct { + mu sync.Mutex + gateways map[string]hub.Gateway + images map[string]hub.Image + envs map[string]hub.Env + deleted []string + upgraded map[string]string + upgradeErr error + gatewayFn func(name string) (hub.Gateway, error) +} + +func newMemoryStore() *memoryStore { + return &memoryStore{ + gateways: map[string]hub.Gateway{}, + images: map[string]hub.Image{}, + envs: map[string]hub.Env{}, + upgraded: map[string]string{}, + } +} + +func (s *memoryStore) CreateGateway(_ context.Context, _, _, _ string) (hub.Gateway, error) { + return hub.Gateway{}, nil +} +func (s *memoryStore) ListGateways(context.Context) ([]hub.Gateway, error) { return nil, nil } +func (s *memoryStore) GetGateway(_ context.Context, name string) (hub.Gateway, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.gatewayFn != nil { + return s.gatewayFn(name) + } + gateway, ok := s.gateways[name] + if !ok { + return hub.Gateway{}, hub.ErrNotFound + } + return gateway, nil +} +func (s *memoryStore) DeleteGateway(context.Context, string) error { return nil } +func (s *memoryStore) CreateImage(_ context.Context, image hub.Image) error { + s.mu.Lock() + defer s.mu.Unlock() + s.images[image.Version] = image + return nil +} +func (s *memoryStore) UpdateImage(_ context.Context, image hub.Image) error { + s.mu.Lock() + defer s.mu.Unlock() + s.images[image.Version] = image + return nil +} +func (s *memoryStore) ListImages(context.Context, bool) ([]hub.Image, error) { + s.mu.Lock() + defer s.mu.Unlock() + images := make([]hub.Image, 0, len(s.images)) + for _, image := range s.images { + images = append(images, image) + } + return images, nil +} +func (s *memoryStore) DeleteImage(context.Context, string) error { return nil } +func (s *memoryStore) ImageRef(_ context.Context, version string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + image, ok := s.images[version] + if !ok || !image.Enabled { + return "", hub.ErrNotFound + } + return image.ImageRef, nil +} +func (s *memoryStore) CreateEnv(_ context.Context, env hub.Env) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.envs[env.Alias]; exists { + return hub.ErrConflict + } + if image, exists := s.images[env.ImageVersion]; !exists || !image.Enabled { + return hub.ErrNotFound + } + s.envs[env.Alias] = env + return nil +} +func (s *memoryStore) ListEnvs(context.Context) ([]hub.Env, error) { + s.mu.Lock() + defer s.mu.Unlock() + envs := make([]hub.Env, 0, len(s.envs)) + for _, env := range s.envs { + envs = append(envs, env) + } + return envs, nil +} +func (s *memoryStore) GetEnv(_ context.Context, alias string) (hub.Env, error) { + s.mu.Lock() + defer s.mu.Unlock() + env, ok := s.envs[alias] + if !ok { + return hub.Env{}, hub.ErrNotFound + } + return env, nil +} +func (s *memoryStore) UpgradeEnv(_ context.Context, alias, version string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.upgradeErr != nil { + return s.upgradeErr + } + if _, ok := s.envs[alias]; !ok { + return hub.ErrNotFound + } + if image, exists := s.images[version]; !exists || !image.Enabled { + return hub.ErrNotFound + } + s.upgraded[alias] = version + env := s.envs[alias] + env.ImageVersion = version + s.envs[alias] = env + return nil +} +func (s *memoryStore) DeleteEnv(_ context.Context, alias string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.deleted = append(s.deleted, alias) + delete(s.envs, alias) + return nil +} + +type recordedRequest struct { + method string + path string + body map[string]any +} + +// fakeGateway 模拟 docker-gateway:按路由表应答并记录请求。 +type fakeGateway struct { + mu sync.Mutex + createOnce sync.Once + token string + requests []recordedRequest + containers []containerStatus + failCreate int // 前 N 次 create 返回失败 + createStarted chan struct{} + releaseCreate <-chan struct{} +} + +func (g *fakeGateway) handler(t *testing.T) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer "+g.token { + response.WriteHeader(http.StatusUnauthorized) + _, _ = response.Write([]byte(`{"error":"gateway token rejected"}`)) + return + } + var body map[string]any + if request.Body != nil { + raw, _ := io.ReadAll(request.Body) + if len(raw) > 0 { + _ = json.Unmarshal(raw, &body) + } + } + g.mu.Lock() + g.requests = append(g.requests, recordedRequest{method: request.Method, path: request.URL.Path, body: body}) + g.mu.Unlock() + + switch { + case request.Method == http.MethodPost && request.URL.Path == "/v1/browsers": + g.mu.Lock() + if g.failCreate > 0 { + g.failCreate-- + g.mu.Unlock() + response.WriteHeader(http.StatusConflict) + _, _ = response.Write([]byte(`{"error":"alias already in use"}`)) + return + } + g.mu.Unlock() + if g.createStarted != nil { + g.createOnce.Do(func() { close(g.createStarted) }) + } + if g.releaseCreate != nil { + <-g.releaseCreate + } + response.WriteHeader(http.StatusCreated) + _, _ = response.Write([]byte(`{"id":"container-id","alias":"account-a"}`)) + case request.Method == http.MethodGet && request.URL.Path == "/v1/browsers": + _ = json.NewEncoder(response).Encode(g.containers) + case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/v1/browsers/"): + response.WriteHeader(http.StatusNoContent) + case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/start"), + request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/stop"): + response.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected gateway request %s %s", request.Method, request.URL.Path) + } + }) +} + +func (g *fakeGateway) recorded() []recordedRequest { + g.mu.Lock() + defer g.mu.Unlock() + return append([]recordedRequest{}, g.requests...) +} + +func newTestApp(t *testing.T, store *memoryStore, gateway *fakeGateway) *fiber.App { + t.Helper() + server := httptest.NewServer(gateway.handler(t)) + t.Cleanup(server.Close) + if store.gateways == nil { + store.gateways = map[string]hub.Gateway{} + } + store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token} + app := fiber.New() + registerHub(app, store) + return app +} + +func do(app *fiber.App, method, path, body string) *httptest.ResponseRecorder { + response := httptest.NewRecorder() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + adaptor.FiberApp(app).ServeHTTP(response, httptest.NewRequest(method, path, reader)) + return response +} + +const createEnvBody = `{"alias":"account-a","name":"店铺一号","gateway":"gw-1","image_version":"148.0.7778.215",` + + `"fingerprint":{"seed":2024,"platform":"windows","timezone":"Asia/Shanghai"}}` + +func TestCreateBrowserOrchestratesGateway(t *testing.T) { + store := newMemoryStore() + _ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser@sha256:abc", Enabled: true}) + gateway := &fakeGateway{token: "unit-test-gateway-token"} + app := newTestApp(t, store, gateway) + + response := do(app, http.MethodPost, "/api/browsers", createEnvBody) + if response.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String()) + } + if _, kept := store.envs["account-a"]; !kept { + t.Fatal("env must be persisted after successful gateway create") + } + requests := gateway.recorded() + if len(requests) != 1 || requests[0].method != http.MethodPost || requests[0].path != "/v1/browsers" { + t.Fatalf("unexpected gateway calls: %#v", requests) + } + payload := requests[0].body + if payload["alias"] != "account-a" || payload["name"] != "店铺一号" || + payload["image"] != "registry.example/browser@sha256:abc" || + payload["volume"] != "creatorhub-profile-account-a" { + t.Fatalf("platform must fully specify the gateway payload: %#v", payload) + } + cmd := payload["cmd"].([]any) + if len(cmd) != 4 || cmd[0] != "--fingerprint=2024" || cmd[1] != "--fingerprint-platform=windows" || + cmd[2] != "--timezone=Asia/Shanghai" || cmd[3] != "about:blank" { + t.Fatalf("cmd must carry fingerprint args plus start url: %#v", cmd) + } +} + +func TestCreateBrowserRollsBackWhenGatewayRejects(t *testing.T) { + store := newMemoryStore() + _ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true}) + gateway := &fakeGateway{token: "unit-test-gateway-token", failCreate: 1} + app := newTestApp(t, store, gateway) + + response := do(app, http.MethodPost, "/api/browsers", createEnvBody) + if response.Code != http.StatusConflict { + t.Fatalf("expected gateway conflict to pass through as 409, got %d: %s", response.Code, response.Body.String()) + } + if len(store.deleted) != 1 || store.deleted[0] != "account-a" { + t.Fatalf("env must be rolled back after gateway rejection, deleted=%v", store.deleted) + } + if _, kept := store.envs["account-a"]; kept { + t.Fatal("rolled-back env must not remain in the store") + } +} + +func TestCreateBrowserReconcilesDisconnectedGateway(t *testing.T) { + for _, test := range []struct { + name string + reconcile bool + wantStatus int + }{ + {name: "completed create", reconcile: true, wantStatus: http.StatusCreated}, + {name: "unknown result", wantStatus: http.StatusBadGateway}, + } { + t.Run(test.name, func(t *testing.T) { + var created bool + var mu sync.Mutex + gatewayServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer unit-test-gateway-token" { + response.WriteHeader(http.StatusUnauthorized) + return + } + if request.Method == http.MethodGet && test.reconcile { + mu.Lock() + exists := created + mu.Unlock() + if exists { + _ = json.NewEncoder(response).Encode([]containerStatus{{ID: "container-id", Alias: "account-a"}}) + return + } + _ = json.NewEncoder(response).Encode([]containerStatus{}) + return + } + if request.Method == http.MethodPost { + mu.Lock() + created = true + mu.Unlock() + } + hijacker, ok := response.(http.Hijacker) + if !ok { + t.Error("test server does not support hijacking") + return + } + connection, _, err := hijacker.Hijack() + if err != nil { + t.Errorf("hijack gateway response: %v", err) + return + } + _ = connection.Close() + })) + defer gatewayServer.Close() + + store := newMemoryStore() + _ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true}) + store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"} + app := fiber.New() + registerHub(app, store) + + response := do(app, http.MethodPost, "/api/browsers", createEnvBody) + if response.Code != test.wantStatus { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + if _, err := store.GetEnv(context.Background(), "account-a"); err != nil { + t.Fatalf("unknown gateway result must retain the environment for reconciliation: %v", err) + } + if len(store.deleted) != 0 { + t.Fatalf("unknown result must not roll back the environment: %v", store.deleted) + } + }) + } +} + +func TestCreateBrowserReconcilesGatewayBadGateway(t *testing.T) { + for _, test := range []struct { + name string + missingReads int + }{ + {name: "Docker create disconnect returned 502"}, + {name: "container becomes visible after query window", missingReads: 2}, + } { + t.Run(test.name, func(t *testing.T) { + var mu sync.Mutex + listCalls := 0 + gatewayServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer unit-test-gateway-token" { + response.WriteHeader(http.StatusUnauthorized) + return + } + if request.Method == http.MethodPost { + response.WriteHeader(http.StatusBadGateway) + _, _ = response.Write([]byte(`{"error":"create container: context deadline exceeded"}`)) + return + } + mu.Lock() + listCalls++ + missing := listCalls <= test.missingReads + mu.Unlock() + if missing { + _ = json.NewEncoder(response).Encode([]containerStatus{}) + return + } + _ = json.NewEncoder(response).Encode([]containerStatus{{ID: "container-id", Alias: "account-a"}}) + })) + defer gatewayServer.Close() + + store := newMemoryStore() + _ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true}) + store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"} + app := fiber.New() + registerHub(app, store) + + response := do(app, http.MethodPost, "/api/browsers", createEnvBody) + if response.Code != http.StatusCreated { + t.Fatalf("expected reconciled create, status=%d body=%s", response.Code, response.Body.String()) + } + if _, err := store.GetEnv(context.Background(), "account-a"); err != nil || len(store.deleted) != 0 { + t.Fatalf("reconciled create must retain DB state: err=%v deleted=%v", err, store.deleted) + } + mu.Lock() + gotCalls := listCalls + mu.Unlock() + if gotCalls != test.missingReads+1 { + t.Fatalf("expected %d reconciliation reads, got %d", test.missingReads+1, gotCalls) + } + }) + } +} + +func TestCreateBrowserRejectsInvalidFingerprintBeforeSideEffects(t *testing.T) { + store := newMemoryStore() + _ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true}) + gateway := &fakeGateway{token: "unit-test-gateway-token"} + app := newTestApp(t, store, gateway) + + response := do(app, http.MethodPost, "/api/browsers", + `{"alias":"account-a","name":"甲","gateway":"gw-1","image_version":"148.0.7778.215","fingerprint":{"seed":0}}`) + if response.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid fingerprint, got %d: %s", response.Code, response.Body.String()) + } + if len(gateway.recorded()) != 0 || len(store.envs) != 0 { + t.Fatal("invalid input must not reach the gateway or the store") + } +} + +func TestListBrowsersMergesLiveGatewayState(t *testing.T) { + store := newMemoryStore() + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148"} + store.envs["account-b"] = hub.Env{Alias: "account-b", Name: "店铺二号", Gateway: "gw-1", ImageVersion: "148"} + gateway := &fakeGateway{ + token: "unit-test-gateway-token", + containers: []containerStatus{ + {ID: "id-1", Alias: "account-a", State: "running", Status: "Up", Endpoint: "http://creatorhub-browser-account-a:9222"}, + }, + } + app := newTestApp(t, store, gateway) + + response := do(app, http.MethodGet, "/api/browsers", "") + if response.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String()) + } + var views []envView + if err := json.NewDecoder(response.Body).Decode(&views); err != nil || len(views) != 2 { + t.Fatalf("expected two env views, err=%v body=%s", err, response.Body.String()) + } + byAlias := map[string]envView{} + for _, view := range views { + byAlias[view.Alias] = view + } + if byAlias["account-a"].State != "running" || byAlias["account-a"].ContainerID != "id-1" { + t.Fatalf("running container state must be merged: %#v", byAlias["account-a"]) + } + if byAlias["account-b"].State != "missing" { + t.Fatalf("env without container must report missing: %#v", byAlias["account-b"]) + } +} + +func TestUpgradeBrowserRecreatesWithSameVolumeAndParams(t *testing.T) { + store := newMemoryStore() + store.envs["account-a"] = hub.Env{ + Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148", + Fingerprint: hub.Fingerprint{Seed: 2024, Timezone: "Asia/Shanghai"}, + } + _ = store.CreateImage(nil, hub.Image{Version: "144.0.7559.132", ImageRef: "registry.example/browser:144", Enabled: true}) + gateway := &fakeGateway{token: "unit-test-gateway-token"} + app := newTestApp(t, store, gateway) + + response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"144.0.7559.132"}`) + if response.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String()) + } + requests := gateway.recorded() + if len(requests) != 2 || requests[0].method != http.MethodDelete || requests[0].path != "/v1/browsers/account-a" || + requests[1].method != http.MethodPost || requests[1].path != "/v1/browsers" { + t.Fatalf("upgrade must delete then recreate: %#v", requests) + } + payload := requests[1].body + if payload["image"] != "registry.example/browser:144" || payload["volume"] != "creatorhub-profile-account-a" { + t.Fatalf("upgrade must reuse the profile volume and switch image: %#v", payload) + } + cmd := payload["cmd"].([]any) + if cmd[0] != "--fingerprint=2024" || cmd[len(cmd)-1] != "about:blank" { + t.Fatalf("upgrade must reuse stored fingerprint params: %#v", cmd) + } + if store.upgraded["account-a"] != "144.0.7559.132" || store.envs["account-a"].ImageVersion != "144.0.7559.132" { + t.Fatal("image version must be persisted after successful upgrade") + } +} + +func TestUpgradeBrowserRemovesUncommittedContainer(t *testing.T) { + store := newMemoryStore() + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 2024}} + store.upgradeErr = hub.ErrNotFound + _ = store.CreateImage(nil, hub.Image{Version: "144.0.7559.132", ImageRef: "registry.example/browser:144", Enabled: true}) + gateway := &fakeGateway{token: "unit-test-gateway-token"} + app := newTestApp(t, store, gateway) + + response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"144.0.7559.132"}`) + if response.Code != http.StatusNotFound { + t.Fatalf("expected persistence failure, got %d: %s", response.Code, response.Body.String()) + } + requests := gateway.recorded() + if len(requests) != 3 || requests[2].method != http.MethodDelete || requests[2].path != "/v1/browsers/account-a" { + t.Fatalf("uncommitted upgraded container must be removed: %#v", requests) + } + env, err := store.GetEnv(context.Background(), "account-a") + if err != nil || env.ImageVersion != "148" { + t.Fatalf("failed upgrade must preserve the stored version: %#v %v", env, err) + } +} + +func TestImageDisableWaitsForUpgradeCommit(t *testing.T) { + releaseCreate := make(chan struct{}) + gateway := &fakeGateway{ + token: "unit-test-gateway-token", + createStarted: make(chan struct{}), + releaseCreate: releaseCreate, + } + store := newMemoryStore() + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 2024}} + _ = store.CreateImage(nil, hub.Image{Version: "144.0.7559.132", ImageRef: "registry.example/browser:144", Enabled: true}) + gatewayServer := httptest.NewServer(gateway.handler(t)) + defer gatewayServer.Close() + store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: gateway.token} + disableArrived := make(chan struct{}) + var disableOnce sync.Once + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + if c.Method() == http.MethodPut { + disableOnce.Do(func() { close(disableArrived) }) + } + return c.Next() + }) + registerHub(app, store) + server := httptest.NewServer(adaptor.FiberApp(app)) + defer server.Close() + + type result struct { + status int + err error + } + upgradeDone := make(chan result, 1) + go func() { + response, err := server.Client().Post(server.URL+"/api/browsers/account-a/upgrade", "application/json", strings.NewReader(`{"version":"144.0.7559.132"}`)) + if err != nil { + upgradeDone <- result{err: err} + return + } + defer response.Body.Close() + upgradeDone <- result{status: response.StatusCode} + }() + select { + case <-gateway.createStarted: + case <-time.After(time.Second): + t.Fatal("upgrade did not reach gateway create") + } + + disableDone := make(chan result, 1) + go func() { + request, err := http.NewRequest(http.MethodPut, server.URL+"/api/browser-images/144.0.7559.132", strings.NewReader( + `{"image_ref":"registry.example/browser:144","enabled":false}`)) + if err != nil { + disableDone <- result{err: err} + return + } + request.Header.Set("Content-Type", "application/json") + response, err := server.Client().Do(request) + if err != nil { + disableDone <- result{err: err} + return + } + defer response.Body.Close() + disableDone <- result{status: response.StatusCode} + }() + <-disableArrived + select { + case result := <-disableDone: + t.Fatalf("disable completed before upgrade commit: %#v", result) + case <-time.After(50 * time.Millisecond): + } + + close(releaseCreate) + if result := <-upgradeDone; result.err != nil || result.status != http.StatusNoContent { + t.Fatalf("upgrade failed: %#v", result) + } + if result := <-disableDone; result.err != nil || result.status != http.StatusNoContent { + t.Fatalf("disable failed: %#v", result) + } + env, err := store.GetEnv(context.Background(), "account-a") + if err != nil || env.ImageVersion != "144.0.7559.132" { + t.Fatalf("upgrade must commit before disable: %#v %v", env, err) + } + if _, err := store.ImageRef(context.Background(), "144.0.7559.132"); !errors.Is(err, hub.ErrNotFound) { + t.Fatalf("disable must apply after upgrade: %v", err) + } +} + +func TestBrowserActionRoutesStartStopAndRejectsUnknown(t *testing.T) { + store := newMemoryStore() + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + gateway := &fakeGateway{token: "unit-test-gateway-token"} + app := newTestApp(t, store, gateway) + + if response := do(app, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent { + t.Fatalf("expected 204 for start, got %d: %s", response.Code, response.Body.String()) + } + if response := do(app, http.MethodPost, "/api/browsers/account-a/pause", ""); response.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for unknown action, got %d", response.Code) + } +} + +func TestDeleteBrowserKeepsEnvAndContainerInSync(t *testing.T) { + store := newMemoryStore() + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + gateway := &fakeGateway{token: "unit-test-gateway-token"} + app := newTestApp(t, store, gateway) + + if response := do(app, http.MethodDelete, "/api/browsers/account-a", ""); response.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String()) + } + _, kept := store.envs["account-a"] + if len(store.deleted) != 1 || kept { + t.Fatalf("env must be removed with the container, deleted=%v", store.deleted) + } +} + +func TestGatewayAndImageCRUDRoutes(t *testing.T) { + store := newMemoryStore() + gateway := &fakeGateway{token: "unit-test-gateway-token"} + app := newTestApp(t, store, gateway) + + if response := do(app, http.MethodPost, "/api/browser-images", + `{"version":"148.0.7778.215","image_ref":"registry.example/browser:148","note":"main","enabled":true}`); response.Code != http.StatusCreated { + t.Fatalf("expected 201 for image create, got %d: %s", response.Code, response.Body.String()) + } + if image, ok := store.images["148.0.7778.215"]; !ok || image.ImageRef != "registry.example/browser:148" || !image.Enabled { + t.Fatalf("image must be stored: %#v", store.images) + } + if response := do(app, http.MethodPost, "/api/browser-images", + `{"version":"148.0.7778.215","image_ref":"registry.example/browser:148"}`); response.Code != http.StatusCreated { + t.Fatalf("enabled must default to true, got %d", response.Code) + } +} + +func TestCreateImageReturnsJSONOverHTTP(t *testing.T) { + store := newMemoryStore() + app := fiber.New() + registerHub(app, store) + server := httptest.NewServer(adaptor.FiberApp(app)) + defer server.Close() + + response, err := server.Client().Post(server.URL+"/api/browser-images", "application/json", strings.NewReader( + `{"version":"148.0.7778.215","image_ref":"registry.example/browser:148","note":"main","enabled":true}`)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var image hub.Image + if err := json.NewDecoder(response.Body).Decode(&image); err != nil { + t.Fatalf("201 response must be JSON: %v", err) + } + if response.StatusCode != http.StatusCreated || !strings.HasPrefix(response.Header.Get("Content-Type"), "application/json") || + image.Version != "148.0.7778.215" || image.ImageRef != "registry.example/browser:148" || !image.Enabled { + t.Fatalf("unexpected create response: status=%d content-type=%q image=%#v", response.StatusCode, response.Header.Get("Content-Type"), image) + } +} diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index 42df587..ae37d7c 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -4,8 +4,6 @@ import ( "context" "errors" "net" - "net/http" - "net/http/httputil" "net/url" "os" "os/signal" @@ -15,9 +13,9 @@ import ( "syscall" "time" + "git.ipao.vip/rogee/creator-hub/internal/hub" "git.ipao.vip/rogee/creator-hub/internal/phasea" "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/middleware/adaptor" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -25,7 +23,6 @@ import ( type config struct { listenAddr string - gateway *url.URL webDir string databaseURL string logLevel logrus.Level @@ -58,13 +55,18 @@ func newCommand() *cobra.Command { return err } logrus.SetLevel(cfg.logLevel) - store, err := phasea.Open(command.Context(), cfg.databaseURL) + phaseAStore, err := phasea.Open(command.Context(), cfg.databaseURL) if err != nil { return err } - defer store.Close() + defer phaseAStore.Close() + hubStore, err := hub.Open(command.Context(), cfg.databaseURL) + if err != nil { + return err + } + defer hubStore.Close() logStartup(cfg) - return newHandlerWithPhaseA(cfg.gateway, cfg.webDir, store).Listen(cfg.listenAddr, fiber.ListenConfig{ + return newHandlerWithStores(cfg.webDir, phaseAStore, hubStore).Listen(cfg.listenAddr, fiber.ListenConfig{ GracefulContext: command.Context(), DisableStartupMessage: true, }) @@ -75,36 +77,28 @@ func newCommand() *cobra.Command { func logStartup(cfg config) { logrus.WithFields(logrus.Fields{ - "service": "control-plane", - "listen_addr": cfg.listenAddr, - "docker_gateway": cfg.gateway.Scheme + "://" + cfg.gateway.Host, + "service": "control-plane", + "listen_addr": cfg.listenAddr, }).Info("service starting") } func loadConfig() (config, error) { v := viper.New() v.SetDefault("listen_addr", ":8080") - v.SetDefault("docker_gateway_url", "http://127.0.0.1:8081") v.SetDefault("web_dir", "web/dist") v.SetDefault("database_url", "postgres://creatorhub@127.0.0.1:5432/creatorhub?sslmode=disable") v.SetDefault("log_level", "info") _ = v.BindEnv("listen_addr", "LISTEN_ADDR") - _ = v.BindEnv("docker_gateway_url", "DOCKER_GATEWAY_URL") _ = v.BindEnv("web_dir", "WEB_DIR") _ = v.BindEnv("database_url", "DATABASE_URL") _ = v.BindEnv("log_level", "LOG_LEVEL") - gateway, err := url.Parse(v.GetString("docker_gateway_url")) - if err != nil || gateway.Host == "" || (gateway.Scheme != "http" && gateway.Scheme != "https") { - return config{}, errors.New("DOCKER_GATEWAY_URL must be an http(s) URL with a host") - } level, err := logrus.ParseLevel(v.GetString("log_level")) if err != nil { return config{}, errors.New("LOG_LEVEL must be panic, fatal, error, warn, info, debug, or trace") } cfg := config{ listenAddr: strings.TrimSpace(v.GetString("listen_addr")), - gateway: gateway, webDir: strings.TrimSpace(v.GetString("web_dir")), databaseURL: strings.TrimSpace(v.GetString("database_url")), logLevel: level, @@ -138,23 +132,15 @@ func validateListenAddr(addr string) error { return nil } -func newHandler(gateway *url.URL, webDirectory string) *fiber.App { - return newHandlerWithPhaseA(gateway, webDirectory, nil) +func newHandler(webDirectory string) *fiber.App { + return newHandlerWithStores(webDirectory, nil, nil) } -func newHandlerWithPhaseA(gateway *url.URL, webDirectory string, store *phasea.Store) *fiber.App { - proxy := httputil.NewSingleHostReverseProxy(gateway) - originalDirector := proxy.Director - proxy.Director = func(request *http.Request) { - originalDirector(request) - request.URL.Path = "/v1/browsers" + strings.TrimPrefix(request.URL.Path, "/api/browsers") - request.Host = gateway.Host - } - proxyHandler := adaptor.HTTPHandler(proxy) - +func newHandlerWithStores(webDirectory string, phaseAStore *phasea.Store, hubStore *hub.Store) *fiber.App { app := fiber.New(fiber.Config{ - AppName: "CreatorHub control plane", - BodyLimit: 1 << 20, + AppName: "CreatorHub control plane", + BodyLimit: 1 << 20, + // 读超时只约束请求读取;创建/升级环境的处理器可等待网关拉取镜像(最长 11 分钟)。 ReadTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, }) @@ -162,10 +148,11 @@ func newHandlerWithPhaseA(gateway *url.URL, webDirectory string, store *phasea.S c.Status(fiber.StatusNoContent) return nil }) - app.All("/api/browsers", proxyHandler) - app.All("/api/browsers/*", proxyHandler) - if store != nil { - registerPhaseA(app, store) + if hubStore != nil { + registerHub(app, hubStore) + } + if phaseAStore != nil { + registerPhaseA(app, phaseAStore) } app.Get("/*", spaHandler(webDirectory)) return app diff --git a/cmd/control-plane/main_test.go b/cmd/control-plane/main_test.go index 3f2c57f..5b92227 100644 --- a/cmd/control-plane/main_test.go +++ b/cmd/control-plane/main_test.go @@ -9,7 +9,6 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "os" "os/exec" "path/filepath" @@ -23,48 +22,6 @@ import ( "github.com/spf13/cobra" ) -func TestAPIProxyPreservesMethodPathAndStatus(t *testing.T) { - requests := make(chan *http.Request, 1) - gateway := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - requests <- request - response.WriteHeader(http.StatusServiceUnavailable) - })) - defer gateway.Close() - - upstream, err := url.Parse(gateway.URL) - if err != nil { - t.Fatal(err) - } - request := httptest.NewRequest(http.MethodPost, "/api/browsers/account-a/start?force=true", nil) - response, err := newHandler(upstream, t.TempDir()).Test(request) - if err != nil { - t.Fatal(err) - } - defer response.Body.Close() - - proxied := <-requests - if proxied.Method != http.MethodPost || proxied.URL.Path != "/v1/browsers/account-a/start" || proxied.URL.RawQuery != "force=true" { - t.Fatalf("unexpected proxy request %s %s", proxied.Method, proxied.URL.String()) - } - if response.StatusCode != http.StatusServiceUnavailable { - t.Fatalf("expected upstream status 503, got %d", response.StatusCode) - } -} - -func TestLoadConfigRejectsInvalidGateway(t *testing.T) { - t.Setenv("DOCKER_GATEWAY_URL", "file:///var/run/docker.sock") - if _, err := loadConfig(); err == nil { - t.Fatal("expected invalid gateway URL to be rejected") - } -} - -func TestLoadConfigRejectsInvalidDatabase(t *testing.T) { - t.Setenv("DATABASE_URL", "file:///tmp/creatorhub.db") - if _, err := loadConfig(); err == nil { - t.Fatal("expected non-Postgres database URL to be rejected") - } -} - func TestExecuteContextStopsOnSIGTERM(t *testing.T) { if os.Getenv("CREATORHUB_SIGTERM_HELPER") == "1" { command := &cobra.Command{Use: "shutdown-test", RunE: func(command *cobra.Command, _ []string) error { @@ -90,6 +47,7 @@ func TestExecuteContextStopsOnSIGTERM(t *testing.T) { if err := process.Start(); err != nil { t.Fatal(err) } + scanner := bufio.NewScanner(stdout) if !scanner.Scan() || scanner.Text() != "ready" { t.Fatalf("helper did not become ready: %q err=%v", scanner.Text(), scanner.Err()) @@ -105,35 +63,10 @@ func TestExecuteContextStopsOnSIGTERM(t *testing.T) { } } -func TestStartupLogRedactsGatewayURL(t *testing.T) { - gateway, err := url.Parse("https://internal-user:internal-pass@docker-gateway:8443/v1?access_token=internal-token") - if err != nil { - t.Fatal(err) - } - logger := logrus.StandardLogger() - previousOutput, previousFormatter, previousLevel := logger.Out, logger.Formatter, logger.Level - t.Cleanup(func() { - logrus.SetOutput(previousOutput) - logrus.SetFormatter(previousFormatter) - logrus.SetLevel(previousLevel) - }) - var output bytes.Buffer - logrus.SetOutput(&output) - logrus.SetFormatter(&logrus.JSONFormatter{}) - logrus.SetLevel(logrus.InfoLevel) - - logStartup(config{listenAddr: ":8080", gateway: gateway}) - var entry map[string]any - if err := json.Unmarshal(output.Bytes(), &entry); err != nil { - t.Fatal(err) - } - if entry["docker_gateway"] != "https://docker-gateway:8443" { - t.Fatalf("unexpected redacted gateway: %#v", entry["docker_gateway"]) - } - for _, sensitive := range []string{"internal-user", "internal-pass", "access_token", "internal-token"} { - if strings.Contains(output.String(), sensitive) { - t.Fatalf("startup log leaked %q: %s", sensitive, output.String()) - } +func TestLoadConfigRejectsInvalidDatabase(t *testing.T) { + t.Setenv("DATABASE_URL", "file:///tmp/creatorhub.db") + if _, err := loadConfig(); err == nil { + t.Fatal("expected non-Postgres database URL to be rejected") } } @@ -190,7 +123,7 @@ func TestSPAServesFileAndIndexFallback(t *testing.T) { if err := os.WriteFile(filepath.Join(directory, "..", "outside-secret.txt"), []byte("secret"), 0o600); err != nil { t.Fatal(err) } - app := newHandler(&url.URL{Scheme: "http", Host: "127.0.0.1"}, directory) + app := newHandler(directory) for path, want := range map[string]string{ "/app.js": "asset", "/hello%20world.js": "space", @@ -209,3 +142,26 @@ func TestSPAServesFileAndIndexFallback(t *testing.T) { } } } + +func TestStartupLogIncludesListenAddress(t *testing.T) { + logger := logrus.StandardLogger() + previousOutput, previousFormatter, previousLevel := logger.Out, logger.Formatter, logger.Level + t.Cleanup(func() { + logrus.SetOutput(previousOutput) + logrus.SetFormatter(previousFormatter) + logrus.SetLevel(previousLevel) + }) + var output bytes.Buffer + logrus.SetOutput(&output) + logrus.SetFormatter(&logrus.JSONFormatter{}) + logrus.SetLevel(logrus.InfoLevel) + + logStartup(config{listenAddr: ":8080"}) + var entry map[string]any + if err := json.Unmarshal(output.Bytes(), &entry); err != nil { + t.Fatal(err) + } + if entry["listen_addr"] != ":8080" || entry["service"] != "control-plane" { + t.Fatalf("unexpected startup log: %#v", entry) + } +} diff --git a/cmd/docker-gateway/main.go b/cmd/docker-gateway/main.go index 1043452..650b426 100644 --- a/cmd/docker-gateway/main.go +++ b/cmd/docker-gateway/main.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -17,6 +18,7 @@ import ( "strings" "syscall" "time" + "unicode/utf8" "github.com/gofiber/fiber/v3" "github.com/sirupsen/logrus" @@ -25,55 +27,66 @@ import ( ) const ( - browserImage = "git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7" browserUser = "1000:1000" browserEntrypoint = "/usr/local/bin/docker-entrypoint.sh" managedLabel = "io.creatorhub.managed" idLabel = "io.creatorhub.runtime-id" + nameLabel = "io.creatorhub.display-name" networkRoleLabel = "io.creatorhub.network-role" browserNetworkRole = "browser" controlNetworkName = "creatorhub_control" namePrefix = "creatorhub-browser-" + pullTimeout = 10 * time.Minute ) -var runtimeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) -var networkNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`) -var errInvalidRuntimeID = errors.New("invalid runtime id") -var errUnmanagedContainer = errors.New("refusing to operate on a container not owned by CreatorHub") +var ( + runtimeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) + networkNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`) + imageRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$`) + volumePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`) +) -type browserConfig struct { - image string - network string -} +var ( + errInvalidRuntimeID = errors.New("invalid runtime id") + errUnmanagedContainer = errors.New("refusing to operate on a container not owned by CreatorHub") + errUnauthorized = errors.New("gateway token rejected") +) type serviceConfig struct { listenAddr string dockerSock string network string + token string logLevel logrus.Level } type dockerClient struct { baseURL string - client *http.Client + client *http.Client // 常规 Docker 调用 + slow *http.Client // 镜像拉取等长操作,不设整体超时 } type gateway struct { - docker dockerClient - config browserConfig + docker dockerClient + network string + token string } +// createRequest 全量字段由平台下发;网关不做业务决策,只做输入合法性校验。 type createRequest struct { - Name string `json:"name"` - Seed int64 `json:"seed"` + Alias string `json:"alias"` + Name string `json:"name"` + Image string `json:"image"` + Cmd []string `json:"cmd"` + Volume string `json:"volume"` } type browser struct { ID string `json:"id"` + Alias string `json:"alias"` Name string `json:"name"` State string `json:"state"` Status string `json:"status"` - Profile string `json:"profile"` Endpoint string `json:"endpoint"` } @@ -115,6 +128,7 @@ func loadConfig() (serviceConfig, error) { _ = v.BindEnv("listen_addr", "LISTEN_ADDR") _ = v.BindEnv("docker_socket", "DOCKER_SOCKET") _ = v.BindEnv("browser_network", "BROWSER_NETWORK") + _ = v.BindEnv("gateway_token", "GATEWAY_TOKEN") _ = v.BindEnv("log_level", "LOG_LEVEL") level, err := logrus.ParseLevel(v.GetString("log_level")) @@ -125,6 +139,7 @@ func loadConfig() (serviceConfig, error) { listenAddr: strings.TrimSpace(v.GetString("listen_addr")), dockerSock: strings.TrimSpace(v.GetString("docker_socket")), network: strings.TrimSpace(v.GetString("browser_network")), + token: strings.TrimSpace(v.GetString("gateway_token")), logLevel: level, } if cfg.listenAddr == "" { @@ -136,6 +151,9 @@ func loadConfig() (serviceConfig, error) { if cfg.dockerSock == "" { return serviceConfig{}, errors.New("DOCKER_SOCKET must not be empty") } + if len(cfg.token) < 16 { + return serviceConfig{}, errors.New("GATEWAY_TOKEN must be at least 16 characters") + } if err := validateBrowserNetwork(cfg.network); err != nil { return serviceConfig{}, err } @@ -164,6 +182,7 @@ func run(command *cobra.Command, cfg serviceConfig) error { docker := dockerClient{ baseURL: "http://docker/v1.43", client: &http.Client{Transport: transport, Timeout: 30 * time.Second}, + slow: &http.Client{Transport: transport}, } if err := docker.ensureBrowserNetwork(cfg.network); err != nil { return err @@ -173,14 +192,14 @@ func run(command *cobra.Command, cfg serviceConfig) error { "listen_addr": cfg.listenAddr, "network": cfg.network, }).Info("service starting") - return newGateway(docker, browserConfig{image: browserImage, network: cfg.network}).Listen(cfg.listenAddr, fiber.ListenConfig{ + return newGateway(docker, cfg.network, cfg.token).Listen(cfg.listenAddr, fiber.ListenConfig{ GracefulContext: command.Context(), DisableStartupMessage: true, }) } -func newGateway(client dockerClient, cfg browserConfig) *fiber.App { - api := gateway{docker: client, config: cfg} +func newGateway(client dockerClient, network, token string) *fiber.App { + api := gateway{docker: client, network: network, token: token} app := fiber.New(fiber.Config{ AppName: "CreatorHub Docker gateway", BodyLimit: 1 << 20, @@ -199,6 +218,7 @@ func newGateway(client dockerClient, cfg browserConfig) *fiber.App { c.Status(fiber.StatusNoContent) return nil }) + app.Use("/v1", api.authorize) app.Get("/v1/browsers", api.list) app.Post("/v1/browsers", api.create) app.Post("/v1/browsers/:id/:action", api.changeState) @@ -206,6 +226,14 @@ func newGateway(client dockerClient, cfg browserConfig) *fiber.App { return app } +func (api gateway) authorize(c fiber.Ctx) error { + expected := "Bearer " + api.token + if subtle.ConstantTimeCompare([]byte(c.Get(fiber.HeaderAuthorization)), []byte(expected)) != 1 { + return writeError(c, http.StatusUnauthorized, errUnauthorized) + } + return c.Next() +} + func (api gateway) list(c fiber.Ctx) error { filters, _ := json.Marshal(map[string][]string{"label": {managedLabel + "=true"}}) result, err := api.docker.request(http.MethodGet, "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil) @@ -229,17 +257,21 @@ func (api gateway) list(c fiber.Ctx) error { browsers := make([]browser, 0, len(containers)) for _, container := range containers { - id := container.Labels[idLabel] - if !runtimeIDPattern.MatchString(id) { + alias := container.Labels[idLabel] + if !runtimeIDPattern.MatchString(alias) { continue } + name := container.Labels[nameLabel] + if name == "" { + name = alias + } browsers = append(browsers, browser{ ID: container.ID, - Name: id, + Alias: alias, + Name: name, State: container.State, Status: container.Status, - Profile: "creatorhub-profile-" + id, - Endpoint: "http://" + namePrefix + id + ":9222", + Endpoint: "http://" + namePrefix + alias + ":9222", }) } return writeJSON(c, http.StatusOK, browsers) @@ -250,26 +282,30 @@ func (api gateway) create(c fiber.Ctx) error { decoder := json.NewDecoder(bytes.NewReader(c.Body())) decoder.DisallowUnknownFields() if err := decoder.Decode(&input); err != nil { - return writeError(c, http.StatusBadRequest, errors.New("body must contain only name and numeric seed")) + return writeError(c, http.StatusBadRequest, errors.New("body must contain only alias, name, image, cmd and volume")) } - if !runtimeIDPattern.MatchString(input.Name) || input.Seed < 1 || input.Seed > 2147483647 { - return writeError(c, http.StatusBadRequest, errors.New("name must match [a-z0-9][a-z0-9-]{0,31} and seed must be 1..2147483647")) + if err := validateCreate(input); err != nil { + return writeError(c, http.StatusBadRequest, err) + } + if err := api.docker.pullIfMissing(c.Context(), input.Image); err != nil { + return writeError(c, http.StatusBadGateway, err) } pidsLimit := int64(512) payload := map[string]any{ - "Image": api.config.image, + "Image": input.Image, "User": browserUser, "Entrypoint": []string{browserEntrypoint}, - "Cmd": []string{"--fingerprint=" + strconv.FormatInt(input.Seed, 10), "about:blank"}, + "Cmd": input.Cmd, "Env": []string{"REMOTE_DEBUGGING_PORT=9222"}, "Labels": map[string]string{ managedLabel: "true", - idLabel: input.Name, + idLabel: input.Alias, + nameLabel: input.Name, }, "ExposedPorts": map[string]any{"9222/tcp": map[string]any{}}, "HostConfig": map[string]any{ - "NetworkMode": api.config.network, + "NetworkMode": api.network, "ReadonlyRootfs": true, "CapDrop": []string{"ALL"}, "SecurityOpt": []string{"no-new-privileges"}, @@ -284,12 +320,12 @@ func (api gateway) create(c fiber.Ctx) error { }, "Mounts": []map[string]any{{ "Type": "volume", - "Source": "creatorhub-profile-" + input.Name, + "Source": input.Volume, "Target": "/data", }}, }, } - result, err := api.docker.request(http.MethodPost, "/containers/create?name="+url.QueryEscape(namePrefix+input.Name), payload) + result, err := api.docker.request(http.MethodPost, "/containers/create?name="+url.QueryEscape(namePrefix+input.Alias), payload) if err != nil { return writeError(c, http.StatusBadGateway, err) } @@ -301,7 +337,7 @@ func (api gateway) create(c fiber.Ctx) error { ID string `json:"Id"` } if err := json.NewDecoder(result.Body).Decode(&created); err != nil || created.ID == "" { - cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(namePrefix+input.Name)+"?force=1&v=0", nil, http.StatusNoContent, http.StatusNotFound) + cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(namePrefix+input.Alias)+"?force=1&v=0", nil, http.StatusNoContent, http.StatusNotFound) if cleanupErr != nil { return writeError(c, http.StatusBadGateway, fmt.Errorf("Docker returned an invalid container id and cleanup failed: %w", cleanupErr)) } @@ -314,7 +350,45 @@ func (api gateway) create(c fiber.Ctx) error { } return writeError(c, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err)) } - return writeJSON(c, http.StatusCreated, map[string]string{"id": created.ID, "name": input.Name}) + return writeJSON(c, http.StatusCreated, map[string]string{"id": created.ID, "alias": input.Alias}) +} + +func validateCreate(input createRequest) error { + if !runtimeIDPattern.MatchString(input.Alias) { + return errors.New("alias must match [a-z0-9][a-z0-9-]{0,31}") + } + if input.Name == "" || utf8.RuneCountInString(input.Name) > 64 || hasControlRunes(input.Name) { + return errors.New("name must be 1..64 visible characters") + } + if !imageRefPattern.MatchString(input.Image) { + return errors.New("image must be a valid image reference") + } + if !volumePattern.MatchString(input.Volume) { + return errors.New("volume must be a valid volume name") + } + if len(input.Cmd) == 0 || len(input.Cmd) > 64 { + return errors.New("cmd must contain 1..64 arguments") + } + total := 0 + for _, arg := range input.Cmd { + if arg == "" || hasControlRunes(arg) { + return errors.New("cmd arguments must be non-empty visible strings") + } + total += len(arg) + } + if total > 4096 { + return errors.New("cmd arguments exceed 4096 characters") + } + return nil +} + +func hasControlRunes(value string) bool { + for _, r := range value { + if r < 0x20 || r == 0x7f { + return true + } + } + return false } func (api gateway) changeState(c fiber.Ctx) error { @@ -381,6 +455,58 @@ func (api gateway) requireManaged(id string) error { return nil } +// pullIfMissing 在镜像不在本地时从远端仓库拉取;镜像缺失属于可恢复错误,调用方可直接重试。 +func (docker dockerClient) pullIfMissing(ctx context.Context, ref string) error { + inspect, err := docker.request(http.MethodGet, "/images/"+url.PathEscape(ref)+"/json", nil) + if err != nil { + return fmt.Errorf("inspect image %s: %w", ref, err) + } + _, _ = io.Copy(io.Discard, inspect.Body) + _ = inspect.Body.Close() + switch inspect.StatusCode { + case http.StatusOK: + return nil + case http.StatusNotFound: + // 本地无此镜像,继续拉取 + default: + return fmt.Errorf("inspect image %s returned %s", ref, inspect.Status) + } + + pullCtx, cancel := context.WithTimeout(ctx, pullTimeout) + defer cancel() + query := url.Values{"fromImage": {ref}} + if !strings.Contains(ref, "@") { + if repository, tag := splitImageRef(ref); tag != "" { + query = url.Values{"fromImage": {repository}, "tag": {tag}} + } + } + request, err := http.NewRequestWithContext(pullCtx, http.MethodPost, docker.baseURL+"/images/create?"+query.Encode(), nil) + if err != nil { + return fmt.Errorf("build image pull request: %w", err) + } + response, err := docker.slow.Do(request) + if err != nil { + return fmt.Errorf("pull image %s: %w", ref, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + return fmt.Errorf("pull image %s returned %s: %s", ref, response.Status, strings.TrimSpace(string(message))) + } + _, _ = io.Copy(io.Discard, response.Body) + return nil +} + +func splitImageRef(ref string) (repository, tag string) { + if at := strings.Index(ref, "@"); at >= 0 { + return ref[:at], ref[at+1:] + } + if colon := strings.LastIndex(ref, ":"); colon > strings.LastIndex(ref, "/") { + return ref[:colon], ref[colon+1:] + } + return ref, "" +} + func (docker dockerClient) request(method, path string, payload any) (*http.Response, error) { var body io.Reader if payload != nil { diff --git a/cmd/docker-gateway/main_test.go b/cmd/docker-gateway/main_test.go index c3c7530..350a9eb 100644 --- a/cmd/docker-gateway/main_test.go +++ b/cmd/docker-gateway/main_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -11,11 +12,39 @@ import ( "github.com/gofiber/fiber/v3/middleware/adaptor" ) -func TestGatewayCreatesConstrainedBrowser(t *testing.T) { +const testToken = "unit-test-gateway-token" + +func authed(method, target string, body io.Reader) *http.Request { + request := httptest.NewRequest(method, target, body) + request.Header.Set("Authorization", "Bearer "+testToken) + return request +} + +func testDocker(handler http.HandlerFunc) (dockerClient, *httptest.Server) { + server := httptest.NewServer(handler) + return dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}, server +} + +func decodeJSONBody(t *testing.T, response *http.Response) map[string]any { + t.Helper() + var body map[string]any + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + t.Fatalf("decode JSON body: %v", err) + } + return body +} + +const testCreateBody = `{"alias":"account-a","name":"账号甲","image":"registry.example/browser:1.2.3",` + + `"cmd":["--fingerprint=1000","--lang=zh-CN","about:blank"],"volume":"creatorhub-profile-account-a"}` + +func TestGatewayCreatesConstrainedBrowserWithPlatformSpec(t *testing.T) { var created map[string]any - docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch { - case request.Method == http.MethodPost && request.URL.Path == "/v1.43/containers/create": + case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"): + response.WriteHeader(http.StatusOK) + _, _ = response.Write([]byte(`{}`)) + case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"): if got := request.URL.Query().Get("name"); got != namePrefix+"account-a" { t.Fatalf("unexpected container name %q", got) } @@ -24,31 +53,31 @@ func TestGatewayCreatesConstrainedBrowser(t *testing.T) { } response.WriteHeader(http.StatusCreated) _, _ = response.Write([]byte(`{"Id":"container-id"}`)) - case request.Method == http.MethodPost && request.URL.Path == "/v1.43/containers/container-id/start": + case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/container-id/start"): response.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } - })) - defer docker.Close() - - handler := newGateway(dockerClient{baseURL: docker.URL + "/v1.43", client: docker.Client()}, browserConfig{ - image: "registry.example/browser:1.2.3", - network: "creatorhub_browser", }) - request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`)) + defer server.Close() + + handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() - adaptor.FiberApp(handler).ServeHTTP(response, request) + adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody))) if response.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String()) } if created["Image"] != "registry.example/browser:1.2.3" { - t.Fatalf("gateway accepted an unexpected image: %#v", created["Image"]) + t.Fatalf("gateway must run the platform-specified image: %#v", created["Image"]) } if created["User"] != browserUser || created["Entrypoint"].([]any)[0] != browserEntrypoint { t.Fatalf("runtime identity is not fixed: user=%#v entrypoint=%#v", created["User"], created["Entrypoint"]) } + cmd := created["Cmd"].([]any) + if len(cmd) != 3 || cmd[0] != "--fingerprint=1000" || cmd[2] != "about:blank" { + t.Fatalf("cmd must be passed through verbatim: %#v", created["Cmd"]) + } host := created["HostConfig"].(map[string]any) if host["NetworkMode"] != "creatorhub_browser" || host["ReadonlyRootfs"] != true { t.Fatalf("missing container isolation: %#v", host) @@ -57,15 +86,123 @@ func TestGatewayCreatesConstrainedBrowser(t *testing.T) { if tmpfs["/tmp/.X11-unix"] == nil || tmpfs["/home/ubuntu"] == nil { t.Fatalf("missing writable runtime paths: %#v", tmpfs) } + mount := host["Mounts"].([]any)[0].(map[string]any) + if mount["Source"] != "creatorhub-profile-account-a" || mount["Target"] != "/data" { + t.Fatalf("profile volume must come from the request: %#v", mount) + } labels := created["Labels"].(map[string]any) - if labels[managedLabel] != "true" || labels[idLabel] != "account-a" { + if labels[managedLabel] != "true" || labels[idLabel] != "account-a" || labels[nameLabel] != "账号甲" { t.Fatalf("missing ownership labels: %#v", labels) } } +func TestGatewayPullsMissingImageOnCreate(t *testing.T) { + tests := []struct { + name string + ref string + fromImage string + tag string + }{{ + name: "tagged ref splits repository and tag", + ref: "registry.example/browser:2.0.0", + fromImage: "registry.example/browser", + tag: "2.0.0", + }, { + name: "digest ref is pulled as a whole", + ref: "registry.example/browser@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7", + fromImage: "registry.example/browser@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7", + tag: "", + }} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pulled := false + docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"): + response.WriteHeader(http.StatusNotFound) + case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/images/create"): + pulled = true + if request.URL.Query().Get("fromImage") != test.fromImage || request.URL.Query().Get("tag") != test.tag { + t.Fatalf("unexpected pull query %s", request.URL.RawQuery) + } + _, _ = response.Write([]byte(`{"status":"Download complete"}`)) + case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"): + response.WriteHeader(http.StatusCreated) + _, _ = response.Write([]byte(`{"Id":"container-id"}`)) + case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/start"): + response.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) + } + }) + defer server.Close() + + handler := newGateway(docker, "creatorhub_browser", testToken) + body := `{"alias":"account-a","name":"账号甲","image":"` + test.ref + + `","cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-account-a"}` + response := httptest.NewRecorder() + adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body))) + + if response.Code != http.StatusCreated || !pulled { + t.Fatalf("expected pull-then-create, status=%d pulled=%v body=%s", response.Code, pulled, response.Body.String()) + } + }) + } +} + +func TestGatewayRejectsCreateWithoutValidToken(t *testing.T) { + docker, server := testDocker(func(http.ResponseWriter, *http.Request) { + t.Fatal("no Docker request is expected for an unauthorized call") + }) + defer server.Close() + + handler := newGateway(docker, "creatorhub_browser", testToken) + for name, header := range map[string]string{ + "missing": "", + "malformed": testToken, + "wrong": "Bearer not-the-token", + } { + request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody)) + if header != "" { + request.Header.Set("Authorization", header) + } + response := httptest.NewRecorder() + adaptor.FiberApp(handler).ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("%s token: expected 401, got %d: %s", name, response.Code, response.Body.String()) + } + } + + response := httptest.NewRecorder() + adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if response.Code != http.StatusNoContent { + t.Fatalf("healthz must stay unauthenticated, got %d", response.Code) + } +} + +func TestGatewayRejectsInvalidCreateRequest(t *testing.T) { + tests := map[string]string{ + "unknown field": `{"alias":"account-a","seed":1}`, + "invalid alias": `{"alias":"AccountA","name":"甲","image":"reg/img:1","cmd":["--fingerprint=1"],"volume":"creatorhub-profile-account-a"}`, + "invalid image": `{"alias":"account-a","name":"甲","image":"","cmd":["--fingerprint=1"],"volume":"creatorhub-profile-account-a"}`, + "empty cmd": `{"alias":"account-a","name":"甲","image":"reg/img:1","cmd":[],"volume":"creatorhub-profile-account-a"}`, + "invalid volume": `{"alias":"account-a","name":"甲","image":"reg/img:1","cmd":["--fingerprint=1"],"volume":"bad volume!"}`, + } + for name, body := range tests { + t.Run(name, func(t *testing.T) { + handler := newGateway(dockerClient{}, "creatorhub_browser", testToken) + response := httptest.NewRecorder() + adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body))) + if response.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", response.Code, response.Body.String()) + } + }) + } +} + func TestGatewayRejectsOversizedCreateRequest(t *testing.T) { - handler := newGateway(dockerClient{}, browserConfig{}) - request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(strings.Repeat("x", (1<<20)+1))) + handler := newGateway(dockerClient{}, "creatorhub_browser", testToken) + request := authed(http.MethodPost, "/v1/browsers", strings.NewReader(strings.Repeat("x", (1<<20)+1))) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, request) if response.Code != http.StatusRequestEntityTooLarge { @@ -88,24 +225,26 @@ func TestGatewayRejectsOversizedCreateRequest(t *testing.T) { func TestGatewayRemovesContainerWhenCreateResponseHasNoID(t *testing.T) { removed := false - docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch { - case request.Method == http.MethodPost && request.URL.Path == "/containers/create": + case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"): + response.WriteHeader(http.StatusOK) + _, _ = response.Write([]byte(`{}`)) + case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"): response.WriteHeader(http.StatusCreated) _, _ = response.Write([]byte(`{"Id":""}`)) - case request.Method == http.MethodDelete && request.URL.Path == "/containers/"+namePrefix+"account-a": + case request.Method == http.MethodDelete && strings.Contains(request.URL.Path, namePrefix+"account-a"): removed = request.URL.Query().Get("force") == "1" && request.URL.Query().Get("v") == "0" response.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } - })) - defer docker.Close() + }) + defer server.Close() - handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{image: browserImage, network: "creatorhub_browser"}) - request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`)) + handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() - adaptor.FiberApp(handler).ServeHTTP(response, request) + adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody))) if response.Code != http.StatusBadGateway || !removed { t.Fatalf("expected invalid create response cleanup, status=%d removed=%v body=%s", response.Code, removed, response.Body.String()) @@ -114,26 +253,28 @@ func TestGatewayRemovesContainerWhenCreateResponseHasNoID(t *testing.T) { func TestGatewayRemovesFailedContainerAndPreservesProfile(t *testing.T) { removed := false - docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch { - case request.Method == http.MethodPost && request.URL.Path == "/containers/create": + case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"): + response.WriteHeader(http.StatusOK) + _, _ = response.Write([]byte(`{}`)) + case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"): response.WriteHeader(http.StatusCreated) _, _ = response.Write([]byte(`{"Id":"failed-id"}`)) - case request.Method == http.MethodPost && request.URL.Path == "/containers/failed-id/start": + case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/failed-id/start"): http.Error(response, "start failed", http.StatusInternalServerError) - case request.Method == http.MethodDelete && request.URL.Path == "/containers/failed-id": + case request.Method == http.MethodDelete && strings.Contains(request.URL.Path, "/containers/failed-id"): removed = request.URL.Query().Get("force") == "1" && request.URL.Query().Get("v") == "0" response.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } - })) - defer docker.Close() + }) + defer server.Close() - handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{image: browserImage, network: "creatorhub_browser"}) - request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`)) + handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() - adaptor.FiberApp(handler).ServeHTTP(response, request) + adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody))) if response.Code != http.StatusBadGateway || !removed { t.Fatalf("expected failed container cleanup with preserved volume, status=%d removed=%v body=%s", response.Code, removed, response.Body.String()) @@ -141,20 +282,23 @@ func TestGatewayRemovesFailedContainerAndPreservesProfile(t *testing.T) { } func TestGatewayListsBrowsers(t *testing.T) { - docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { if request.Method != http.MethodGet || request.URL.Path != "/containers/json" { t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } - _, _ = response.Write([]byte(`[{"Id":"container-id","State":"running","Status":"Up","Labels":{"io.creatorhub.runtime-id":"account-a"}}]`)) - })) - defer docker.Close() + _, _ = response.Write([]byte(`[{"Id":"container-id","State":"running","Status":"Up","Labels":{` + + `"` + idLabel + `":"account-a","` + nameLabel + `":"账号甲"}}]`)) + }) + defer server.Close() - handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{}) + handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() - adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/browsers", nil)) + adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodGet, "/v1/browsers", nil)) var browsers []browser - if response.Code != http.StatusOK || json.NewDecoder(response.Body).Decode(&browsers) != nil || len(browsers) != 1 || browsers[0].Name != "account-a" { + if response.Code != http.StatusOK || json.NewDecoder(response.Body).Decode(&browsers) != nil || + len(browsers) != 1 || browsers[0].Alias != "account-a" || browsers[0].Name != "账号甲" || + browsers[0].Endpoint != "http://creatorhub-browser-account-a:9222" { t.Fatalf("unexpected list response status=%d body=%s", response.Code, response.Body.String()) } } @@ -171,21 +315,21 @@ func TestGatewayLifecycle(t *testing.T) { } for _, test := range tests { t.Run(test.method+" "+test.path, func(t *testing.T) { - docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { if request.Method == http.MethodGet { - _, _ = response.Write([]byte(`{"Config":{"Labels":{"io.creatorhub.managed":"true","io.creatorhub.runtime-id":"account-a"}}}`)) + _, _ = response.Write([]byte(`{"Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a"}}}`)) return } if request.URL.Path != test.dockerPath { t.Fatalf("unexpected Docker path %s", request.URL.String()) } response.WriteHeader(http.StatusNoContent) - })) - defer docker.Close() + }) + defer server.Close() - handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{}) + handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() - adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil)) + adaptor.FiberApp(handler).ServeHTTP(response, authed(test.method, test.path, nil)) if response.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String()) } @@ -194,14 +338,14 @@ func TestGatewayLifecycle(t *testing.T) { } func TestGatewayMapsDockerServiceFailureToBadGateway(t *testing.T) { - docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + docker, server := testDocker(func(response http.ResponseWriter, _ *http.Request) { http.Error(response, "daemon unavailable", http.StatusInternalServerError) - })) - defer docker.Close() + }) + defer server.Close() - handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{}) + handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() - adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/v1/browsers/account-a", nil)) + adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", nil)) if response.Code != http.StatusBadGateway { t.Fatalf("expected 502 for Docker failure, got %d: %s", response.Code, response.Body.String()) } @@ -209,7 +353,7 @@ func TestGatewayMapsDockerServiceFailureToBadGateway(t *testing.T) { func TestGatewayRefusesUnmanagedContainer(t *testing.T) { deleted := false - docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch request.Method { case http.MethodGet: _, _ = response.Write([]byte(`{"Config":{"Labels":{}}}`)) @@ -217,11 +361,11 @@ func TestGatewayRefusesUnmanagedContainer(t *testing.T) { deleted = true response.WriteHeader(http.StatusNoContent) } - })) - defer docker.Close() + }) + defer server.Close() - handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{}) - request := httptest.NewRequest(http.MethodDelete, "/v1/browsers/foreign", nil) + handler := newGateway(docker, "creatorhub_browser", testToken) + request := authed(http.MethodDelete, "/v1/browsers/foreign", nil) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, request) @@ -236,7 +380,7 @@ func TestEnsureBrowserNetwork(t *testing.T) { Driver string `json:"Driver"` Labels map[string]string `json:"Labels"` } - docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch request.Method { case http.MethodGet: response.WriteHeader(http.StatusNotFound) @@ -246,11 +390,10 @@ func TestEnsureBrowserNetwork(t *testing.T) { } response.WriteHeader(http.StatusCreated) } - })) - defer docker.Close() + }) + defer server.Close() - client := dockerClient{baseURL: docker.URL, client: docker.Client()} - if err := client.ensureBrowserNetwork("creatorhub_browser"); err != nil { + if err := docker.ensureBrowserNetwork("creatorhub_browser"); err != nil { t.Fatal(err) } if created.Name != "creatorhub_browser" || created.Driver != "bridge" || @@ -271,12 +414,11 @@ func TestEnsureBrowserNetworkRejectsUnsafeExistingNetwork(t *testing.T) { networkRoleLabel: browserNetworkRole, }, } - validDocker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + validDocker, validServer := testDocker(func(response http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(response).Encode(valid) - })) - defer validDocker.Close() - client := dockerClient{baseURL: validDocker.URL, client: validDocker.Client()} - if err := client.ensureBrowserNetwork("creatorhub_browser"); err != nil { + }) + defer validServer.Close() + if err := validDocker.ensureBrowserNetwork("creatorhub_browser"); err != nil { t.Fatalf("expected owned bridge network to be accepted: %v", err) } @@ -301,13 +443,12 @@ func TestEnsureBrowserNetworkRejectsUnsafeExistingNetwork(t *testing.T) { network[key] = value } mutate(network) - docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + docker, server := testDocker(func(response http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(response).Encode(network) - })) - defer docker.Close() + }) + defer server.Close() - client := dockerClient{baseURL: docker.URL, client: docker.Client()} - if err := client.ensureBrowserNetwork("creatorhub_browser"); err == nil { + if err := docker.ensureBrowserNetwork("creatorhub_browser"); err == nil { t.Fatal("expected unsafe existing network to be rejected") } }) @@ -316,16 +457,23 @@ func TestEnsureBrowserNetworkRejectsUnsafeExistingNetwork(t *testing.T) { func TestEnsureBrowserNetworkRejectsControlNetwork(t *testing.T) { requested := false - docker := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requested = true })) - defer docker.Close() + docker, server := testDocker(func(http.ResponseWriter, *http.Request) { requested = true }) + defer server.Close() - client := dockerClient{baseURL: docker.URL, client: docker.Client()} - if err := client.ensureBrowserNetwork(controlNetworkName); err == nil || requested { + if err := docker.ensureBrowserNetwork(controlNetworkName); err == nil || requested { t.Fatalf("expected control network to be rejected before Docker request, requested=%v err=%v", requested, err) } } +func TestLoadConfigRequiresGatewayToken(t *testing.T) { + t.Setenv("GATEWAY_TOKEN", "short") + if _, err := loadConfig(); err == nil { + t.Fatal("expected a short gateway token to be rejected") + } +} + func TestLoadConfigRejectsControlNetwork(t *testing.T) { + t.Setenv("GATEWAY_TOKEN", testToken) t.Setenv("BROWSER_NETWORK", controlNetworkName) if _, err := loadConfig(); err == nil { t.Fatal("expected control network configuration to be rejected") diff --git a/compose.yaml b/compose.yaml index 78af5aa..917ddb9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,7 +2,6 @@ services: creator-hub: build: . environment: - DOCKER_GATEWAY_URL: http://docker-gateway:8081 DATABASE_URL: postgres://creatorhub@postgres/creatorhub?sslmode=disable ports: - "${CREATORHUB_PORT:-8080}:8080" @@ -47,6 +46,7 @@ services: command: ["/app/docker-gateway"] environment: BROWSER_NETWORK: creatorhub_browser + GATEWAY_TOKEN: ${GATEWAY_TOKEN:-dev-creatorhub-gateway-token} volumes: - /var/run/docker.sock:/var/run/docker.sock:ro group_add: diff --git a/docs/architecture/container-control.md b/docs/architecture/container-control.md index 38fb9af..0ab20b9 100644 --- a/docs/architecture/container-control.md +++ b/docs/architecture/container-control.md @@ -2,33 +2,41 @@ ## 技术选型 -- 前端:React + Vite。阶段 A 只有一个运行环境页面,不引入路由、状态库或组件库。 -- 后端:Go 模块化单体,Fiber v3 提供 HTTP 路由,Viper 读取并校验启动配置,Logrus 输出 JSON 结构化日志,Cobra 保持当前两个服务入口。控制面提供同源 API 和静态文件,受限网关单独封装 Docker Engine API。 -- 数据:Docker 容器标签仍是运行态事实,Profile 使用命名卷持久化;阶段 A 账号、凭据引用、确认、任务、尝试和审计实体由控制面使用原生 SQL 持久化到 Postgres。 -- 部署:Docker Compose 启动控制面和受限网关;浏览器容器由网关动态创建。 +- 前端:React 19 + react-admin(ra-core)+ MUI,包含运行环境、镜像版本、网关管理三个页面。 +- 后端:Go 模块化单体,Fiber v3 提供 HTTP 路由,Viper 读取并校验启动配置,Logrus 输出 JSON 结构化日志,Cobra 保持当前两个服务入口。控制面提供同源 API 和静态文件,并编排网关;受限网关单独封装 Docker Engine API,是纯执行器。 +- 数据:环境配置(别名、中文名、网关、镜像版本、指纹参数)持久化在 Postgres,运行态实时查询网关;Profile 使用命名卷持久化;阶段 A 账号、凭据引用、确认、任务、尝试和审计实体同样由控制面持久化到 Postgres。 +- 部署:Docker Compose 启动控制面和受限网关;浏览器容器由网关按平台下发的镜像引用动态创建,缺失时自动拉取。 ## 调用链与契约 ```text -React ──> control-plane ── /api/browsers ──> docker-gateway ──> docker.sock - │ │ - │ └─> browser container - └─ /api/phase-a ──> PostgreSQL +React ──> control-plane ── /api/browsers ──(Bearer token)──> docker-gateway ──> docker.sock + │ │ + │ └─> browser container + └─ /api/phase-a, /api/browser-images, /api/gateways ──> PostgreSQL ``` -`POST /api/browsers` 接受 `{name, seed}` 并创建后启动;`GET /api/browsers` 列出受管容器;`POST /api/browsers/{name}/start|stop` 改变状态;`DELETE /api/browsers/{name}` 仅回收容器,保留 Profile 数据卷。名称唯一约束由 Docker 容器名保证,重复创建返回冲突;启停接受 Docker 的幂等响应,不自动重试未知结果。 +控制面是唯一事实源:网关不持有镜像清单和业务规则,镜像引用、启动命令和卷名均随请求下发。 + +- `POST /api/browsers` 接受 `{alias, name, gateway, image_version, fingerprint}`(严格 JSON,未知字段拒绝),校验后先落库,再调网关创建并启动;网关失败时回滚数据库行。`name` 为中文环境名,`alias` 限 `^[a-z0-9][a-z0-9-]{0,31}$`,容器名 `creatorhub-browser-`,Profile 卷 `creatorhub-profile-`。 +- `GET /api/browsers` 合并数据库环境与网关实时状态;环境在网关无容器时显示为未部署。 +- `POST /api/browsers/{alias}/start|stop` 改变状态;`POST /api/browsers/{alias}/upgrade` 收 `{version}`,由平台编排:停止并删除旧容器(保留 Profile 卷)→ 用新镜像引用与原指纹参数重建 → 启动;失败直接重试,不做自动回滚。 +- `DELETE /api/browsers/{alias}` 回收容器并删除数据库行,保留 Profile 数据卷。 +- `GET/POST /api/browser-images` 维护可用镜像版本(版本号不可改,`PUT /{version}` 仅接受 `image_ref/note/enabled`);仅启用版本可用于创建与升级;被环境引用时拒绝删除。 +- `GET/POST /api/gateways` 注册网关(`POST` 可携带令牌,否则平台生成 48 位十六进制令牌并明文存储),`DELETE /api/gateways/{name}` 删除;仍被环境引用时拒绝删除。 +- 启停接受幂等响应,不自动重试未知结果;别名唯一约束由数据库保证。 ## docker.sock 安全边界 将 socket 以只读文件挂载**不会**限制 Docker API 的写操作;拥有 socket 等价于拥有宿主机 root 权限。因此: - 只有 `docker-gateway` 挂载 socket,控制面和浏览器容器均不可见;网关只加入 control 网络,浏览器不能连接网关; -- 网关只暴露四个面向领域的路由,不提供通用 Docker 代理; -- 镜像、命令、网络、挂载和资源限制均由网关固定,外部输入只有受校验的名称和数字 seed; +- 网关只暴露面向领域的路由,不提供通用 Docker 代理;`/v1` 全部接口校验 `Authorization: Bearer `(常数时间比较),令牌由部署者在网关环境变量与平台注册表中保持一致; +- 网关固定命令、网络、挂载和资源限制;外部输入是受校验的别名,以及平台下发的镜像引用、启动参数和卷名——镜像引用来自平台维护的版本表,新增/变更由人工在页面审核启用,不再写死在代码中; - 启停和删除前必须同时匹配固定名称前缀及 `io.creatorhub.managed`、`io.creatorhub.runtime-id` 标签; -- 动态容器使用只读根文件系统、非 root 镜像、全部 capability drop、`no-new-privileges`、CPU/内存/PID 限制,且无宿主机端口和目录挂载; +- 动态容器使用只读根文件系统、非 root `1000:1000` 与固定镜像入口、全部 capability drop、`no-new-privileges`、CPU/内存/PID 限制,且无宿主机端口和目录挂载; - 控制面发布到宿主机所有网卡;控制网络为固定名称的 Compose 网络;浏览器 bridge 按 ownership、role、driver、Internal 失败关闭校验,且拒绝复用 control 网络; -- 浏览器镜像固定为审核过的 immutable digest;运行时创建请求同时固定非 root `1000:1000` 与镜像入口,构建阶段和最终基础镜像也均锁定 digest。 +- Compose 基础镜像锁定 digest;浏览器镜像推荐使用 `@sha256:` 摘要引用以获得不可变性,tag 引用由部署者自行把控。 网关自身一旦被攻破,socket 仍允许接管宿主机;应用内校验不能消除这个平台级风险。开发阶段控制面不做认证或访问限制,安全由部署者自行把控。 @@ -37,13 +45,13 @@ React ──> control-plane ── /api/browsers ──> docker-gateway ──> Docker socket 的 GID 因宿主机而异: ```bash -docker pull git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7 +export GATEWAY_TOKEN="$(openssl rand -hex 24)" # 亦可在 .env 中设置 DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build ``` -打开 ,局域网内用宿主机 IP 访问同一端口。浏览器镜像固定为 `git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7`。浏览器容器可访问外网。 +打开 ,局域网内用宿主机 IP 访问同一端口。首次使用:在「网关管理」用 `GATEWAY_TOKEN` 注册 `http://docker-gateway:8081`,在「镜像版本」添加镜像引用(缺失时网关自动拉取,拉取上限 10 分钟)。浏览器容器可访问外网。 -创建成功但启动失败时,网关会立即删除失败容器并保留命名 Profile 卷,允许同名请求安全重试。 +创建成功但启动失败时,网关会立即删除失败容器并保留命名 Profile 卷,控制面回滚数据库行,允许同名请求安全重试。 ## 阶段 A 离线闭环 diff --git a/docs/deployment.md b/docs/deployment.md index cdab741..7258997 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -9,7 +9,7 @@ - `creator-hub`:Go 1.26 控制面,同时提供 React 19/Vite 8 构建的静态页面; - `docker-gateway`:受限 Docker API 网关,是唯一挂载 `/var/run/docker.sock` 的服务; - `postgres`:PostgreSQL 17,数据保存在 `creatorhub_postgres` 命名卷; -- 浏览器容器:由网关按需创建,使用固定摘要的指纹浏览器镜像,Profile 保存在 `creatorhub-profile-<运行时名称>` 命名卷。 +- 浏览器容器:由网关按需创建,镜像引用由平台「镜像版本」页配置(缺失时网关自动拉取),Profile 保存在 `creatorhub-profile-<别名>` 命名卷。 控制面发布到宿主机所有网卡,局域网内可直接访问;浏览器容器可访问外网。 `creator-hub` 会等待 `docker-gateway` 健康检查通过后再启动。 @@ -20,7 +20,7 @@ - Docker Engine 26 或兼容版本; - Docker Compose v2; - 当前用户可访问 Docker daemon; -- 可访问 `git.ipao.vip`,并已完成私有镜像仓库登录(如仓库要求认证); +- 可访问镜像仓库(如 `git.ipao.vip`),并已完成登录(如仓库要求认证);镜像也可不在宿主机预拉取,网关会在缺失时按引用自动拉取; - `curl`,用于部署后检查。 在仓库根目录执行预检: @@ -39,14 +39,22 @@ docker compose config --quiet ```bash export DOCKER_GID="$(stat -c '%g' /var/run/docker.sock)" export CREATORHUB_PORT=8080 +export GATEWAY_TOKEN="$(openssl rand -hex 24)" # 亦可在 .env 中设置 -docker pull git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7 docker compose config --quiet docker compose up --detach --build ``` 控制面启动时会连接 PostgreSQL,并在事务和 advisory lock 保护下自动执行前向迁移。迁移失败时控制面会退出,由 Compose 按 `restart: unless-stopped` 重启;先检查日志,不要删除数据卷。 +### 首次配置 + +服务起来后打开 : + +1. 「网关管理」页注册网关:名称如 `gw-main`,Endpoint `http://docker-gateway:8081`,令牌填 `GATEWAY_TOKEN` 的值(即 `openssl rand -hex 24` 生成的值)。 +2. 「镜像版本」页添加可用镜像,如版本 `148.0.7778.215`、引用 `git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215`(或 `@sha256:` 摘要引用)。 +3. 「运行环境」页创建环境:中文名 + 小写别名 + 指纹参数,容器名 `creatorhub-browser-<别名>`,Profile 卷 `creatorhub-profile-<别名>`。 + ## 部署验证 ```bash @@ -60,13 +68,13 @@ curl --fail --silent --show-error \ docker compose exec -T postgres \ psql -U creatorhub -d creatorhub -tAc \ - 'SELECT 1 FROM schema_migration WHERE version = 1;' \ - | grep -qx 1 + 'SELECT 1 FROM schema_migration WHERE version = 2;' \ + | grep -qx 2 docker compose ps ``` -健康检查应成功,浏览器列表接口应返回 JSON,迁移查询当前应输出 `1`,三个 Compose 服务应为运行状态。然后访问 ;修改过 `CREATORHUB_PORT` 时使用对应端口。 +健康检查应成功,浏览器列表接口应返回 JSON,迁移查询当前应输出 `2`,三个 Compose 服务应为运行状态。然后访问 ;修改过 `CREATORHUB_PORT` 时使用对应端口。 排障时读取结构化服务日志: @@ -82,19 +90,20 @@ Compose 部署时通常只需设置以下宿主机变量: | --- | --- | --- | | `CREATORHUB_PORT` | `8080` | 控制面宿主机端口,局域网可访问 | | `DOCKER_GID` | `999` | Docker socket 的宿主机组 ID;必须按实际值设置 | +| `GATEWAY_TOKEN` | `dev-creatorhub-gateway-token` | 网关与控制面共享的 Bearer 令牌;生产须改为随机值,并同步填入网关注册表单 | 服务本身支持并校验以下环境变量;`compose.yaml` 已提供当前部署所需的值: | 服务 | 变量 | 当前 Compose 值 | | --- | --- | --- | | `creator-hub` | `LISTEN_ADDR` | 默认 `:8080` | -| `creator-hub` | `DOCKER_GATEWAY_URL` | `http://docker-gateway:8081` | | `creator-hub` | `WEB_DIR` | 镜像内固定为 `/app/web` | | `creator-hub` | `DATABASE_URL` | `postgres://creatorhub@postgres/creatorhub?sslmode=disable` | | `creator-hub` | `LOG_LEVEL` | 默认 `info` | | `docker-gateway` | `LISTEN_ADDR` | 默认 `:8081` | | `docker-gateway` | `DOCKER_SOCKET` | 默认值和 Compose 挂载均固定为 `/var/run/docker.sock`;不能只覆盖环境变量 | | `docker-gateway` | `BROWSER_NETWORK` | `creatorhub_browser` | +| `docker-gateway` | `GATEWAY_TOKEN` | 与平台注册值一致,长度 ≥16;`/v1` 全部接口校验 Bearer 令牌 | | `docker-gateway` | `LOG_LEVEL` | 默认 `info` | 不要把凭据写入仓库或 Compose 文件。 @@ -123,11 +132,12 @@ set -Eeuo pipefail git pull --ff-only export DOCKER_GID="$(stat -c '%g' /var/run/docker.sock)" export CREATORHUB_PORT=8080 -docker pull git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7 docker compose config --quiet docker compose up --detach --build ``` +镜像版本升级在页面「运行环境 → 升级」完成:平台会停止并删除旧容器(保留 Profile 卷),用新镜像引用与原指纹参数重建后启动;失败时直接重试即可,无需回滚。 + 数据库迁移只支持安全前进,不提供自动破坏性回滚。需要同时恢复旧代码和更新前数据库时,修改下面两个变量后**整块执行一次**;不要逐行或拆块执行。预检、恢复演练、动态容器停止、停服、主库恢复、提交切换和启动都位于同一个 fail-fast subshell 中。 ```bash @@ -154,8 +164,8 @@ docker compose up --detach --build < "$BACKUP_FILE" docker compose exec -T postgres \ psql -U creatorhub -d creatorhub_restore_check -v ON_ERROR_STOP=1 -tAc \ - 'SELECT 1 FROM schema_migration WHERE version = 1;' \ - | grep -qx 1 + 'SELECT 1 FROM schema_migration WHERE version = 2;' \ + | grep -qx 2 docker compose exec -T postgres \ dropdb --force -U creatorhub creatorhub_restore_check @@ -201,8 +211,8 @@ docker compose up --detach --build < "$BACKUP_FILE" docker compose exec -T postgres \ psql -U creatorhub -d creatorhub -v ON_ERROR_STOP=1 -tAc \ - 'SELECT 1 FROM schema_migration WHERE version = 1;' \ - | grep -qx 1 + 'SELECT 1 FROM schema_migration WHERE version = 2;' \ + | grep -qx 2 git switch --detach "$RESTORE_REV" if ! docker compose up --detach --build; then diff --git a/internal/hub/fingerprint.go b/internal/hub/fingerprint.go new file mode 100644 index 0000000..bd52b7e --- /dev/null +++ b/internal/hub/fingerprint.go @@ -0,0 +1,135 @@ +package hub + +import ( + "errors" + "net/url" + "regexp" + "strconv" + "strings" +) + +// Fingerprint 是 fingerprint-chromium 的结构化启动参数集合。 +// 参数契约来源:https://github.com/adryfish/fingerprint-chromium(BSD-3-clause)。 +// 零值字段表示不传递该命令行开关。 +type Fingerprint struct { + Seed int64 `json:"seed"` + Platform string `json:"platform,omitempty"` + PlatformVersion string `json:"platform_version,omitempty"` + Brand string `json:"brand,omitempty"` + BrandVersion string `json:"brand_version,omitempty"` + HardwareConcurrency int64 `json:"hardware_concurrency,omitempty"` + Lang string `json:"lang,omitempty"` + AcceptLang string `json:"accept_lang,omitempty"` + Timezone string `json:"timezone,omitempty"` + ProxyServer string `json:"proxy_server,omitempty"` + DisableNonProxiedUDP bool `json:"disable_non_proxied_udp,omitempty"` + DisableSpoofing string `json:"disable_spoofing,omitempty"` +} + +var ( + platforms = map[string]bool{"windows": true, "linux": true, "macos": true} + brands = map[string]bool{"Chrome": true, "Edge": true, "Opera": true, "Vivaldi": true} + spoofings = map[string]bool{"font": true, "audio": true, "canvas": true, "clientrects": true, "gpu": true} + langPattern = regexp.MustCompile(`^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})?$`) + acceptLangPattern = regexp.MustCompile( + `^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})?(, ?[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*){0,7}$`) + timezonePattern = regexp.MustCompile(`^[A-Za-z0-9_+\-/]{1,64}$`) + shortVersionPatten = regexp.MustCompile(`^[A-Za-z0-9._+~-]{1,32}$`) +) + +// Validate 校验全量指纹参数;只做值域校验,参数作为独立 argv 传入容器,无 shell 注入面。 +func (f Fingerprint) Validate() error { + if f.Seed < 1 || f.Seed > 2147483647 { + return errors.New("seed must be 1..2147483647") + } + if !optionalIn(f.Platform, platforms) { + return errors.New("platform must be one of windows, linux, macos") + } + if !optionalIn(f.Brand, brands) { + return errors.New("brand must be one of Chrome, Edge, Opera, Vivaldi") + } + if !optionalMatch(f.PlatformVersion, shortVersionPatten) { + return errors.New("platform_version must be a short version like 11.0.0") + } + if !optionalMatch(f.BrandVersion, shortVersionPatten) { + return errors.New("brand_version must be a short version like 132.0.6834.159") + } + if f.HardwareConcurrency < 0 || f.HardwareConcurrency > 128 { + return errors.New("hardware_concurrency must be 0..128, 0 omits the flag") + } + if !optionalMatch(f.Lang, langPattern) { + return errors.New("lang must be a language code like zh-CN") + } + if !optionalMatch(f.AcceptLang, acceptLangPattern) { + return errors.New("accept_lang must be a comma separated language list like zh-CN,en-US") + } + if !optionalMatch(f.Timezone, timezonePattern) { + return errors.New("timezone must be an IANA timezone like Asia/Shanghai") + } + if f.ProxyServer != "" && !validProxyURL(f.ProxyServer) { + return errors.New("proxy_server must be an http/https/socks4/socks5 URL with a host") + } + if f.DisableSpoofing != "" && !validDisableSpoofing(f.DisableSpoofing) { + return errors.New("disable_spoofing must be a comma separated subset of font,audio,canvas,clientrects,gpu") + } + return nil +} + +// Args 生成 fingerprint-chromium 命令行参数(不含 URL 尾参)。 +func (f Fingerprint) Args() []string { + args := []string{"--fingerprint=" + strconv.FormatInt(f.Seed, 10)} + value := func(flag, setting string) { + if setting != "" { + args = append(args, "--"+flag+"="+setting) + } + } + value("fingerprint-platform", f.Platform) + value("fingerprint-platform-version", f.PlatformVersion) + value("fingerprint-brand", f.Brand) + value("fingerprint-brand-version", f.BrandVersion) + if f.HardwareConcurrency > 0 { + value("fingerprint-hardware-concurrency", strconv.FormatInt(f.HardwareConcurrency, 10)) + } + value("lang", f.Lang) + value("accept-lang", f.AcceptLang) + value("timezone", f.Timezone) + value("proxy-server", f.ProxyServer) + if f.DisableNonProxiedUDP { + args = append(args, "--disable-non-proxied-udp") + } + value("disable-spoofing", f.DisableSpoofing) + return args +} + +func optionalIn(value string, allowed map[string]bool) bool { + return value == "" || allowed[value] +} + +func optionalMatch(value string, pattern *regexp.Regexp) bool { + return value == "" || pattern.MatchString(value) +} + +func validProxyURL(raw string) bool { + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return false + } + switch parsed.Scheme { + case "http", "https", "socks4", "socks5": + return true + default: + return false + } +} + +func validDisableSpoofing(value string) bool { + seen := make(map[string]bool, len(spoofings)) + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if !spoofings[part] || seen[part] { + return false + } + seen[part] = true + } + return true +} diff --git a/internal/hub/migrations/002_hub.sql b/internal/hub/migrations/002_hub.sql new file mode 100644 index 0000000..822b630 --- /dev/null +++ b/internal/hub/migrations/002_hub.sql @@ -0,0 +1,25 @@ +CREATE TABLE gateway ( + name text PRIMARY KEY CHECK (name <> '' AND length(name) <= 64), + endpoint text NOT NULL, + token text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE browser_image ( + version text PRIMARY KEY CHECK (version ~ '^[0-9][A-Za-z0-9.+~-]{0,63}$'), + image_ref text NOT NULL CHECK (image_ref ~ '^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$'), + note text NOT NULL DEFAULT '' CHECK (length(note) <= 200), + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE browser_env ( + alias text PRIMARY KEY CHECK (alias ~ '^[a-z0-9][a-z0-9-]{0,31}$'), + name text NOT NULL CHECK (name <> '' AND length(name) <= 64), + gateway_name text NOT NULL REFERENCES gateway(name), + image_version text NOT NULL REFERENCES browser_image(version), + fingerprint jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); diff --git a/internal/hub/store.go b/internal/hub/store.go new file mode 100644 index 0000000..f08e4eb --- /dev/null +++ b/internal/hub/store.go @@ -0,0 +1,404 @@ +package hub + +import ( + "context" + "crypto/rand" + "database/sql" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5/pgconn" + _ "github.com/jackc/pgx/v5/stdlib" +) + +//go:embed migrations/002_hub.sql +var migration002 string + +var ( + ErrConflict = errors.New("resource conflicts with existing state") + ErrInvalid = errors.New("invalid hub input") + ErrNotFound = errors.New("resource not found") +) + +var ( + aliasPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) + gatewayNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + tokenPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$`) + imageVersionPattern = regexp.MustCompile(`^[0-9][A-Za-z0-9.+~-]{0,63}$`) + imageRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$`) +) + +type Store struct{ db *sql.DB } + +// Gateway 是平台注册的 docker-gateway 实例;Token 由平台生成,明文存储供页面复制(开发阶段约定)。 +type Gateway struct { + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Token string `json:"token"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Image 是页面维护的可用浏览器镜像版本。 +type Image struct { + Version string `json:"version"` + ImageRef string `json:"image_ref"` + Note string `json:"note"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Env 是一个浏览器环境;Fingerprint 为全量启动参数,创建后随请求整体下发网关。 +type Env struct { + Alias string `json:"alias"` + Name string `json:"name"` + Gateway string `json:"gateway"` + ImageVersion string `json:"image_version"` + Fingerprint Fingerprint `json:"fingerprint"` + CreatedAt time.Time `json:"created_at"` +} + +func Open(ctx context.Context, databaseURL string) (*Store, error) { + db, err := sql.Open("pgx", databaseURL) + if err != nil { + return nil, errors.New("open hub database") + } + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(2) + db.SetConnMaxIdleTime(5 * time.Minute) + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, errors.New("connect to hub database") + } + store := &Store{db: db} + if err := store.migrate(ctx); err != nil { + db.Close() + return nil, err + } + return store, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) migrate(ctx context.Context) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return errors.New("begin hub schema migration") + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1542738012)`); err != nil { + return errors.New("lock hub schema migration") + } + if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migration (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { + return errors.New("create schema migration table") + } + var applied bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = 2)`).Scan(&applied); err != nil { + return errors.New("read hub schema migration state") + } + if !applied { + if _, err := tx.ExecContext(ctx, migration002); err != nil { + return fmt.Errorf("apply hub schema migration 2: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migration (version) VALUES (2)`); err != nil { + return errors.New("record hub schema migration 2") + } + } + if err := tx.Commit(); err != nil { + return errors.New("commit hub schema migration") + } + return nil +} + +func (s *Store) CreateGateway(ctx context.Context, name, endpoint, token string) (Gateway, error) { + name, endpoint, token = strings.TrimSpace(name), strings.TrimSpace(endpoint), strings.TrimSpace(token) + if !gatewayNamePattern.MatchString(name) || !validHTTPURL(endpoint) { + return Gateway{}, ErrInvalid + } + // 未显式提供 token 时由平台生成;提供则与网关进程的 GATEWAY_TOKEN 保持一致。 + if token != "" { + if !tokenPattern.MatchString(token) { + return Gateway{}, ErrInvalid + } + } else { + token = newToken() + } + gateway := Gateway{Name: name, Endpoint: endpoint, Token: token} + err := s.db.QueryRowContext(ctx, ` + INSERT INTO gateway (name, endpoint, token) VALUES ($1, $2, $3) + RETURNING created_at, updated_at`, name, endpoint, gateway.Token). + Scan(&gateway.CreatedAt, &gateway.UpdatedAt) + if err != nil { + return Gateway{}, publicDatabaseError(err) + } + return gateway, nil +} + +func (s *Store) ListGateways(ctx context.Context) ([]Gateway, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT name, endpoint, token, created_at, updated_at FROM gateway ORDER BY created_at, name`) + if err != nil { + return nil, errors.New("read gateways") + } + defer rows.Close() + gateways := []Gateway{} + for rows.Next() { + var gateway Gateway + if err := rows.Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt); err != nil { + return nil, errors.New("decode gateway") + } + gateways = append(gateways, gateway) + } + return gateways, rows.Err() +} + +func (s *Store) GetGateway(ctx context.Context, name string) (Gateway, error) { + var gateway Gateway + if !gatewayNamePattern.MatchString(name) { + return gateway, ErrInvalid + } + err := s.db.QueryRowContext(ctx, ` + SELECT name, endpoint, token, created_at, updated_at FROM gateway WHERE name = $1`, name). + Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt) + if err != nil { + return Gateway{}, rowError(err) + } + return gateway, nil +} + +func (s *Store) DeleteGateway(ctx context.Context, name string) error { + if !gatewayNamePattern.MatchString(name) { + return ErrInvalid + } + var deleted string + if err := s.db.QueryRowContext(ctx, `DELETE FROM gateway WHERE name = $1 RETURNING name`, name).Scan(&deleted); err != nil { + return rowError(err) + } + return nil +} + +func (s *Store) CreateImage(ctx context.Context, image Image) error { + image.Version = strings.TrimSpace(image.Version) + image.ImageRef = strings.TrimSpace(image.ImageRef) + image.Note = strings.TrimSpace(image.Note) + if !imageVersionPattern.MatchString(image.Version) || !imageRefPattern.MatchString(image.ImageRef) || + utf8.RuneCountInString(image.Note) > 200 { + return ErrInvalid + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO browser_image (version, image_ref, note, enabled) VALUES ($1, $2, $3, $4)`, + image.Version, image.ImageRef, image.Note, image.Enabled) + return publicDatabaseError(err) +} + +func (s *Store) UpdateImage(ctx context.Context, image Image) error { + image.ImageRef = strings.TrimSpace(image.ImageRef) + image.Note = strings.TrimSpace(image.Note) + if !imageVersionPattern.MatchString(image.Version) || !imageRefPattern.MatchString(image.ImageRef) || + utf8.RuneCountInString(image.Note) > 200 { + return ErrInvalid + } + var updated string + err := s.db.QueryRowContext(ctx, ` + UPDATE browser_image SET image_ref = $2, note = $3, enabled = $4, updated_at = now() + WHERE version = $1 RETURNING version`, image.Version, image.ImageRef, image.Note, image.Enabled). + Scan(&updated) + return rowError(err) +} + +func (s *Store) ListImages(ctx context.Context, enabledOnly bool) ([]Image, error) { + query := `SELECT version, image_ref, note, enabled, created_at, updated_at FROM browser_image` + if enabledOnly { + query += ` WHERE enabled` + } + query += ` ORDER BY created_at DESC, version` + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, errors.New("read browser images") + } + defer rows.Close() + images := []Image{} + for rows.Next() { + var image Image + if err := rows.Scan(&image.Version, &image.ImageRef, &image.Note, &image.Enabled, &image.CreatedAt, &image.UpdatedAt); err != nil { + return nil, errors.New("decode browser image") + } + images = append(images, image) + } + return images, rows.Err() +} + +func (s *Store) DeleteImage(ctx context.Context, version string) error { + if !imageVersionPattern.MatchString(version) { + return ErrInvalid + } + var deleted string + if err := s.db.QueryRowContext(ctx, `DELETE FROM browser_image WHERE version = $1 RETURNING version`, version).Scan(&deleted); err != nil { + return rowError(err) + } + return nil +} + +// ImageRef 返回可用(存在且启用)版本的镜像引用;缺失或禁用均视为不可用。 +func (s *Store) ImageRef(ctx context.Context, version string) (string, error) { + if !imageVersionPattern.MatchString(version) { + return "", ErrInvalid + } + var ref string + if err := s.db.QueryRowContext(ctx, `SELECT image_ref FROM browser_image WHERE version = $1 AND enabled`, version).Scan(&ref); err != nil { + return "", rowError(err) + } + return ref, nil +} + +func (s *Store) CreateEnv(ctx context.Context, env Env) error { + env.Alias = strings.TrimSpace(env.Alias) + env.Name = strings.TrimSpace(env.Name) + if !aliasPattern.MatchString(env.Alias) || !validDisplayName(env.Name) || + !gatewayNamePattern.MatchString(env.Gateway) || !imageVersionPattern.MatchString(env.ImageVersion) { + return ErrInvalid + } + if err := env.Fingerprint.Validate(); err != nil { + return fmt.Errorf("%w: %s", ErrInvalid, err) + } + encoded, err := json.Marshal(env.Fingerprint) + if err != nil { + return ErrInvalid + } + var created string + err = s.db.QueryRowContext(ctx, ` + INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint) + SELECT $1, $2, $3, version, $5 FROM browser_image WHERE version = $4 AND enabled + RETURNING alias`, env.Alias, env.Name, env.Gateway, env.ImageVersion, encoded).Scan(&created) + return rowError(err) +} + +func (s *Store) ListEnvs(ctx context.Context) ([]Env, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT alias, name, gateway_name, image_version, fingerprint, created_at + FROM browser_env ORDER BY created_at, alias`) + if err != nil { + return nil, errors.New("read browser envs") + } + defer rows.Close() + envs := []Env{} + for rows.Next() { + env, err := scanEnv(rows) + if err != nil { + return nil, err + } + envs = append(envs, env) + } + return envs, rows.Err() +} + +func (s *Store) GetEnv(ctx context.Context, alias string) (Env, error) { + if !aliasPattern.MatchString(alias) { + return Env{}, ErrInvalid + } + rows, err := s.db.QueryContext(ctx, ` + SELECT alias, name, gateway_name, image_version, fingerprint, created_at + FROM browser_env WHERE alias = $1`, alias) + if err != nil { + return Env{}, errors.New("read browser env") + } + defer rows.Close() + if !rows.Next() { + return Env{}, ErrNotFound + } + env, err := scanEnv(rows) + if err != nil { + return Env{}, err + } + return env, rows.Err() +} + +// UpgradeEnv 将环境切换到指定可用镜像版本;参数与卷不变,容器重建由控制面编排网关完成。 +func (s *Store) UpgradeEnv(ctx context.Context, alias, version string) error { + if !aliasPattern.MatchString(alias) || !imageVersionPattern.MatchString(version) { + return ErrInvalid + } + var updated string + if err := s.db.QueryRowContext(ctx, ` + UPDATE browser_env SET image_version = $2 + WHERE alias = $1 AND EXISTS (SELECT 1 FROM browser_image WHERE version = $2 AND enabled) + RETURNING alias`, alias, version).Scan(&updated); err != nil { + return rowError(err) + } + return nil +} + +func (s *Store) DeleteEnv(ctx context.Context, alias string) error { + if !aliasPattern.MatchString(alias) { + return ErrInvalid + } + var deleted string + if err := s.db.QueryRowContext(ctx, `DELETE FROM browser_env WHERE alias = $1 RETURNING alias`, alias).Scan(&deleted); err != nil { + return rowError(err) + } + return nil +} + +func scanEnv(rows *sql.Rows) (Env, error) { + var env Env + var encoded []byte + if err := rows.Scan(&env.Alias, &env.Name, &env.Gateway, &env.ImageVersion, &encoded, &env.CreatedAt); err != nil { + return Env{}, errors.New("decode browser env") + } + if len(encoded) > 0 { + if err := json.Unmarshal(encoded, &env.Fingerprint); err != nil { + return Env{}, errors.New("decode env fingerprint") + } + } + return env, nil +} + +func validDisplayName(name string) bool { + if name == "" || utf8.RuneCountInString(name) > 64 { + return false + } + for _, r := range name { + if r < 0x20 || r == 0x7f { + return false + } + } + return true +} + +func validHTTPURL(raw string) bool { + parsed, err := url.Parse(raw) + return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https") +} + +func newToken() string { + var value [24]byte + _, _ = rand.Read(value[:]) + return hex.EncodeToString(value[:]) +} + +func rowError(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return publicDatabaseError(err) +} + +func publicDatabaseError(err error) error { + if err == nil { + return nil + } + var postgresError *pgconn.PgError + if errors.As(err, &postgresError) && (postgresError.Code == "23505" || postgresError.Code == "23503" || postgresError.Code == "23514") { + return ErrConflict + } + return errors.New("hub persistence operation failed") +} diff --git a/internal/hub/store_test.go b/internal/hub/store_test.go new file mode 100644 index 0000000..fcdcc8f --- /dev/null +++ b/internal/hub/store_test.go @@ -0,0 +1,227 @@ +package hub + +import ( + "context" + "encoding/json" + "errors" + "os" + "reflect" + "strings" + "testing" +) + +func TestFingerprintArgsFollowUpstreamCommandLineContract(t *testing.T) { + full := Fingerprint{ + Seed: 2024, Platform: "windows", PlatformVersion: "11.0.0", + Brand: "Edge", BrandVersion: "132.0.6834.159", HardwareConcurrency: 8, + Lang: "zh-CN", AcceptLang: "zh-CN,en-US", Timezone: "Asia/Shanghai", + ProxyServer: "socks5://127.0.0.1:1080", DisableNonProxiedUDP: true, DisableSpoofing: "font,gpu", + } + if err := full.Validate(); err != nil { + t.Fatalf("expected full fingerprint to be valid: %v", err) + } + want := []string{ + "--fingerprint=2024", + "--fingerprint-platform=windows", + "--fingerprint-platform-version=11.0.0", + "--fingerprint-brand=Edge", + "--fingerprint-brand-version=132.0.6834.159", + "--fingerprint-hardware-concurrency=8", + "--lang=zh-CN", + "--accept-lang=zh-CN,en-US", + "--timezone=Asia/Shanghai", + "--proxy-server=socks5://127.0.0.1:1080", + "--disable-non-proxied-udp", + "--disable-spoofing=font,gpu", + } + if !reflect.DeepEqual(full.Args(), want) { + t.Fatalf("unexpected args:\n got %v\nwant %v", full.Args(), want) + } + + minimal := Fingerprint{Seed: 1} + if err := minimal.Validate(); err != nil { + t.Fatalf("minimal fingerprint must be valid: %v", err) + } + if args := minimal.Args(); len(args) != 1 || args[0] != "--fingerprint=1" { + t.Fatalf("zero-value fields must be omitted: %v", args) + } + + encoded, err := json.Marshal(minimal) + if err != nil { + t.Fatal(err) + } + var decoded Fingerprint + if err := json.Unmarshal(encoded, &decoded); err != nil || decoded.Seed != 1 || decoded.Args()[0] != "--fingerprint=1" { + t.Fatalf("fingerprint must survive JSON round trip: %#v %v", decoded, err) + } +} + +func TestFingerprintValidateRejectsUnsupportedValues(t *testing.T) { + invalid := map[string]Fingerprint{ + "seed zero": {Seed: 0}, + "seed overflow": {Seed: 2147483648}, + "platform": {Seed: 1, Platform: "android"}, + "brand": {Seed: 1, Brand: "Firefox"}, + "platform version": {Seed: 1, PlatformVersion: "bad value"}, + "brand version": {Seed: 1, BrandVersion: strings.Repeat("x", 33)}, + "concurrency": {Seed: 1, HardwareConcurrency: 129}, + "lang": {Seed: 1, Lang: "zh CN"}, + "accept lang": {Seed: 1, AcceptLang: "zh-CN;drop"}, + "timezone": {Seed: 1, Timezone: "Asia/Shanghai\n"}, + "proxy scheme": {Seed: 1, ProxyServer: "ftp://proxy:21"}, + "proxy host": {Seed: 1, ProxyServer: "http://"}, + "spoofing unknown": {Seed: 1, DisableSpoofing: "webrtc"}, + "spoofing repeated": {Seed: 1, DisableSpoofing: "font,font"}, + } + for name, fingerprint := range invalid { + t.Run(name, func(t *testing.T) { + if err := fingerprint.Validate(); err == nil { + t.Fatalf("expected rejection for %#v", fingerprint) + } + }) + } +} + +func TestStoreValidationRejectsInvalidInputsBeforePersistence(t *testing.T) { + store := &Store{} + ctx := context.Background() + if _, err := store.CreateGateway(ctx, "bad name!", "http://gw:8081", ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected invalid gateway name, got %v", err) + } + if _, err := store.CreateGateway(ctx, "gw-1", "ftp://gw:8081", ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected invalid gateway endpoint, got %v", err) + } + if _, err := store.CreateGateway(ctx, "gw-1", "http://gw:8081", "short-token"); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected invalid gateway token, got %v", err) + } + if err := store.CreateImage(ctx, Image{Version: "v1", ImageRef: "registry/img:1"}); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected invalid image version, got %v", err) + } + if err := store.CreateImage(ctx, Image{Version: "148.0.0.1", ImageRef: "has space"}); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected invalid image ref, got %v", err) + } + if err := store.CreateImage(ctx, Image{Version: "148.0.0.1", ImageRef: "registry/img:1", Note: strings.Repeat("长", 201)}); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected overlong note to be rejected, got %v", err) + } + if err := store.CreateEnv(ctx, Env{Alias: "UP", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected invalid alias, got %v", err) + } + if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: strings.Repeat("名", 65), Gateway: "gw-1", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected overlong name, got %v", err) + } + if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 0}}); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected invalid fingerprint, got %v", err) + } +} + +func TestHubWorkflow(t *testing.T) { + databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if databaseURL == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") + } + ctx := context.Background() + store, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if _, err := store.db.ExecContext(ctx, `TRUNCATE browser_env, browser_image, gateway`); err != nil { + t.Fatal(err) + } + + gateway, err := store.CreateGateway(ctx, "gw-main", "http://127.0.0.1:8081", "") + if err != nil { + t.Fatal(err) + } + if len(gateway.Token) != 48 { + t.Fatalf("platform must assign a 48-hex-char token: %q", gateway.Token) + } + custom, err := store.CreateGateway(ctx, "gw-custom", "http://127.0.0.3:8081", "operator-provided-token-1234") + if err != nil || custom.Token != "operator-provided-token-1234" { + t.Fatalf("explicit token must be honored: %#v %v", custom, err) + } + if _, err := store.CreateGateway(ctx, "gw-main", "http://127.0.0.2:8081", ""); !errors.Is(err, ErrConflict) { + t.Fatalf("expected duplicate gateway conflict, got %v", err) + } + + if err := store.CreateImage(ctx, Image{Version: "148.0.7778.215", ImageRef: "git.ipao.vip/rogee/fingerprint-chromium:148", Note: "主力版本"}); err != nil { + t.Fatal(err) + } + if err := store.CreateImage(ctx, Image{Version: "144.0.7559.132", ImageRef: "git.ipao.vip/rogee/fingerprint-chromium:144", Enabled: false}); err != nil { + t.Fatal(err) + } + if err := store.CreateImage(ctx, Image{Version: "148.0.7778.215", ImageRef: "git.ipao.vip/rogee/fingerprint-chromium:148b"}); !errors.Is(err, ErrConflict) { + t.Fatalf("expected duplicate version conflict, got %v", err) + } + + env := Env{ + Alias: "shop-01", Name: "店铺一号", Gateway: "gw-main", ImageVersion: "148.0.7778.215", + Fingerprint: Fingerprint{Seed: 1000, Timezone: "Asia/Shanghai", Lang: "zh-CN"}, + } + if err := store.CreateEnv(ctx, env); err != nil { + t.Fatal(err) + } + if err := store.CreateEnv(ctx, env); !errors.Is(err, ErrConflict) { + t.Fatalf("expected duplicate alias conflict, got %v", err) + } + if err := store.CreateEnv(ctx, Env{Alias: "shop-02", Name: "店铺二号", Gateway: "missing", ImageVersion: "148.0.7778.215", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrConflict) { + t.Fatalf("expected unknown gateway conflict, got %v", err) + } + + listed, err := store.ListEnvs(ctx) + if err != nil || len(listed) != 1 { + t.Fatalf("expected one env, err=%v list=%#v", err, listed) + } + if listed[0].Name != "店铺一号" || listed[0].Fingerprint.Seed != 1000 || listed[0].Fingerprint.Timezone != "Asia/Shanghai" { + t.Fatalf("fingerprint must round trip through jsonb: %#v", listed[0]) + } + if _, err := store.GetEnv(ctx, "ghost"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected missing env, got %v", err) + } + + if _, err := store.ImageRef(ctx, "144.0.7559.132"); !errors.Is(err, ErrNotFound) { + t.Fatalf("disabled version must not resolve, got %v", err) + } + if err := store.UpdateImage(ctx, Image{Version: "144.0.7559.132", ImageRef: "git.ipao.vip/rogee/fingerprint-chromium:144", Enabled: true}); err != nil { + t.Fatal(err) + } + if ref, err := store.ImageRef(ctx, "144.0.7559.132"); err != nil || !strings.HasSuffix(ref, ":144") { + t.Fatalf("enabled version must resolve: %v %q", err, ref) + } + if _, err := store.ImageRef(ctx, "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected missing version, got %v", err) + } + + if err := store.UpgradeEnv(ctx, "shop-01", "144.0.7559.132"); err != nil { + t.Fatal(err) + } + upgraded, err := store.GetEnv(ctx, "shop-01") + if err != nil || upgraded.ImageVersion != "144.0.7559.132" || upgraded.Fingerprint.Seed != 1000 { + t.Fatalf("upgrade must only change image version: %#v %v", upgraded, err) + } + if err := store.UpgradeEnv(ctx, "ghost", "144.0.7559.132"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected missing env on upgrade, got %v", err) + } + + if err := store.DeleteImage(ctx, "144.0.7559.132"); !errors.Is(err, ErrConflict) { + t.Fatalf("referenced version must not be deletable, got %v", err) + } + if err := store.DeleteGateway(ctx, "gw-main"); !errors.Is(err, ErrConflict) { + t.Fatalf("referenced gateway must not be deletable, got %v", err) + } + if err := store.DeleteEnv(ctx, "shop-01"); err != nil { + t.Fatal(err) + } + if err := store.DeleteEnv(ctx, "shop-01"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected missing env on double delete, got %v", err) + } + if err := store.DeleteImage(ctx, "144.0.7559.132"); err != nil { + t.Fatal(err) + } + if err := store.DeleteGateway(ctx, "gw-main"); err != nil { + t.Fatal(err) + } + if err := store.DeleteGateway(ctx, "gw-custom"); err != nil { + t.Fatal(err) + } +} diff --git a/web/src/BrowserImageList.jsx b/web/src/BrowserImageList.jsx new file mode 100644 index 0000000..096d01c --- /dev/null +++ b/web/src/BrowserImageList.jsx @@ -0,0 +1,176 @@ +import { useEffect, useState } from 'react' +import { useDataProvider, useGetList } from 'ra-core' +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + IconButton, + MenuItem, + Paper, + Stack, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material' +import DeleteOutlined from '@mui/icons-material/DeleteOutlined' +import EditOutlined from '@mui/icons-material/EditOutlined' + +const versionPattern = /^[0-9][A-Za-z0-9.+~-]{0,63}$/ +const refPattern = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$/ + +function ImageTable({ images, busyVersion, onEdit, onToggle, onDelete }) { + return ( + + + 版本镜像引用备注启用操作 + + {images.map(image => ( + + {image.version} + {image.image_ref} + {image.note || '—'} + + onToggle(image)} slotProps={{ input: { 'aria-label': `启用 ${image.version}` } }} /> + + + onEdit(image)}> + onDelete(image)}> + + + ))} + +
+
+ ) +} + +export function BrowserImageList() { + const dataProvider = useDataProvider() + const { data: images = [], error, isPending, refetch } = useGetList('browser-images') + const [form, setForm] = useState({ version: '', image_ref: '', note: '' }) + const [editing, setEditing] = useState(null) + const [busy, setBusy] = useState('') + const [localError, setLocalError] = useState('') + + useEffect(() => { + document.title = 'CreatorHub · 镜像版本' + }, []) + + const createValid = versionPattern.test(form.version) && refPattern.test(form.image_ref) + + async function createImage(event) { + event.preventDefault() + if (!createValid) return + setBusy('create') + setLocalError('') + try { + await dataProvider.create('browser-images', { data: { version: form.version, image_ref: form.image_ref, note: form.note, enabled: true } }) + setForm({ version: '', image_ref: '', note: '' }) + await refetch() + } catch (reason) { + setLocalError(reason.message) + } finally { + setBusy('') + } + } + + async function toggle(image) { + setBusy(image.version) + setLocalError('') + try { + await dataProvider.update('browser-images', { id: image.version, data: { image_ref: image.image_ref, note: image.note, enabled: !image.enabled } }) + await refetch() + } catch (reason) { + setLocalError(reason.message) + } finally { + setBusy('') + } + } + + async function remove(image) { + if (!window.confirm(`删除镜像版本 ${image.version}?被环境引用时删除会被拒绝。`)) return + setBusy(image.version) + setLocalError('') + try { + await dataProvider.delete('browser-images', { id: image.version }) + await refetch() + } catch (reason) { + setLocalError(reason.message) + } finally { + setBusy('') + } + } + + async function saveEditing() { + if (!refPattern.test(editing.image_ref)) { + setEditing(current => ({ ...current, error: '镜像引用格式不正确' })) + return + } + setBusy(editing.version) + setLocalError('') + try { + await dataProvider.update('browser-images', { id: editing.version, data: { image_ref: editing.image_ref, note: editing.note, enabled: editing.enabled } }) + setEditing(null) + await refetch() + } catch (reason) { + setLocalError(reason.message) + } finally { + setBusy('') + } + } + + const message = localError || error?.message + return ( + <> + 镜像版本维护可用浏览器镜像版本;创建与升级环境时从中选择 + + + + 版本 + setForm(current => ({ ...current, version: event.target.value }))} placeholder="如 148.0.7778.215" /> + + + 镜像引用 + setForm(current => ({ ...current, image_ref: event.target.value }))} placeholder="registry/repo:tag 或 @sha256 摘要" /> + + + 备注 + setForm(current => ({ ...current, note: event.target.value }))} /> + + + + + {message ? {message} : null} + {isPending ? : null} + {!isPending && images.length === 0 ? 暂无镜像版本;网关会在镜像缺失时按引用自动拉取。 : null} + {!isPending && images.length > 0 ? setEditing({ ...image, error: '' })} onToggle={toggle} onDelete={remove} /> : null} + + setEditing(null)} aria-label="编辑镜像版本"> + 编辑 {editing?.version} + + + setEditing(current => ({ ...current, image_ref: event.target.value, error: '' }))} slotProps={{ htmlInput: { 'aria-label': '镜像引用' } }} error={Boolean(editing?.error)} helperText={editing?.error || ' '} /> + setEditing(current => ({ ...current, note: event.target.value }))} slotProps={{ htmlInput: { 'aria-label': '备注', maxLength: 200 } }} /> + setEditing(current => ({ ...current, enabled: event.target.checked }))} slotProps={{ input: { 'aria-label': '启用' } }} />} label="启用(可用于创建与升级)" /> + + + + + + + + + ) +} diff --git a/web/src/BrowserImageList.test.jsx b/web/src/BrowserImageList.test.jsx new file mode 100644 index 0000000..ae32700 --- /dev/null +++ b/web/src/BrowserImageList.test.jsx @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { CoreAdminContext } from 'ra-core' +import { BrowserImageList } from './BrowserImageList' + +afterEach(cleanup) + +const images = [ + { id: '148.0.0.1', version: '148.0.0.1', image_ref: 'reg/img:148', note: '', enabled: true }, + { id: '144.0.0.1', version: '144.0.0.1', image_ref: 'reg/img:144', note: '旧版', enabled: false }, +] + +function provider(overrides = {}) { + return { + getList: vi.fn(() => Promise.resolve({ data: images, total: images.length })), + create: vi.fn().mockResolvedValue({ data: images[0] }), + update: vi.fn().mockResolvedValue({ data: images[0] }), + delete: vi.fn().mockResolvedValue({ data: { id: '144.0.0.1' } }), + getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn(), browserAction: vi.fn(), + ...overrides, + } +} + +describe('BrowserImageList', () => { + it('adds an image version', async () => { + const dataProvider = provider() + render() + + await screen.findAllByText('reg/img:148') + fireEvent.change(screen.getByRole('textbox', { name: '版本' }), { target: { value: '150.0.0.1' } }) + fireEvent.change(screen.getByRole('textbox', { name: '镜像引用' }), { target: { value: 'reg/img:150' } }) + fireEvent.click(screen.getByRole('button', { name: '添加版本' })) + + await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browser-images', { data: { version: '150.0.0.1', image_ref: 'reg/img:150', note: '', enabled: true } })) + }) + + it('toggles enabled through update', async () => { + const dataProvider = provider() + render() + + fireEvent.click(await screen.findByRole('switch', { name: '启用 144.0.0.1' })) + + await waitFor(() => expect(dataProvider.update).toHaveBeenCalledWith('browser-images', { id: '144.0.0.1', data: { image_ref: 'reg/img:144', note: '旧版', enabled: true } })) + }) + + it('blocks invalid input before submit', async () => { + const dataProvider = provider() + render() + + await screen.findAllByText('reg/img:148') + fireEvent.change(screen.getByRole('textbox', { name: '版本' }), { target: { value: 'v1!' } }) + expect(screen.getByRole('button', { name: '添加版本' }).disabled).toBe(true) + expect(dataProvider.create).not.toHaveBeenCalled() + }) + + it('surfaces delete rejection (referenced by env)', async () => { + const dataProvider = provider({ delete: vi.fn().mockRejectedValue(new Error('该版本正被环境引用')) }) + render() + + fireEvent.click(await screen.findByLabelText('删除 148.0.0.1')) + expect(window.confirm).toBeTruthy() + window.confirm = vi.fn(() => true) + fireEvent.click(screen.getByLabelText('删除 148.0.0.1')) + + expect((await screen.findByRole('alert')).textContent).toContain('该版本正被环境引用') + }) +}) diff --git a/web/src/BrowserList.jsx b/web/src/BrowserList.jsx index 0e8d58d..2108e51 100644 --- a/web/src/BrowserList.jsx +++ b/web/src/BrowserList.jsx @@ -4,9 +4,20 @@ import { Alert, Box, Button, + Checkbox, CircularProgress, + Collapse, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + IconButton, + InputAdornment, + MenuItem, Paper, Stack, + Switch, Table, TableBody, TableCell, @@ -16,10 +27,18 @@ import { TextField, Typography, } from '@mui/material' +import ContentCopyOutlined from '@mui/icons-material/ContentCopyOutlined' import DeleteOutlined from '@mui/icons-material/DeleteOutlined' +import ExpandMoreOutlined from '@mui/icons-material/ExpandMoreOutlined' import PlayArrowOutlined from '@mui/icons-material/PlayArrowOutlined' import SecurityOutlined from '@mui/icons-material/SecurityOutlined' import StopOutlined from '@mui/icons-material/StopOutlined' +import UpgradeOutlined from '@mui/icons-material/UpgradeOutlined' + +const aliasPattern = /^[a-z0-9][a-z0-9-]{0,31}$/ +const platformOptions = ['windows', 'linux', 'macos'] +const brandOptions = ['Chrome', 'Edge', 'Opera', 'Vivaldi'] +const spoofingOptions = ['font', 'audio', 'canvas', 'clientrects', 'gpu'] const statusLabels = { created: '已创建', @@ -27,6 +46,47 @@ const statusLabels = { exited: '已停止', restarting: '重启中', paused: '已暂停', + missing: '未部署', +} + +const emptyForm = { + name: '', + alias: '', + gateway: '', + image_version: '', + seed: '1000', + platform: '', + platform_version: '', + brand: '', + brand_version: '', + hardware_concurrency: '', + lang: '', + accept_lang: '', + timezone: '', + proxy_server: '', + disable_non_proxied_udp: false, + disable_spoofing: [], +} + +function buildFingerprint(form) { + const fingerprint = { seed: Number(form.seed) } + const optionalText = { + platform: form.platform, + platform_version: form.platform_version, + brand: form.brand, + brand_version: form.brand_version, + lang: form.lang, + accept_lang: form.accept_lang, + timezone: form.timezone, + proxy_server: form.proxy_server, + } + for (const [key, value] of Object.entries(optionalText)) { + if (value !== '') fingerprint[key] = value + } + if (form.hardware_concurrency !== '') fingerprint.hardware_concurrency = Number(form.hardware_concurrency) + if (form.disable_non_proxied_udp) fingerprint.disable_non_proxied_udp = true + if (form.disable_spoofing.length > 0) fingerprint.disable_spoofing = form.disable_spoofing.join(',') + return fingerprint } function Status({ state }) { @@ -39,26 +99,170 @@ function RuntimeActions({ runtime, busy, onAction }) { const running = runtime.state === 'running' return ( - - - + + + + ) } +function Copyable({ value }) { + const copy = () => navigator.clipboard?.writeText(value) + return ( + + {value} + + + ) +} + +function CreateForm({ gateways, images, onSubmit, busy }) { + const [form, setForm] = useState(emptyForm) + const [advanced, setAdvanced] = useState(false) + const enabledImages = images.filter(image => image.enabled) + const update = (key, value) => setForm(current => ({ ...current, [key]: value })) + const toggleSpoofing = option => setForm(current => ({ + ...current, + disable_spoofing: current.disable_spoofing.includes(option) + ? current.disable_spoofing.filter(item => item !== option) + : [...current.disable_spoofing, option], + })) + + useEffect(() => { + if (form.gateway === '' && gateways.length > 0) update('gateway', gateways[0].name) + }, [gateways, form.gateway]) + useEffect(() => { + if (form.image_version === '' && enabledImages.length > 0) update('image_version', enabledImages[0].version) + }, [enabledImages, form.image_version]) + + const seedNumber = Number(form.seed) + const valid = form.name.trim() !== '' && aliasPattern.test(form.alias) && form.gateway !== '' && + form.image_version !== '' && Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647 + + const submit = event => { + event.preventDefault() + if (!valid) return + onSubmit({ + alias: form.alias, + name: form.name.trim(), + gateway: form.gateway, + image_version: form.image_version, + fingerprint: buildFingerprint(form), + }) + setForm(current => ({ ...emptyForm, gateway: current.gateway, image_version: current.image_version })) + } + + const label = (htmlFor, text, required = false) => ( + + {text} {required ? : null} + + ) + + return ( + + + {label('env-name', '环境名称', true)} + update('name', event.target.value)} placeholder="支持中文,如:店铺一号" /> + + + {label('env-alias', '别名(容器标识)', true)} + update('alias', event.target.value)} placeholder="如:shop-01" helperText="用于容器名、数据卷与 API 路径" /> + + + {label('env-gateway', '网关', true)} + update('gateway', event.target.value)} disabled={gateways.length === 0} helperText={gateways.length === 0 ? '请先在「网关管理」注册网关' : ' '}> + {gateways.map(gateway => {gateway.name})} + + + + {label('env-version', '镜像版本', true)} + update('image_version', event.target.value)} disabled={enabledImages.length === 0} helperText={enabledImages.length === 0 ? '请先在「镜像版本」添加并启用' : ' '}> + {enabledImages.map(image => {image.version})} + + + + {label('env-seed', 'Fingerprint Seed', true)} + update('seed', event.target.value)} /> + + + + + + + update('platform', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '操作系统 platform' } }}> + 默认 + {platformOptions.map(option => {option})} + + update('platform_version', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '系统版本 platform-version' } }} placeholder="如 11.0.0" /> + update('brand', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '浏览器品牌 brand' } }}> + 默认 Chromium + {brandOptions.map(option => {option})} + + update('brand_version', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '品牌版本 brand-version' } }} placeholder="如 132.0.6834.159" /> + update('hardware_concurrency', event.target.value)} slotProps={{ htmlInput: { 'aria-label': 'CPU 核心数 hardware-concurrency', min: 1, max: 128 } }} placeholder="留空由种子生成" /> + update('lang', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '语言 lang' } }} placeholder="如 zh-CN" /> + update('accept_lang', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '接受语言 accept-lang' } }} placeholder="如 zh-CN,en-US" /> + update('timezone', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '时区 timezone' } }} placeholder="如 Asia/Shanghai" /> + update('proxy_server', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '代理 proxy-server' } }} placeholder="如 socks5://127.0.0.1:1080" /> + update('disable_non_proxied_udp', event.target.checked)} slotProps={{ input: { 'aria-label': '禁用非代理 UDP' } }} />} label="禁用非代理 UDP(WebRTC)" sx={{ gridColumn: { md: 'span 2' } }} /> + + 禁用指纹伪装 disable-spoofing + + {spoofingOptions.map(option => ( + toggleSpoofing(option)} slotProps={{ input: { 'aria-label': `禁用伪装 ${option}` } }} />} label={option} /> + ))} + + + + + + + + + ) +} + +function UpgradeDialog({ target, images, busy, onClose, onConfirm }) { + const [version, setVersion] = useState('') + const enabledImages = images.filter(image => image.enabled) + useEffect(() => { + setVersion('') + }, [target]) + return ( + + 升级 {target ? `${target.name}(${target.alias})` : ''} + + + 容器将按新镜像重建,Profile 数据卷与指纹参数保持不变。 + setVersion(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '目标镜像版本' } }}> + {enabledImages.map(image => {image.version}{image.version === target?.image_version ? '(当前)' : ''})} + + + + + + + + + ) +} + function RuntimeTable({ runtimes, busy, onAction }) { return ( - 环境状态ProfileCDP操作 + 环境状态镜像版本CDP操作 {runtimes.map(runtime => ( - - {runtime.name}{runtime.status} + + {runtime.name}{runtime.alias} · seed {runtime.fingerprint?.seed} - {runtime.profile} - {runtime.endpoint} - + {runtime.image_version} + {runtime.endpoint ? : —} + ))} @@ -68,28 +272,27 @@ function RuntimeTable({ runtimes, busy, onAction }) { } function RuntimeCards({ runtimes, busy, onAction }) { - return {runtimes.map(runtime => {runtime.name}{runtime.status}{runtime.profile}CDP{runtime.endpoint})} + return {runtimes.map(runtime => {runtime.name}{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}{runtime.endpoint ? : null})} } export function BrowserList() { const dataProvider = useDataProvider() - const [name, setName] = useState('') - const [seed, setSeed] = useState('1000') const [busy, setBusy] = useState('') const [localError, setLocalError] = useState('') + const [upgradeTarget, setUpgradeTarget] = useState(null) const { data: runtimes = [], error, isPending, refetch } = useGetList('browsers', {}, { refetchInterval: 3000 }) + const { data: gateways = [] } = useGetList('gateways') + const { data: images = [] } = useGetList('browser-images') useEffect(() => { document.title = 'CreatorHub · 运行环境' }, []) - async function createRuntime(event) { - event.preventDefault() + async function createRuntime(data) { setBusy('create') setLocalError('') try { - await dataProvider.create('browsers', { data: { name, seed: Number(seed) } }) - setName('') + await dataProvider.create('browsers', { data }) await refetch() } catch (reason) { setLocalError(reason.message) @@ -98,43 +301,50 @@ export function BrowserList() { } } - async function act(runtimeName, action) { - if (action === 'recycle' && !window.confirm(`回收 ${runtimeName}?Profile 数据卷将保留。`)) return - setBusy(runtimeName) + async function act(runtime, action) { + if (action === 'recycle' && !window.confirm(`回收 ${runtime.name}(${runtime.alias})?容器将被删除,Profile 数据卷保留。`)) return + if (action === 'upgrade') { + setUpgradeTarget(runtime) + return + } + await runAction(runtime, action) + } + + async function runAction(runtime, action, extra) { + setBusy(runtime.alias) setLocalError('') try { - await dataProvider.browserAction(runtimeName, action) + await dataProvider.browserAction(runtime.alias, action, extra) await refetch() } catch (reason) { setLocalError(reason.message) + return reason } finally { setBusy('') } } + async function confirmUpgrade(version) { + const target = upgradeTarget + const failure = await runAction(target, 'upgrade', { version }) + if (!failure) setUpgradeTarget(null) + } + const message = localError || error?.message return ( <> - 运行环境启动、停止并回收隔离的浏览器 Profile + 运行环境启动、停止、升级并回收隔离的指纹浏览器 - - - 环境名称 - setName(event.target.value)} placeholder="例如:account-a" /> - - - Fingerprint Seed - setSeed(event.target.value)} /> - - - + {message ? { setLocalError(''); refetch() }}>重试} sx={{ mb: 2.5 }}>{message} : null} {isPending ? : null} {!isPending && runtimes.length === 0 ? 暂无运行环境,创建第一个隔离 Profile。 : null} {!isPending && runtimes.length > 0 ? <> : null} - Docker socket 仅由受限网关访问 + setUpgradeTarget(null)} onConfirm={confirmUpgrade} /> + + Docker socket 仅由受限网关访问,镜像与参数由平台下发 ) } diff --git a/web/src/BrowserList.test.jsx b/web/src/BrowserList.test.jsx index 77f0bb8..c525180 100644 --- a/web/src/BrowserList.test.jsx +++ b/web/src/BrowserList.test.jsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { CoreAdminContext } from 'ra-core' import { BrowserList } from './BrowserList' import { theme } from './theme' @@ -7,14 +7,25 @@ import { theme } from './theme' afterEach(cleanup) const runtimes = [ - { id: 'container-a', name: 'account-a', state: 'running', status: 'Up', profile: 'creatorhub-profile-account-a', endpoint: 'http://account-a:9222' }, - { id: 'container-b', name: 'account-b', state: 'exited', status: 'Exited', profile: 'creatorhub-profile-account-b', endpoint: 'http://account-b:9222' }, + { id: 'account-a', alias: 'account-a', name: '店铺一号', state: 'running', status: 'Up', image_version: '148.0.0.1', fingerprint: { seed: 1000 }, endpoint: 'http://account-a:9222' }, + { id: 'account-b', alias: 'account-b', name: '店铺二号', state: 'exited', status: 'Exited', image_version: '148.0.0.1', fingerprint: { seed: 2000 }, endpoint: 'http://account-b:9222' }, +] +const gateways = [{ id: 'gw-1', name: 'gw-1', endpoint: 'http://gw:8081', token: 't' }] +const images = [ + { id: '148.0.0.1', version: '148.0.0.1', image_ref: 'reg/img:148', enabled: true }, + { id: '144.0.0.1', version: '144.0.0.1', image_ref: 'reg/img:144', enabled: true }, + { id: '139.0.0.1', version: '139.0.0.1', image_ref: 'reg/img:139', enabled: false }, ] function provider(overrides = {}) { return { - getList: vi.fn().mockResolvedValue({ data: runtimes, total: runtimes.length }), - create: vi.fn().mockResolvedValue({ data: { id: 'new-runtime', name: 'new-runtime' } }), + getList: vi.fn((resource) => { + if (resource === 'browsers') return Promise.resolve({ data: runtimes, total: runtimes.length }) + if (resource === 'gateways') return Promise.resolve({ data: gateways, total: gateways.length }) + if (resource === 'browser-images') return Promise.resolve({ data: images, total: images.length }) + return Promise.reject(new Error(`unsupported ${resource}`)) + }), + create: vi.fn().mockResolvedValue({ data: { id: 'account-a', alias: 'account-a' } }), browserAction: vi.fn().mockResolvedValue(undefined), getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), ...overrides, @@ -33,7 +44,7 @@ describe('BrowserList', () => { render() expect(await screen.findAllByText('http://account-a:9222')).toHaveLength(2) - expect(screen.getAllByText('CDP')).toHaveLength(3) + expect(screen.getAllByText('CDP')).toHaveLength(1) }) it('shows unknown states with their raw value as secondary evidence', async () => { @@ -58,31 +69,52 @@ describe('BrowserList', () => { const dataProvider = provider() render() - await screen.findAllByText('account-a') - expect(screen.getAllByLabelText('启动 account-a')[0].disabled).toBe(true) - expect(screen.getAllByLabelText('停止 account-b')[0].disabled).toBe(true) - fireEvent.click(screen.getAllByLabelText('停止 account-a')[0]) + await screen.findAllByText('店铺一号') + expect(screen.getAllByLabelText('启动 店铺一号')[0].disabled).toBe(true) + expect(screen.getAllByLabelText('停止 店铺二号')[0].disabled).toBe(true) + fireEvent.click(screen.getAllByLabelText('停止 店铺一号')[0]) - await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'stop')) + await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'stop', undefined)) }) - it('creates a runtime through the data provider', async () => { + it('creates an env with chinese name, alias, gateway and enabled image version', async () => { const dataProvider = provider() render() - await screen.findAllByText('account-a') + await screen.findAllByText('店铺一号') - fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: 'new-runtime' } }) + fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '店铺三号' } }) + fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'shop-03' } }) fireEvent.click(screen.getByRole('button', { name: '创建环境' })) - await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: { name: 'new-runtime', seed: 1000 } })) + await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: { + alias: 'shop-03', + name: '店铺三号', + gateway: 'gw-1', + image_version: '148.0.0.1', + fingerprint: { seed: 1000 }, + } })) + }) + + it('upgrades an env through the version dialog', async () => { + const dataProvider = provider() + render() + await screen.findAllByText('店铺一号') + + fireEvent.click(screen.getAllByLabelText('升级 店铺一号')[0]) + const dialog = await screen.findByRole('dialog') + fireEvent.mouseDown(within(dialog).getByRole('combobox', { name: /目标镜像版本/ })) + fireEvent.click(await within(document.body).findByRole('option', { name: '144.0.0.1' })) + fireEvent.click(within(dialog).getByRole('button', { name: '确认升级' })) + + await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'upgrade', { version: '144.0.0.1' })) }) it('shows action errors and keeps a retry control', async () => { const dataProvider = provider({ browserAction: vi.fn().mockRejectedValue(new Error('停止失败')) }) render() - await screen.findAllByText('account-a') + await screen.findAllByText('店铺一号') - fireEvent.click(screen.getAllByLabelText('停止 account-a')[0]) + fireEvent.click(screen.getAllByLabelText('停止 店铺一号')[0]) expect((await screen.findByRole('alert')).textContent).toContain('停止失败') expect(screen.getByRole('button', { name: '重试' })).toBeTruthy() diff --git a/web/src/GatewayList.jsx b/web/src/GatewayList.jsx new file mode 100644 index 0000000..878aa59 --- /dev/null +++ b/web/src/GatewayList.jsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react' +import { useDataProvider, useGetList } from 'ra-core' +import { + Alert, + Box, + Button, + CircularProgress, + IconButton, + InputAdornment, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material' +import ContentCopyOutlined from '@mui/icons-material/ContentCopyOutlined' +import DeleteOutlined from '@mui/icons-material/DeleteOutlined' +import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined' +import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined' + +const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ +const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/ + +function CopyButton({ value }) { + return navigator.clipboard?.writeText(value)}> +} + +function TokenCell({ token, name }) { + const [revealed, setRevealed] = useState(false) + return ( + + {revealed ? token : '•'.repeat(12)} + setRevealed(value => !value)}> + {revealed ? : } + + {revealed ? : null} + + ) +} + +export function GatewayList() { + const dataProvider = useDataProvider() + const { data: gateways = [], error, isPending, refetch } = useGetList('gateways') + const [form, setForm] = useState({ name: '', endpoint: '', token: '' }) + const [busy, setBusy] = useState('') + const [localError, setLocalError] = useState('') + const [created, setCreated] = useState(null) + + useEffect(() => { + document.title = 'CreatorHub · 网关管理' + }, []) + + const token = form.token.trim() + const createValid = namePattern.test(form.name) && /^https?:\/\/\S+$/.test(form.endpoint) && (token === '' || tokenPattern.test(token)) + + async function registerGateway(event) { + event.preventDefault() + if (!createValid) return + setBusy('create') + setLocalError('') + try { + const { data } = await dataProvider.create('gateways', { data: { name: form.name, endpoint: form.endpoint, token } }) + setCreated(data) + setForm({ name: '', endpoint: '', token: '' }) + await refetch() + } catch (reason) { + setLocalError(reason.message) + } finally { + setBusy('') + } + } + + async function remove(gateway) { + if (!window.confirm(`删除网关 ${gateway.name}?仍被环境引用时删除会被拒绝。`)) return + setBusy(gateway.name) + setLocalError('') + try { + await dataProvider.delete('gateways', { id: gateway.name }) + await refetch() + } catch (reason) { + setLocalError(reason.message) + } finally { + setBusy('') + } + } + + const message = localError || error?.message + return ( + <> + 网关管理注册 Docker 网关;令牌须与网关进程的 GATEWAY_TOKEN 一致 + + {created ? ( + setCreated(null)}>知道了}> + 网关 {created.name} 已注册。 + 令牌 {created.token} + + + ) : null} + + + + 名称 + setForm(current => ({ ...current, name: event.target.value }))} placeholder="如 gw-main" /> + + + Endpoint + setForm(current => ({ ...current, endpoint: event.target.value }))} placeholder="http://docker-gateway:8081" /> + + + 令牌(可选) + setForm(current => ({ ...current, token: event.target.value }))} placeholder="留空由平台生成" helperText="填写则须与网关 GATEWAY_TOKEN 一致" /> + + + + + {message ? {message} : null} + {isPending ? : null} + {!isPending && gateways.length === 0 ? 暂无网关;注册后环境即可调度到该网关。 : null} + {!isPending && gateways.length > 0 ? ( + +
+ 名称Endpoint令牌操作 + + {gateways.map(gateway => ( + + {gateway.name} + {gateway.endpoint} + + + remove(gateway)}> + + + ))} + +
+
+ ) : null} + + ) +} diff --git a/web/src/GatewayList.test.jsx b/web/src/GatewayList.test.jsx new file mode 100644 index 0000000..8a784b8 --- /dev/null +++ b/web/src/GatewayList.test.jsx @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { CoreAdminContext } from 'ra-core' +import { GatewayList } from './GatewayList' + +afterEach(cleanup) + +const gateways = [{ id: 'gw-1', name: 'gw-1', endpoint: 'http://docker-gateway:8081', token: 'unit-test-gateway-token' }] + +function provider(overrides = {}) { + return { + getList: vi.fn(() => Promise.resolve({ data: gateways, total: gateways.length })), + create: vi.fn().mockResolvedValue({ data: { name: 'gw-2', endpoint: 'http://gw2:8081', token: 'generated-token-abcdef' } }), + delete: vi.fn().mockResolvedValue({ data: { id: 'gw-1' } }), + getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn(), browserAction: vi.fn(), + ...overrides, + } +} + +describe('GatewayList', () => { + it('registers a gateway and reveals the generated token', async () => { + const dataProvider = provider() + render() + + await screen.findAllByText('gw-1') + fireEvent.change(screen.getByRole('textbox', { name: '网关名称' }), { target: { value: 'gw-2' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Endpoint' }), { target: { value: 'http://gw2:8081' } }) + fireEvent.click(screen.getByRole('button', { name: '注册网关' })) + + await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('gateways', { data: { name: 'gw-2', endpoint: 'http://gw2:8081', token: '' } })) + expect((await screen.findByRole('alert')).textContent).toContain('generated-token-abcdef') + }) + + it('rejects an invalid endpoint before submit', async () => { + const dataProvider = provider() + render() + + await screen.findAllByText('gw-1') + fireEvent.change(screen.getByRole('textbox', { name: '网关名称' }), { target: { value: 'gw-2' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Endpoint' }), { target: { value: 'ftp://gw2:8081' } }) + expect(screen.getByRole('button', { name: '注册网关' }).disabled).toBe(true) + expect(dataProvider.create).not.toHaveBeenCalled() + }) + + it('deletes a gateway after confirm', async () => { + const dataProvider = provider() + window.confirm = vi.fn(() => true) + render() + + fireEvent.click(await screen.findByLabelText('删除 gw-1')) + + await waitFor(() => expect(dataProvider.delete).toHaveBeenCalledWith('gateways', { id: 'gw-1' })) + }) +}) diff --git a/web/src/dataProvider.js b/web/src/dataProvider.js index 5be2b81..c3d3067 100644 --- a/web/src/dataProvider.js +++ b/web/src/dataProvider.js @@ -1,7 +1,7 @@ import { HttpError } from 'ra-core' async function request(path = '', options) { - const response = await fetch(`/api/browsers${path}`, options) + const response = await fetch(`/api${path}`, options) if (!response.ok) { const body = await response.json().catch(() => ({})) throw new HttpError(body.error || `请求失败 (${response.status})`, response.status, body) @@ -9,38 +9,62 @@ async function request(path = '', options) { return response.status === 204 ? null : response.json() } -const unsupported = operation => Promise.reject(new Error(`browsers 不支持 ${operation}`)) +const jsonOptions = (method, data) => ({ + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), +}) + +const unsupported = (resource, operation) => Promise.reject(new Error(`${resource} 不支持 ${operation}`)) + +const resourcePaths = { + browsers: '/browsers', + 'browser-images': '/browser-images', + gateways: '/gateways', +} export const dataProvider = { async getList(resource) { - if (resource !== 'browsers') return unsupported('getList') - const browsers = await request() - return { - data: browsers.map(browser => ({ ...browser, id: browser.id || browser.name })), - total: browsers.length, - } + const path = resourcePaths[resource] + if (!path) return unsupported(resource, 'getList') + const records = await request(path) + return { data: records.map(record => ({ ...record, id: record.alias ?? record.version ?? record.name })), total: records.length } }, async create(resource, { data }) { - if (resource !== 'browsers') return unsupported('create') - const created = await request('', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - return { data: { ...data, ...created, id: created.id || created.name } } + const path = resourcePaths[resource] + if (!path) return unsupported(resource, 'create') + const created = await request(path, jsonOptions('POST', data)) + return { data: { ...data, ...created, id: created?.alias ?? created?.version ?? created?.name ?? data.alias } } }, - getOne: () => unsupported('getOne'), - getMany: () => unsupported('getMany'), - getManyReference: () => unsupported('getManyReference'), - update: () => unsupported('update'), - updateMany: () => unsupported('updateMany'), - delete: () => unsupported('delete'), - deleteMany: () => unsupported('deleteMany'), - async browserAction(name, action) { + async update(resource, { id, data }) { + const path = resourcePaths[resource] + if (!path || resource === 'browsers') return unsupported(resource, 'update') + // browser-images 的 PUT 不接受 version 字段(路径已携带),透传其余字段。 + const { version: _ignored, ...rest } = data ?? {} + await request(`${path}/${encodeURIComponent(id)}`, jsonOptions('PUT', rest)) + return { data: { ...rest, id } } + }, + async delete(resource, { id }) { + const path = resourcePaths[resource] + if (!path) return unsupported(resource, 'delete') + await request(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' }) + return { data: { id } } + }, + getOne: resource => unsupported(resource, 'getOne'), + getMany: resource => unsupported(resource, 'getMany'), + getManyReference: resource => unsupported(resource, 'getManyReference'), + updateMany: resource => unsupported(resource, 'updateMany'), + deleteMany: resource => unsupported(resource, 'deleteMany'), + // 环境的领域动作保持显式动词,不伪装成 CRUD update。 + async browserAction(alias, action, data) { + if (action === 'upgrade') { + await request(`/browsers/${encodeURIComponent(alias)}/upgrade`, jsonOptions('POST', data)) + return + } const paths = { - start: [`/${encodeURIComponent(name)}/start`, 'POST'], - stop: [`/${encodeURIComponent(name)}/stop`, 'POST'], - recycle: [`/${encodeURIComponent(name)}`, 'DELETE'], + start: [`/browsers/${encodeURIComponent(alias)}/start`, 'POST'], + stop: [`/browsers/${encodeURIComponent(alias)}/stop`, 'POST'], + recycle: [`/browsers/${encodeURIComponent(alias)}`, 'DELETE'], } const target = paths[action] if (!target) throw new Error(`未知运行环境操作: ${action}`) diff --git a/web/src/layout.jsx b/web/src/layout.jsx index 8ba70ff..12fea15 100644 --- a/web/src/layout.jsx +++ b/web/src/layout.jsx @@ -16,6 +16,8 @@ import ChevronLeft from '@mui/icons-material/ChevronLeft' import ChevronRight from '@mui/icons-material/ChevronRight' import DnsOutlined from '@mui/icons-material/DnsOutlined' import HistoryOutlined from '@mui/icons-material/HistoryOutlined' +import HubOutlined from '@mui/icons-material/HubOutlined' +import Inventory2Outlined from '@mui/icons-material/Inventory2Outlined' import MenuIcon from '@mui/icons-material/Menu' import WidgetsOutlined from '@mui/icons-material/WidgetsOutlined' import { useTheme } from '@mui/material/styles' @@ -35,6 +37,14 @@ export function CreatorHubMenu({ collapsed, onNavigate }) { {collapsed ? null : } + + + {collapsed ? null : } + + + + {collapsed ? null : } + {collapsed ? null : } diff --git a/web/src/main.jsx b/web/src/main.jsx index 302b345..69cb527 100644 --- a/web/src/main.jsx +++ b/web/src/main.jsx @@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client' // react-admin 5.15.1 publishes an incomplete MUI barrel; ra-core is its supported headless entry point. import { CoreAdmin, Resource } from 'ra-core' import { CssBaseline, ThemeProvider } from '@mui/material' +import { BrowserImageList } from './BrowserImageList' import { BrowserList } from './BrowserList' import { dataProvider } from './dataProvider' +import { GatewayList } from './GatewayList' import { CreatorHubLayout } from './layout' import { theme } from './theme' import './styles.css' @@ -15,6 +17,8 @@ createRoot(document.getElementById('root')).render( + + , diff --git a/web/tests/responsive.e2e.js b/web/tests/responsive.e2e.js index 271437b..9efe6fe 100644 --- a/web/tests/responsive.e2e.js +++ b/web/tests/responsive.e2e.js @@ -2,15 +2,20 @@ import { expect, test } from '@playwright/test' const runtime = { id: 'container-a', + alias: 'account-a', name: 'account-a', state: 'running', status: 'Up', + image_version: '148.0.0.1', + fingerprint: { seed: 1000 }, profile: 'creatorhub-profile-account-a', endpoint: 'http://account-a:9222', } test('keeps the create form inside a 900px viewport', async ({ page }) => { await page.route('**/api/browsers', route => route.fulfill({ json: [] })) + await page.route('**/api/gateways', route => route.fulfill({ json: [] })) + await page.route('**/api/browser-images', route => route.fulfill({ json: [] })) await page.setViewportSize({ width: 900, height: 800 }) await page.goto('/')