HH-744: converge Go service infrastructure (#6)
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM golang:1.26-alpine@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS go
|
FROM golang:1.26-alpine@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS go
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod ./
|
COPY go.mod go.sum ./
|
||||||
COPY cmd/ ./cmd/
|
COPY cmd/ ./cmd/
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/control-plane ./cmd/control-plane \
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/control-plane ./cmd/control-plane \
|
||||||
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/docker-gateway ./cmd/docker-gateway
|
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/docker-gateway ./cmd/docker-gateway
|
||||||
|
|||||||
+121
-37
@@ -1,32 +1,116 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/gofiber/fiber/v3/middleware/adaptor"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
type config struct {
|
||||||
gateway, err := url.Parse(env("DOCKER_GATEWAY_URL", "http://127.0.0.1:8081"))
|
listenAddr string
|
||||||
if err != nil {
|
gateway *url.URL
|
||||||
log.Fatal(err)
|
webDir string
|
||||||
}
|
logLevel logrus.Level
|
||||||
server := &http.Server{
|
|
||||||
Addr: env("LISTEN_ADDR", ":8080"),
|
|
||||||
Handler: newHandler(gateway, env("WEB_DIR", "web/dist")),
|
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
|
||||||
IdleTimeout: 60 * time.Second,
|
|
||||||
}
|
|
||||||
log.Printf("CreatorHub control plane listening on %s", server.Addr)
|
|
||||||
log.Fatal(server.ListenAndServe())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHandler(gateway *url.URL, webDirectory string) http.Handler {
|
func main() {
|
||||||
|
logrus.SetFormatter(&logrus.JSONFormatter{})
|
||||||
|
if err := newCommand().Execute(); err != nil {
|
||||||
|
logrus.WithField("service", "control-plane").WithError(err).Error("service stopped")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCommand() *cobra.Command {
|
||||||
|
command := &cobra.Command{
|
||||||
|
Use: "control-plane",
|
||||||
|
Short: "Run the CreatorHub control plane",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(command *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logrus.SetLevel(cfg.logLevel)
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"service": "control-plane",
|
||||||
|
"listen_addr": cfg.listenAddr,
|
||||||
|
"docker_gateway": cfg.gateway.String(),
|
||||||
|
}).Info("service starting")
|
||||||
|
return newHandler(cfg.gateway, cfg.webDir).Listen(cfg.listenAddr, fiber.ListenConfig{
|
||||||
|
GracefulContext: command.Context(),
|
||||||
|
DisableStartupMessage: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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("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")),
|
||||||
|
logLevel: level,
|
||||||
|
}
|
||||||
|
if cfg.listenAddr == "" {
|
||||||
|
return config{}, errors.New("LISTEN_ADDR must not be empty")
|
||||||
|
}
|
||||||
|
if err := validateListenAddr(cfg.listenAddr); err != nil {
|
||||||
|
return config{}, err
|
||||||
|
}
|
||||||
|
if cfg.webDir == "" {
|
||||||
|
return config{}, errors.New("WEB_DIR must not be empty")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateListenAddr(addr string) error {
|
||||||
|
_, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("LISTEN_ADDR must be a host:port address")
|
||||||
|
}
|
||||||
|
number, err := strconv.Atoi(port)
|
||||||
|
if err != nil || number < 1 || number > 65535 {
|
||||||
|
return errors.New("LISTEN_ADDR port must be 1..65535")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHandler(gateway *url.URL, webDirectory string) *fiber.App {
|
||||||
proxy := httputil.NewSingleHostReverseProxy(gateway)
|
proxy := httputil.NewSingleHostReverseProxy(gateway)
|
||||||
originalDirector := proxy.Director
|
originalDirector := proxy.Director
|
||||||
proxy.Director = func(request *http.Request) {
|
proxy.Director = func(request *http.Request) {
|
||||||
@@ -34,32 +118,32 @@ func newHandler(gateway *url.URL, webDirectory string) http.Handler {
|
|||||||
request.URL.Path = "/v1/browsers" + strings.TrimPrefix(request.URL.Path, "/api/browsers")
|
request.URL.Path = "/v1/browsers" + strings.TrimPrefix(request.URL.Path, "/api/browsers")
|
||||||
request.Host = gateway.Host
|
request.Host = gateway.Host
|
||||||
}
|
}
|
||||||
|
proxyHandler := adaptor.HTTPHandler(proxy)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
app := fiber.New(fiber.Config{
|
||||||
mux.Handle("/api/browsers", proxy)
|
AppName: "CreatorHub control plane",
|
||||||
mux.Handle("/api/browsers/", proxy)
|
ReadTimeout: 5 * time.Second,
|
||||||
mux.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) {
|
IdleTimeout: 60 * time.Second,
|
||||||
response.WriteHeader(http.StatusNoContent)
|
|
||||||
})
|
})
|
||||||
mux.Handle("/", spaHandler(webDirectory))
|
app.Get("/healthz", func(c fiber.Ctx) error {
|
||||||
return mux
|
c.Status(fiber.StatusNoContent)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
app.All("/api/browsers", proxyHandler)
|
||||||
|
app.All("/api/browsers/*", proxyHandler)
|
||||||
|
app.Get("/*", spaHandler(webDirectory))
|
||||||
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
func spaHandler(directory string) http.Handler {
|
func spaHandler(directory string) fiber.Handler {
|
||||||
files := http.FileServer(http.Dir(directory))
|
return func(c fiber.Ctx) error {
|
||||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
path := filepath.Clean(strings.TrimPrefix(c.Path(), "/"))
|
||||||
name := filepath.Join(directory, filepath.Clean(strings.TrimPrefix(request.URL.Path, "/")))
|
if filepath.IsLocal(path) {
|
||||||
if info, err := os.Stat(name); err == nil && !info.IsDir() {
|
name := filepath.Join(directory, path)
|
||||||
files.ServeHTTP(response, request)
|
if info, err := os.Stat(name); err == nil && !info.IsDir() {
|
||||||
return
|
return c.SendFile(name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
http.ServeFile(response, request, filepath.Join(directory, "index.html"))
|
return c.SendFile(filepath.Join(directory, "index.html"))
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func env(name, fallback string) string {
|
|
||||||
if value := os.Getenv(name); value != "" {
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
return fallback
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,15 +22,47 @@ func TestAPIProxyPreservesMethodPathAndStatus(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
request := httptest.NewRequest(http.MethodPost, "/api/browsers/account-a/start", nil)
|
request := httptest.NewRequest(http.MethodPost, "/api/browsers/account-a/start?force=true", nil)
|
||||||
response := httptest.NewRecorder()
|
response, err := newHandler(upstream, t.TempDir()).Test(request)
|
||||||
newHandler(upstream, t.TempDir()).ServeHTTP(response, request)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
proxied := <-requests
|
proxied := <-requests
|
||||||
if proxied.Method != http.MethodPost || proxied.URL.Path != "/v1/browsers/account-a/start" {
|
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.Path)
|
t.Fatalf("unexpected proxy request %s %s", proxied.Method, proxied.URL.String())
|
||||||
}
|
}
|
||||||
if response.Code != http.StatusServiceUnavailable {
|
if response.StatusCode != http.StatusServiceUnavailable {
|
||||||
t.Fatalf("expected upstream status 503, got %d", response.Code)
|
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 TestSPAServesFileAndIndexFallback(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, "index.html"), []byte("index"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, "app.js"), []byte("asset"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app := newHandler(&url.URL{Scheme: "http", Host: "127.0.0.1"}, directory)
|
||||||
|
for path, want := range map[string]string{"/app.js": "asset", "/dashboard": "index"} {
|
||||||
|
response, err := app.Test(httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(response.Body)
|
||||||
|
response.Body.Close()
|
||||||
|
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != want {
|
||||||
|
t.Fatalf("GET %s: status=%d body=%q err=%v", path, response.StatusCode, body, readErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+169
-93
@@ -7,7 +7,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -16,6 +15,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -36,11 +40,18 @@ var networkNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
|
|||||||
var errInvalidRuntimeID = errors.New("invalid runtime id")
|
var errInvalidRuntimeID = errors.New("invalid runtime id")
|
||||||
var errUnmanagedContainer = errors.New("refusing to operate on a container not owned by CreatorHub")
|
var errUnmanagedContainer = errors.New("refusing to operate on a container not owned by CreatorHub")
|
||||||
|
|
||||||
type config struct {
|
type browserConfig struct {
|
||||||
image string
|
image string
|
||||||
network string
|
network string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type serviceConfig struct {
|
||||||
|
listenAddr string
|
||||||
|
dockerSock string
|
||||||
|
network string
|
||||||
|
logLevel logrus.Level
|
||||||
|
}
|
||||||
|
|
||||||
type dockerClient struct {
|
type dockerClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
@@ -48,7 +59,7 @@ type dockerClient struct {
|
|||||||
|
|
||||||
type gateway struct {
|
type gateway struct {
|
||||||
docker dockerClient
|
docker dockerClient
|
||||||
config config
|
config browserConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type createRequest struct {
|
type createRequest struct {
|
||||||
@@ -66,55 +77,133 @@ type browser struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
transport := &http.Transport{
|
logrus.SetFormatter(&logrus.JSONFormatter{})
|
||||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
if err := newCommand().Execute(); err != nil {
|
||||||
return (&net.Dialer{}).DialContext(ctx, "unix", env("DOCKER_SOCKET", "/var/run/docker.sock"))
|
logrus.WithField("service", "docker-gateway").WithError(err).Error("service stopped")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCommand() *cobra.Command {
|
||||||
|
command := &cobra.Command{
|
||||||
|
Use: "docker-gateway",
|
||||||
|
Short: "Run the restricted CreatorHub Docker gateway",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(command *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logrus.SetLevel(cfg.logLevel)
|
||||||
|
return run(command, cfg)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (serviceConfig, error) {
|
||||||
|
v := viper.New()
|
||||||
|
v.SetDefault("listen_addr", ":8081")
|
||||||
|
v.SetDefault("docker_socket", "/var/run/docker.sock")
|
||||||
|
v.SetDefault("browser_network", "creatorhub_browser")
|
||||||
|
v.SetDefault("log_level", "info")
|
||||||
|
_ = v.BindEnv("listen_addr", "LISTEN_ADDR")
|
||||||
|
_ = v.BindEnv("docker_socket", "DOCKER_SOCKET")
|
||||||
|
_ = v.BindEnv("browser_network", "BROWSER_NETWORK")
|
||||||
|
_ = v.BindEnv("log_level", "LOG_LEVEL")
|
||||||
|
|
||||||
|
level, err := logrus.ParseLevel(v.GetString("log_level"))
|
||||||
|
if err != nil {
|
||||||
|
return serviceConfig{}, errors.New("LOG_LEVEL must be panic, fatal, error, warn, info, debug, or trace")
|
||||||
|
}
|
||||||
|
cfg := serviceConfig{
|
||||||
|
listenAddr: strings.TrimSpace(v.GetString("listen_addr")),
|
||||||
|
dockerSock: strings.TrimSpace(v.GetString("docker_socket")),
|
||||||
|
network: strings.TrimSpace(v.GetString("browser_network")),
|
||||||
|
logLevel: level,
|
||||||
|
}
|
||||||
|
if cfg.listenAddr == "" {
|
||||||
|
return serviceConfig{}, errors.New("LISTEN_ADDR must not be empty")
|
||||||
|
}
|
||||||
|
if err := validateListenAddr(cfg.listenAddr); err != nil {
|
||||||
|
return serviceConfig{}, err
|
||||||
|
}
|
||||||
|
if cfg.dockerSock == "" {
|
||||||
|
return serviceConfig{}, errors.New("DOCKER_SOCKET must not be empty")
|
||||||
|
}
|
||||||
|
if err := validateBrowserNetwork(cfg.network); err != nil {
|
||||||
|
return serviceConfig{}, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateListenAddr(addr string) error {
|
||||||
|
_, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("LISTEN_ADDR must be a host:port address")
|
||||||
|
}
|
||||||
|
number, err := strconv.Atoi(port)
|
||||||
|
if err != nil || number < 1 || number > 65535 {
|
||||||
|
return errors.New("LISTEN_ADDR port must be 1..65535")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(command *cobra.Command, cfg serviceConfig) error {
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", cfg.dockerSock)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
docker := dockerClient{
|
docker := dockerClient{
|
||||||
baseURL: "http://docker/v1.43",
|
baseURL: "http://docker/v1.43",
|
||||||
client: &http.Client{Transport: transport, Timeout: 30 * time.Second},
|
client: &http.Client{Transport: transport, Timeout: 30 * time.Second},
|
||||||
}
|
}
|
||||||
network := env("BROWSER_NETWORK", "creatorhub_browser")
|
if err := docker.ensureInternalNetwork(cfg.network); err != nil {
|
||||||
if err := docker.ensureInternalNetwork(network); err != nil {
|
return err
|
||||||
log.Fatal(err)
|
|
||||||
}
|
}
|
||||||
handler := newGateway(docker, config{image: browserImage, network: network})
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"service": "docker-gateway",
|
||||||
server := &http.Server{
|
"listen_addr": cfg.listenAddr,
|
||||||
Addr: env("LISTEN_ADDR", ":8081"),
|
"network": cfg.network,
|
||||||
Handler: handler,
|
}).Info("service starting")
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
return newGateway(docker, browserConfig{image: browserImage, network: cfg.network}).Listen(cfg.listenAddr, fiber.ListenConfig{
|
||||||
IdleTimeout: 60 * time.Second,
|
GracefulContext: command.Context(),
|
||||||
}
|
DisableStartupMessage: true,
|
||||||
log.Printf("restricted Docker gateway listening on %s", server.Addr)
|
|
||||||
log.Fatal(server.ListenAndServe())
|
|
||||||
}
|
|
||||||
|
|
||||||
func newGateway(client dockerClient, cfg config) http.Handler {
|
|
||||||
api := gateway{docker: client, config: cfg}
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) {
|
|
||||||
response.WriteHeader(http.StatusNoContent)
|
|
||||||
})
|
})
|
||||||
mux.HandleFunc("GET /v1/browsers", api.list)
|
|
||||||
mux.HandleFunc("POST /v1/browsers", api.create)
|
|
||||||
mux.HandleFunc("POST /v1/browsers/{id}/{action}", api.changeState)
|
|
||||||
mux.HandleFunc("DELETE /v1/browsers/{id}", api.remove)
|
|
||||||
return mux
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api gateway) list(response http.ResponseWriter, _ *http.Request) {
|
func newGateway(client dockerClient, cfg browserConfig) *fiber.App {
|
||||||
|
api := gateway{docker: client, config: cfg}
|
||||||
|
app := fiber.New(fiber.Config{
|
||||||
|
AppName: "CreatorHub Docker gateway",
|
||||||
|
BodyLimit: 1 << 20,
|
||||||
|
ReadTimeout: 5 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
})
|
||||||
|
app.Get("/healthz", func(c fiber.Ctx) error {
|
||||||
|
c.Status(fiber.StatusNoContent)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
app.Get("/v1/browsers", api.list)
|
||||||
|
app.Post("/v1/browsers", api.create)
|
||||||
|
app.Post("/v1/browsers/:id/:action", api.changeState)
|
||||||
|
app.Delete("/v1/browsers/:id", api.remove)
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api gateway) list(c fiber.Ctx) error {
|
||||||
filters, _ := json.Marshal(map[string][]string{"label": {managedLabel + "=true"}})
|
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)
|
result, err := api.docker.request(http.MethodGet, "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(response, http.StatusBadGateway, err)
|
return writeError(c, http.StatusBadGateway, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer result.Body.Close()
|
defer result.Body.Close()
|
||||||
if result.StatusCode != http.StatusOK {
|
if result.StatusCode != http.StatusOK {
|
||||||
forwardDockerError(response, result)
|
return forwardDockerError(c, result)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var containers []struct {
|
var containers []struct {
|
||||||
@@ -124,8 +213,7 @@ func (api gateway) list(response http.ResponseWriter, _ *http.Request) {
|
|||||||
Labels map[string]string `json:"Labels"`
|
Labels map[string]string `json:"Labels"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(result.Body).Decode(&containers); err != nil {
|
if err := json.NewDecoder(result.Body).Decode(&containers); err != nil {
|
||||||
writeError(response, http.StatusBadGateway, fmt.Errorf("decode Docker response: %w", err))
|
return writeError(c, http.StatusBadGateway, fmt.Errorf("decode Docker response: %w", err))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
browsers := make([]browser, 0, len(containers))
|
browsers := make([]browser, 0, len(containers))
|
||||||
@@ -143,20 +231,18 @@ func (api gateway) list(response http.ResponseWriter, _ *http.Request) {
|
|||||||
Endpoint: "http://" + namePrefix + id + ":9222",
|
Endpoint: "http://" + namePrefix + id + ":9222",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(response, http.StatusOK, browsers)
|
return writeJSON(c, http.StatusOK, browsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api gateway) create(response http.ResponseWriter, request *http.Request) {
|
func (api gateway) create(c fiber.Ctx) error {
|
||||||
var input createRequest
|
var input createRequest
|
||||||
decoder := json.NewDecoder(http.MaxBytesReader(response, request.Body, 1<<20))
|
decoder := json.NewDecoder(bytes.NewReader(c.Body()))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
if err := decoder.Decode(&input); err != nil {
|
if err := decoder.Decode(&input); err != nil {
|
||||||
writeError(response, http.StatusBadRequest, errors.New("body must contain only name and numeric seed"))
|
return writeError(c, http.StatusBadRequest, errors.New("body must contain only name and numeric seed"))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if !runtimeIDPattern.MatchString(input.Name) || input.Seed < 1 || input.Seed > 2147483647 {
|
if !runtimeIDPattern.MatchString(input.Name) || input.Seed < 1 || input.Seed > 2147483647 {
|
||||||
writeError(response, http.StatusBadRequest, errors.New("name must match [a-z0-9][a-z0-9-]{0,31} and seed must be 1..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"))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pidsLimit := int64(512)
|
pidsLimit := int64(512)
|
||||||
@@ -194,39 +280,33 @@ func (api gateway) create(response http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
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.Name), payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(response, http.StatusBadGateway, err)
|
return writeError(c, http.StatusBadGateway, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer result.Body.Close()
|
defer result.Body.Close()
|
||||||
if result.StatusCode != http.StatusCreated {
|
if result.StatusCode != http.StatusCreated {
|
||||||
forwardDockerError(response, result)
|
return forwardDockerError(c, result)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
var created struct {
|
var created struct {
|
||||||
ID string `json:"Id"`
|
ID string `json:"Id"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(result.Body).Decode(&created); err != nil || created.ID == "" {
|
if err := json.NewDecoder(result.Body).Decode(&created); err != nil || created.ID == "" {
|
||||||
writeError(response, http.StatusBadGateway, errors.New("Docker returned an invalid container id"))
|
return writeError(c, http.StatusBadGateway, errors.New("Docker returned an invalid container id"))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil {
|
if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil {
|
||||||
cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent)
|
cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent)
|
||||||
if cleanupErr != nil {
|
if cleanupErr != nil {
|
||||||
writeError(response, http.StatusBadGateway, fmt.Errorf("container did not start and cleanup failed: %w; cleanup: %v", err, cleanupErr))
|
return writeError(c, http.StatusBadGateway, fmt.Errorf("container did not start and cleanup failed: %w; cleanup: %v", err, cleanupErr))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
writeError(response, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err))
|
return writeError(c, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
writeJSON(response, http.StatusCreated, map[string]string{"id": created.ID, "name": input.Name})
|
return writeJSON(c, http.StatusCreated, map[string]string{"id": created.ID, "name": input.Name})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api gateway) changeState(response http.ResponseWriter, request *http.Request) {
|
func (api gateway) changeState(c fiber.Ctx) error {
|
||||||
id := request.PathValue("id")
|
id := c.Params("id")
|
||||||
action := request.PathValue("action")
|
action := c.Params("action")
|
||||||
if err := api.requireManaged(id); err != nil {
|
if err := api.requireManaged(id); err != nil {
|
||||||
writeError(response, statusFor(err), err)
|
return writeError(c, statusFor(err), err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
var path string
|
var path string
|
||||||
switch action {
|
switch action {
|
||||||
@@ -235,28 +315,26 @@ func (api gateway) changeState(response http.ResponseWriter, request *http.Reque
|
|||||||
case "stop":
|
case "stop":
|
||||||
path = "/containers/" + url.PathEscape(namePrefix+id) + "/stop?t=10"
|
path = "/containers/" + url.PathEscape(namePrefix+id) + "/stop?t=10"
|
||||||
default:
|
default:
|
||||||
writeError(response, http.StatusNotFound, errors.New("unknown action"))
|
return writeError(c, http.StatusNotFound, errors.New("unknown action"))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if err := api.docker.expect(http.MethodPost, path, nil, http.StatusNoContent, http.StatusNotModified); err != nil {
|
if err := api.docker.expect(http.MethodPost, path, nil, http.StatusNoContent, http.StatusNotModified); err != nil {
|
||||||
writeError(response, http.StatusBadGateway, err)
|
return writeError(c, http.StatusBadGateway, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
response.WriteHeader(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api gateway) remove(response http.ResponseWriter, request *http.Request) {
|
func (api gateway) remove(c fiber.Ctx) error {
|
||||||
id := request.PathValue("id")
|
id := c.Params("id")
|
||||||
if err := api.requireManaged(id); err != nil {
|
if err := api.requireManaged(id); err != nil {
|
||||||
writeError(response, statusFor(err), err)
|
return writeError(c, statusFor(err), err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
path := "/containers/" + url.PathEscape(namePrefix+id) + "?force=1&v=0"
|
path := "/containers/" + url.PathEscape(namePrefix+id) + "?force=1&v=0"
|
||||||
if err := api.docker.expect(http.MethodDelete, path, nil, http.StatusNoContent); err != nil {
|
if err := api.docker.expect(http.MethodDelete, path, nil, http.StatusNoContent); err != nil {
|
||||||
writeError(response, http.StatusBadGateway, err)
|
return writeError(c, http.StatusBadGateway, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
response.WriteHeader(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api gateway) requireManaged(id string) error {
|
func (api gateway) requireManaged(id string) error {
|
||||||
@@ -323,11 +401,8 @@ func (docker dockerClient) expect(method, path string, payload any, allowed ...i
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (docker dockerClient) ensureInternalNetwork(name string) error {
|
func (docker dockerClient) ensureInternalNetwork(name string) error {
|
||||||
if !networkNamePattern.MatchString(name) {
|
if err := validateBrowserNetwork(name); err != nil {
|
||||||
return errors.New("BROWSER_NETWORK is invalid")
|
return err
|
||||||
}
|
|
||||||
if name == controlNetworkName {
|
|
||||||
return errors.New("BROWSER_NETWORK must not reuse the control network")
|
|
||||||
}
|
}
|
||||||
response, err := docker.request(http.MethodGet, "/networks/"+url.PathEscape(name), nil)
|
response, err := docker.request(http.MethodGet, "/networks/"+url.PathEscape(name), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -370,6 +445,16 @@ func (docker dockerClient) ensureInternalNetwork(name string) error {
|
|||||||
}, http.StatusCreated)
|
}, http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateBrowserNetwork(name string) error {
|
||||||
|
if !networkNamePattern.MatchString(name) {
|
||||||
|
return errors.New("BROWSER_NETWORK is invalid")
|
||||||
|
}
|
||||||
|
if name == controlNetworkName {
|
||||||
|
return errors.New("BROWSER_NETWORK must not reuse the control network")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func statusFor(err error) int {
|
func statusFor(err error) int {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, errInvalidRuntimeID):
|
case errors.Is(err, errInvalidRuntimeID):
|
||||||
@@ -383,28 +468,19 @@ func statusFor(err error) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func forwardDockerError(response http.ResponseWriter, result *http.Response) {
|
func forwardDockerError(c fiber.Ctx, result *http.Response) error {
|
||||||
message, _ := io.ReadAll(io.LimitReader(result.Body, 4096))
|
message, _ := io.ReadAll(io.LimitReader(result.Body, 4096))
|
||||||
status := http.StatusBadGateway
|
status := http.StatusBadGateway
|
||||||
if result.StatusCode == http.StatusConflict {
|
if result.StatusCode == http.StatusConflict {
|
||||||
status = http.StatusConflict
|
status = http.StatusConflict
|
||||||
}
|
}
|
||||||
writeError(response, status, fmt.Errorf("Docker returned %s: %s", result.Status, strings.TrimSpace(string(message))))
|
return writeError(c, status, fmt.Errorf("Docker returned %s: %s", result.Status, strings.TrimSpace(string(message))))
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeError(response http.ResponseWriter, status int, err error) {
|
func writeError(c fiber.Ctx, status int, err error) error {
|
||||||
writeJSON(response, status, map[string]string{"error": err.Error()})
|
return writeJSON(c, status, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(response http.ResponseWriter, status int, value any) {
|
func writeJSON(c fiber.Ctx, status int, value any) error {
|
||||||
response.Header().Set("Content-Type", "application/json")
|
return c.Status(status).JSON(value)
|
||||||
response.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(response).Encode(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func env(name, fallback string) string {
|
|
||||||
if value := os.Getenv(name); value != "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3/middleware/adaptor"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGatewayCreatesConstrainedBrowser(t *testing.T) {
|
func TestGatewayCreatesConstrainedBrowser(t *testing.T) {
|
||||||
@@ -35,13 +37,13 @@ func TestGatewayCreatesConstrainedBrowser(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer docker.Close()
|
defer docker.Close()
|
||||||
|
|
||||||
handler := newGateway(dockerClient{baseURL: docker.URL + "/v1.43", client: docker.Client()}, config{
|
handler := newGateway(dockerClient{baseURL: docker.URL + "/v1.43", client: docker.Client()}, browserConfig{
|
||||||
image: "registry.example/browser:1.2.3",
|
image: "registry.example/browser:1.2.3",
|
||||||
network: "creatorhub_browser",
|
network: "creatorhub_browser",
|
||||||
})
|
})
|
||||||
request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`))
|
request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`))
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(response, request)
|
adaptor.FiberApp(handler).ServeHTTP(response, request)
|
||||||
|
|
||||||
if response.Code != http.StatusCreated {
|
if response.Code != http.StatusCreated {
|
||||||
t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String())
|
t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String())
|
||||||
@@ -66,6 +68,16 @@ func TestGatewayCreatesConstrainedBrowser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGatewayRejectsOversizedCreateRequest(t *testing.T) {
|
||||||
|
handler := newGateway(dockerClient{}, browserConfig{})
|
||||||
|
request := httptest.NewRequest(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 {
|
||||||
|
t.Fatalf("expected 413 for oversized body, status=%d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGatewayRemovesFailedContainerAndPreservesProfile(t *testing.T) {
|
func TestGatewayRemovesFailedContainerAndPreservesProfile(t *testing.T) {
|
||||||
removed := false
|
removed := false
|
||||||
docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
@@ -84,10 +96,10 @@ func TestGatewayRemovesFailedContainerAndPreservesProfile(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer docker.Close()
|
defer docker.Close()
|
||||||
|
|
||||||
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{image: browserImage, network: "creatorhub_browser"})
|
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}`))
|
request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`))
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(response, request)
|
adaptor.FiberApp(handler).ServeHTTP(response, request)
|
||||||
|
|
||||||
if response.Code != http.StatusBadGateway || !removed {
|
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())
|
t.Fatalf("expected failed container cleanup with preserved volume, status=%d removed=%v body=%s", response.Code, removed, response.Body.String())
|
||||||
@@ -103,9 +115,9 @@ func TestGatewayListsBrowsers(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer docker.Close()
|
defer docker.Close()
|
||||||
|
|
||||||
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{})
|
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{})
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/browsers", nil))
|
adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/browsers", nil))
|
||||||
|
|
||||||
var browsers []browser
|
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].Name != "account-a" {
|
||||||
@@ -137,9 +149,9 @@ func TestGatewayLifecycle(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer docker.Close()
|
defer docker.Close()
|
||||||
|
|
||||||
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{})
|
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{})
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
|
adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
|
||||||
if response.Code != http.StatusNoContent {
|
if response.Code != http.StatusNoContent {
|
||||||
t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String())
|
t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
@@ -153,9 +165,9 @@ func TestGatewayMapsDockerServiceFailureToBadGateway(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer docker.Close()
|
defer docker.Close()
|
||||||
|
|
||||||
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{})
|
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{})
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/v1/browsers/account-a", nil))
|
adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/v1/browsers/account-a", nil))
|
||||||
if response.Code != http.StatusBadGateway {
|
if response.Code != http.StatusBadGateway {
|
||||||
t.Fatalf("expected 502 for Docker failure, got %d: %s", response.Code, response.Body.String())
|
t.Fatalf("expected 502 for Docker failure, got %d: %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
@@ -174,10 +186,10 @@ func TestGatewayRefusesUnmanagedContainer(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer docker.Close()
|
defer docker.Close()
|
||||||
|
|
||||||
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{})
|
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{})
|
||||||
request := httptest.NewRequest(http.MethodDelete, "/v1/browsers/foreign", nil)
|
request := httptest.NewRequest(http.MethodDelete, "/v1/browsers/foreign", nil)
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(response, request)
|
adaptor.FiberApp(handler).ServeHTTP(response, request)
|
||||||
|
|
||||||
if response.Code != http.StatusForbidden || deleted {
|
if response.Code != http.StatusForbidden || deleted {
|
||||||
t.Fatalf("expected unmanaged container to be rejected, status=%d deleted=%v", response.Code, deleted)
|
t.Fatalf("expected unmanaged container to be rejected, status=%d deleted=%v", response.Code, deleted)
|
||||||
@@ -288,6 +300,13 @@ func TestEnsureInternalNetworkRejectsControlNetwork(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigRejectsControlNetwork(t *testing.T) {
|
||||||
|
t.Setenv("BROWSER_NETWORK", controlNetworkName)
|
||||||
|
if _, err := loadConfig(); err == nil {
|
||||||
|
t.Fatal("expected control network configuration to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDockerIntegrationBlocksCrossContainerTraffic(t *testing.T) {
|
func TestDockerIntegrationBlocksCrossContainerTraffic(t *testing.T) {
|
||||||
if os.Getenv("CREATORHUB_DOCKER_INTEGRATION") != "1" {
|
if os.Getenv("CREATORHUB_DOCKER_INTEGRATION") != "1" {
|
||||||
t.Skip("set CREATORHUB_DOCKER_INTEGRATION=1 to run")
|
t.Skip("set CREATORHUB_DOCKER_INTEGRATION=1 to run")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## 技术选型
|
## 技术选型
|
||||||
|
|
||||||
- 前端:React + Vite。阶段 A 只有一个运行环境页面,不引入路由、状态库或组件库。
|
- 前端:React + Vite。阶段 A 只有一个运行环境页面,不引入路由、状态库或组件库。
|
||||||
- 后端:Go 标准库模块化单体。控制面提供同源 API 和静态文件,受限网关单独封装 Docker Engine API。
|
- 后端:Go 模块化单体,Fiber v3 提供 HTTP 路由,Viper 读取并校验启动配置,Logrus 输出 JSON 结构化日志,Cobra 保持当前两个服务入口。控制面提供同源 API 和静态文件,受限网关单独封装 Docker Engine API。
|
||||||
- 数据:本任务不引入业务数据库;Docker 容器标签是运行态事实,Profile 使用命名卷持久化。账号、任务和审计实体仍按产品规划在后续阶段落入 Postgres。
|
- 数据:本任务不引入业务数据库;Docker 容器标签是运行态事实,Profile 使用命名卷持久化。账号、任务和审计实体仍按产品规划在后续阶段落入 Postgres。
|
||||||
- 部署:Docker Compose 启动控制面和受限网关;浏览器容器由网关动态创建。
|
- 部署:Docker Compose 启动控制面和受限网关;浏览器容器由网关动态创建。
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,39 @@
|
|||||||
module git.ipao.vip/rogee/creator-hub
|
module git.ipao.vip/rogee/creator-hub
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gofiber/fiber/v3 v3.5.0
|
||||||
|
github.com/sirupsen/logrus v1.10.0
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/gofiber/schema v1.8.3 // indirect
|
||||||
|
github.com/gofiber/utils/v2 v2.4.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.19.2 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/tinylib/msgp v1.6.4 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.73.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.54.0 // indirect
|
||||||
|
golang.org/x/net v0.57.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.40.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||||
|
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/gofiber/fiber/v3 v3.5.0 h1:dk7TOUH6DXJGtOLsN2XEG+0ZML7cznzHILTVozbNEK8=
|
||||||
|
github.com/gofiber/fiber/v3 v3.5.0/go.mod h1:GOVDTW+gjJvfe0iJyVujbQ1Lnx+JUjFySJRI/9/xX/w=
|
||||||
|
github.com/gofiber/schema v1.8.3 h1:06ZedxIYjngzc0095PYy7uWnFnbRflWFpikvZH61fDc=
|
||||||
|
github.com/gofiber/schema v1.8.3/go.mod h1:jWnnZdhcW1mHyV+VnfRxKJDPNcepJsTZ9RIWxrr32Ng=
|
||||||
|
github.com/gofiber/utils/v2 v2.4.1 h1:E2X9G8O5Mn7b2GDb0JU3IUk42Rw2npuhhepIbuJQ2po=
|
||||||
|
github.com/gofiber/utils/v2 v2.4.1/go.mod h1:I+RTsgMUdzFuifVc3LOEkfh32wQW9BfRl7l5RYjamW4=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||||
|
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/shamaton/msgpack/v3 v3.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8=
|
||||||
|
github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo=
|
||||||
|
github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc=
|
||||||
|
github.com/sirupsen/logrus v1.10.0/go.mod h1:FXZFonkDAnFozmO+5hGAFvB0Yg9/j2SIhA/QuIkP180=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||||
|
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
|
||||||
|
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
Reference in New Issue
Block a user