diff --git a/Dockerfile b/Dockerfile index 896dd8b..ae7678c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN npm run build FROM golang:1.26-alpine@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS go WORKDIR /src -COPY go.mod ./ +COPY go.mod go.sum ./ COPY cmd/ ./cmd/ 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 diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index 105d565..af6fdee 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -1,32 +1,116 @@ package main import ( - "log" + "errors" + "net" "net/http" "net/http/httputil" "net/url" "os" "path/filepath" + "strconv" "strings" "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() { - gateway, err := url.Parse(env("DOCKER_GATEWAY_URL", "http://127.0.0.1:8081")) - if err != nil { - log.Fatal(err) - } - 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()) +type config struct { + listenAddr string + gateway *url.URL + webDir string + logLevel logrus.Level } -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) originalDirector := proxy.Director 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.Host = gateway.Host } + proxyHandler := adaptor.HTTPHandler(proxy) - mux := http.NewServeMux() - mux.Handle("/api/browsers", proxy) - mux.Handle("/api/browsers/", proxy) - mux.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) { - response.WriteHeader(http.StatusNoContent) + app := fiber.New(fiber.Config{ + AppName: "CreatorHub control plane", + ReadTimeout: 5 * time.Second, + IdleTimeout: 60 * time.Second, }) - mux.Handle("/", spaHandler(webDirectory)) - return mux + app.Get("/healthz", func(c fiber.Ctx) error { + 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 { - files := http.FileServer(http.Dir(directory)) - return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - name := filepath.Join(directory, filepath.Clean(strings.TrimPrefix(request.URL.Path, "/"))) - if info, err := os.Stat(name); err == nil && !info.IsDir() { - files.ServeHTTP(response, request) - return +func spaHandler(directory string) fiber.Handler { + return func(c fiber.Ctx) error { + path := filepath.Clean(strings.TrimPrefix(c.Path(), "/")) + if filepath.IsLocal(path) { + name := filepath.Join(directory, path) + if info, err := os.Stat(name); err == nil && !info.IsDir() { + return c.SendFile(name) + } } - http.ServeFile(response, request, filepath.Join(directory, "index.html")) - }) -} - -func env(name, fallback string) string { - if value := os.Getenv(name); value != "" { - return value + return c.SendFile(filepath.Join(directory, "index.html")) } - return fallback } diff --git a/cmd/control-plane/main_test.go b/cmd/control-plane/main_test.go index 299b914..c7645a7 100644 --- a/cmd/control-plane/main_test.go +++ b/cmd/control-plane/main_test.go @@ -1,9 +1,12 @@ package main import ( + "io" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "testing" ) @@ -19,15 +22,47 @@ func TestAPIProxyPreservesMethodPathAndStatus(t *testing.T) { if err != nil { t.Fatal(err) } - request := httptest.NewRequest(http.MethodPost, "/api/browsers/account-a/start", nil) - response := httptest.NewRecorder() - newHandler(upstream, t.TempDir()).ServeHTTP(response, request) + 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" { - t.Fatalf("unexpected proxy request %s %s", proxied.Method, proxied.URL.Path) + 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.Code != http.StatusServiceUnavailable { - t.Fatalf("expected upstream status 503, got %d", response.Code) + 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 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) + } } } diff --git a/cmd/docker-gateway/main.go b/cmd/docker-gateway/main.go index 27fbd96..cd6e541 100644 --- a/cmd/docker-gateway/main.go +++ b/cmd/docker-gateway/main.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "log" "net" "net/http" "net/url" @@ -16,6 +15,11 @@ import ( "strconv" "strings" "time" + + "github.com/gofiber/fiber/v3" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" ) 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 errUnmanagedContainer = errors.New("refusing to operate on a container not owned by CreatorHub") -type config struct { +type browserConfig struct { image string network string } +type serviceConfig struct { + listenAddr string + dockerSock string + network string + logLevel logrus.Level +} + type dockerClient struct { baseURL string client *http.Client @@ -48,7 +59,7 @@ type dockerClient struct { type gateway struct { docker dockerClient - config config + config browserConfig } type createRequest struct { @@ -66,55 +77,133 @@ type browser struct { } func main() { - transport := &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", env("DOCKER_SOCKET", "/var/run/docker.sock")) + logrus.SetFormatter(&logrus.JSONFormatter{}) + if err := newCommand().Execute(); err != nil { + 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{ baseURL: "http://docker/v1.43", client: &http.Client{Transport: transport, Timeout: 30 * time.Second}, } - network := env("BROWSER_NETWORK", "creatorhub_browser") - if err := docker.ensureInternalNetwork(network); err != nil { - log.Fatal(err) + if err := docker.ensureInternalNetwork(cfg.network); err != nil { + return err } - handler := newGateway(docker, config{image: browserImage, network: network}) - - server := &http.Server{ - Addr: env("LISTEN_ADDR", ":8081"), - Handler: handler, - ReadHeaderTimeout: 5 * time.Second, - IdleTimeout: 60 * time.Second, - } - 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) + logrus.WithFields(logrus.Fields{ + "service": "docker-gateway", + "listen_addr": cfg.listenAddr, + "network": cfg.network, + }).Info("service starting") + return newGateway(docker, browserConfig{image: browserImage, network: cfg.network}).Listen(cfg.listenAddr, fiber.ListenConfig{ + GracefulContext: command.Context(), + DisableStartupMessage: true, }) - 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"}}) result, err := api.docker.request(http.MethodGet, "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil) if err != nil { - writeError(response, http.StatusBadGateway, err) - return + return writeError(c, http.StatusBadGateway, err) } defer result.Body.Close() if result.StatusCode != http.StatusOK { - forwardDockerError(response, result) - return + return forwardDockerError(c, result) } var containers []struct { @@ -124,8 +213,7 @@ func (api gateway) list(response http.ResponseWriter, _ *http.Request) { Labels map[string]string `json:"Labels"` } if err := json.NewDecoder(result.Body).Decode(&containers); err != nil { - writeError(response, http.StatusBadGateway, fmt.Errorf("decode Docker response: %w", err)) - return + return writeError(c, http.StatusBadGateway, fmt.Errorf("decode Docker response: %w", err)) } browsers := make([]browser, 0, len(containers)) @@ -143,20 +231,18 @@ func (api gateway) list(response http.ResponseWriter, _ *http.Request) { 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 - decoder := json.NewDecoder(http.MaxBytesReader(response, request.Body, 1<<20)) + decoder := json.NewDecoder(bytes.NewReader(c.Body())) decoder.DisallowUnknownFields() if err := decoder.Decode(&input); err != nil { - writeError(response, http.StatusBadRequest, errors.New("body must contain only name and numeric seed")) - return + return writeError(c, http.StatusBadRequest, errors.New("body must contain only name and numeric seed")) } 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 + return writeError(c, http.StatusBadRequest, errors.New("name must match [a-z0-9][a-z0-9-]{0,31} and seed must be 1..2147483647")) } 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) if err != nil { - writeError(response, http.StatusBadGateway, err) - return + return writeError(c, http.StatusBadGateway, err) } defer result.Body.Close() if result.StatusCode != http.StatusCreated { - forwardDockerError(response, result) - return + return forwardDockerError(c, result) } var created struct { ID string `json:"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 + return writeError(c, http.StatusBadGateway, errors.New("Docker returned an invalid container id")) } 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) if cleanupErr != nil { - writeError(response, http.StatusBadGateway, fmt.Errorf("container did not start and cleanup failed: %w; cleanup: %v", err, cleanupErr)) - return + return writeError(c, http.StatusBadGateway, fmt.Errorf("container did not start and cleanup failed: %w; cleanup: %v", err, cleanupErr)) } - writeError(response, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err)) - return + return writeError(c, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err)) } - 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) { - id := request.PathValue("id") - action := request.PathValue("action") +func (api gateway) changeState(c fiber.Ctx) error { + id := c.Params("id") + action := c.Params("action") if err := api.requireManaged(id); err != nil { - writeError(response, statusFor(err), err) - return + return writeError(c, statusFor(err), err) } var path string switch action { @@ -235,28 +315,26 @@ func (api gateway) changeState(response http.ResponseWriter, request *http.Reque case "stop": path = "/containers/" + url.PathEscape(namePrefix+id) + "/stop?t=10" default: - writeError(response, http.StatusNotFound, errors.New("unknown action")) - return + return writeError(c, http.StatusNotFound, errors.New("unknown action")) } if err := api.docker.expect(http.MethodPost, path, nil, http.StatusNoContent, http.StatusNotModified); err != nil { - writeError(response, http.StatusBadGateway, err) - return + return writeError(c, http.StatusBadGateway, err) } - response.WriteHeader(http.StatusNoContent) + c.Status(http.StatusNoContent) + return nil } -func (api gateway) remove(response http.ResponseWriter, request *http.Request) { - id := request.PathValue("id") +func (api gateway) remove(c fiber.Ctx) error { + id := c.Params("id") if err := api.requireManaged(id); err != nil { - writeError(response, statusFor(err), err) - return + return writeError(c, statusFor(err), err) } path := "/containers/" + url.PathEscape(namePrefix+id) + "?force=1&v=0" if err := api.docker.expect(http.MethodDelete, path, nil, http.StatusNoContent); err != nil { - writeError(response, http.StatusBadGateway, err) - return + return writeError(c, http.StatusBadGateway, err) } - response.WriteHeader(http.StatusNoContent) + c.Status(http.StatusNoContent) + return nil } 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 { - 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") + if err := validateBrowserNetwork(name); err != nil { + return err } response, err := docker.request(http.MethodGet, "/networks/"+url.PathEscape(name), nil) if err != nil { @@ -370,6 +445,16 @@ func (docker dockerClient) ensureInternalNetwork(name string) error { }, 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 { switch { 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)) status := http.StatusBadGateway if result.StatusCode == 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) { - writeJSON(response, status, map[string]string{"error": err.Error()}) +func writeError(c fiber.Ctx, status int, err error) error { + return writeJSON(c, status, map[string]string{"error": err.Error()}) } -func writeJSON(response http.ResponseWriter, status int, value any) { - response.Header().Set("Content-Type", "application/json") - response.WriteHeader(status) - _ = json.NewEncoder(response).Encode(value) -} - -func env(name, fallback string) string { - if value := os.Getenv(name); value != "" { - return value - } - return fallback +func writeJSON(c fiber.Ctx, status int, value any) error { + return c.Status(status).JSON(value) } diff --git a/cmd/docker-gateway/main_test.go b/cmd/docker-gateway/main_test.go index 3142ac2..7156776 100644 --- a/cmd/docker-gateway/main_test.go +++ b/cmd/docker-gateway/main_test.go @@ -12,6 +12,8 @@ import ( "strings" "testing" "time" + + "github.com/gofiber/fiber/v3/middleware/adaptor" ) func TestGatewayCreatesConstrainedBrowser(t *testing.T) { @@ -35,13 +37,13 @@ func TestGatewayCreatesConstrainedBrowser(t *testing.T) { })) 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", network: "creatorhub_browser", }) request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`)) response := httptest.NewRecorder() - handler.ServeHTTP(response, request) + adaptor.FiberApp(handler).ServeHTTP(response, request) if response.Code != http.StatusCreated { 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) { removed := false docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { @@ -84,10 +96,10 @@ func TestGatewayRemovesFailedContainerAndPreservesProfile(t *testing.T) { })) 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}`)) response := httptest.NewRecorder() - handler.ServeHTTP(response, request) + adaptor.FiberApp(handler).ServeHTTP(response, request) 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()) @@ -103,9 +115,9 @@ func TestGatewayListsBrowsers(t *testing.T) { })) 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() - 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 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() - handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{}) + handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{}) 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 { t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String()) } @@ -153,9 +165,9 @@ func TestGatewayMapsDockerServiceFailureToBadGateway(t *testing.T) { })) 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() - 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 { 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() - 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) response := httptest.NewRecorder() - handler.ServeHTTP(response, request) + adaptor.FiberApp(handler).ServeHTTP(response, request) if response.Code != http.StatusForbidden || 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) { if os.Getenv("CREATORHUB_DOCKER_INTEGRATION") != "1" { t.Skip("set CREATORHUB_DOCKER_INTEGRATION=1 to run") diff --git a/docs/architecture/container-control.md b/docs/architecture/container-control.md index 8efde0f..9480a1b 100644 --- a/docs/architecture/container-control.md +++ b/docs/architecture/container-control.md @@ -3,7 +3,7 @@ ## 技术选型 - 前端:React + Vite。阶段 A 只有一个运行环境页面,不引入路由、状态库或组件库。 -- 后端:Go 标准库模块化单体。控制面提供同源 API 和静态文件,受限网关单独封装 Docker Engine API。 +- 后端:Go 模块化单体,Fiber v3 提供 HTTP 路由,Viper 读取并校验启动配置,Logrus 输出 JSON 结构化日志,Cobra 保持当前两个服务入口。控制面提供同源 API 和静态文件,受限网关单独封装 Docker Engine API。 - 数据:本任务不引入业务数据库;Docker 容器标签是运行态事实,Profile 使用命名卷持久化。账号、任务和审计实体仍按产品规划在后续阶段落入 Postgres。 - 部署:Docker Compose 启动控制面和受限网关;浏览器容器由网关动态创建。 diff --git a/go.mod b/go.mod index 9cdac8c..4b75c8a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,39 @@ module git.ipao.vip/rogee/creator-hub 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c088c85 --- /dev/null +++ b/go.sum @@ -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=