Files
creator-hub/cmd/control-plane/hub.go
T

493 lines
16 KiB
Go

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})
}