fix(api): wait for modules readiness before starting server (#752)

This commit is contained in:
Julien Neuhart
2023-12-13 14:37:35 +01:00
committed by GitHub
parent 4bd7cba247
commit cc65ca9b6a
16 changed files with 370 additions and 138 deletions

View File

@@ -29,13 +29,14 @@ build: ## Build the Gotenberg's Docker image
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000 API_PORT=3000
API_PORT_FROM_ENV= API_PORT_FROM_ENV=
API_START_TIMEOUT=30s
API_TIMEOUT=30s API_TIMEOUT=30s
API_ROOT_PATH=/ API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace API_TRACE_HEADER=Gotenberg-Trace
API_DISABLE_HEALTH_CHECK_LOGGING=false API_DISABLE_HEALTH_CHECK_LOGGING=false
CHROMIUM_RESTART_AFTER=0 CHROMIUM_RESTART_AFTER=0
CHROMIUM_AUTO_START=false CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=10s CHROMIUM_START_TIMEOUT=20s
CHROMIUM_INCOGNITO=false CHROMIUM_INCOGNITO=false
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
@@ -49,7 +50,7 @@ CHROMIUM_DISABLE_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=false CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_RESTART_AFTER=10 LIBREOFFICE_RESTART_AFTER=10
LIBREOFFICE_AUTO_START=false LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=10s LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_DISABLE_ROUTES=false LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info LOG_LEVEL=info
LOG_FORMAT=auto LOG_FORMAT=auto
@@ -79,6 +80,7 @@ run: ## Start a Gotenberg container
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \ --gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--api-port=$(API_PORT) \ --api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \ --api-port-from-env=$(API_PORT_FROM_ENV) \
--api-start-timeout=$(API_START_TIMEOUT) \
--api-timeout=$(API_TIMEOUT) \ --api-timeout=$(API_TIMEOUT) \
--api-root-path=$(API_ROOT_PATH) \ --api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \ --api-trace-header=$(API_TRACE_HEADER) \

View File

@@ -84,7 +84,6 @@ func Run() {
startupMessage := app.StartupMessage() startupMessage := app.StartupMessage()
if startupMessage == "" { if startupMessage == "" {
fmt.Printf("[SYSTEM] %s: application started\n", id) fmt.Printf("[SYSTEM] %s: application started\n", id)
return return
} }
@@ -144,7 +143,6 @@ func Run() {
} }
fmt.Printf("[SYSTEM] %s: application stopped\n", id) fmt.Printf("[SYSTEM] %s: application stopped\n", id)
return nil return nil
} }
}(a.(gotenberg.App))) }(a.(gotenberg.App)))

2
go.mod
View File

@@ -8,7 +8,7 @@ require (
github.com/chromedp/cdproto v0.0.0-20231205062650-00455a960d61 github.com/chromedp/cdproto v0.0.0-20231205062650-00455a960d61
github.com/chromedp/chromedp v0.9.3 github.com/chromedp/chromedp v0.9.3
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.4.0 github.com/google/uuid v1.5.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 github.com/hashicorp/go-retryablehttp v0.7.5
github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/compress v1.17.4 // indirect

4
go.sum
View File

@@ -35,8 +35,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=

View File

@@ -17,6 +17,7 @@ import (
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
) )
@@ -29,6 +30,7 @@ func init() {
// middlewares or health checks. // middlewares or health checks.
type Api struct { type Api struct {
port int port int
startTimeout time.Duration
timeout time.Duration timeout time.Duration
rootPath string rootPath string
traceHeader string traceHeader string
@@ -37,6 +39,7 @@ type Api struct {
routes []Route routes []Route
externalMiddlewares []Middleware externalMiddlewares []Middleware
healthChecks []health.CheckerOption healthChecks []health.CheckerOption
readyFn []func() error
fs *gotenberg.FileSystem fs *gotenberg.FileSystem
logger *zap.Logger logger *zap.Logger
srv *echo.Echo srv *echo.Echo
@@ -145,6 +148,7 @@ type Middleware struct {
// See https://github.com/alexliesenfeld/health for more details. // See https://github.com/alexliesenfeld/health for more details.
type HealthChecker interface { type HealthChecker interface {
Checks() ([]health.CheckerOption, error) Checks() ([]health.CheckerOption, error)
Ready() error
} }
// Descriptor returns an [Api]'s module descriptor. // Descriptor returns an [Api]'s module descriptor.
@@ -155,6 +159,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("api", flag.ExitOnError) fs := flag.NewFlagSet("api", flag.ExitOnError)
fs.Int("api-port", 3000, "Set the port on which the API should listen") fs.Int("api-port", 3000, "Set the port on which the API should listen")
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port") fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
fs.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start")
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests") fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths") fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests") fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
@@ -170,6 +175,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
func (a *Api) Provision(ctx *gotenberg.Context) error { func (a *Api) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags() flags := ctx.ParsedFlags()
a.port = flags.MustInt("api-port") a.port = flags.MustInt("api-port")
a.startTimeout = flags.MustDuration("api-start-timeout")
a.timeout = flags.MustDuration("api-timeout") a.timeout = flags.MustDuration("api-timeout")
a.rootPath = flags.MustString("api-root-path") a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header") a.traceHeader = flags.MustString("api-trace-header")
@@ -259,6 +265,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
} }
a.healthChecks = append(a.healthChecks, checks...) a.healthChecks = append(a.healthChecks, checks...)
a.readyFn = append(a.readyFn, healthChecker.Ready)
} }
// Logger. // Logger.
@@ -430,12 +437,25 @@ func (a *Api) Start() error {
func() echo.HandlerFunc { func() echo.HandlerFunc {
checks := append(a.healthChecks, health.WithTimeout(a.timeout)) checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...) checker := health.NewChecker(checks...)
return echo.WrapHandler(health.NewHandler(checker)) return echo.WrapHandler(health.NewHandler(checker))
}(), }(),
hardTimeoutMiddleware(hardTimeout), hardTimeoutMiddleware(hardTimeout),
) )
// Wait for all modules to be ready.
ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout)
defer cancel()
eg, _ := errgroup.WithContext(ctx)
for _, f := range a.readyFn {
eg.Go(f)
}
err := eg.Wait()
if err != nil {
return fmt.Errorf("waiting for modules readiness: %w", err)
}
// As the following code is blocking, run it in a goroutine. // As the following code is blocking, run it in a goroutine.
go func() { go func() {
server := &http2.Server{} server := &http2.Server{}

View File

@@ -10,6 +10,7 @@ import (
"os" "os"
"reflect" "reflect"
"testing" "testing"
"time"
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@@ -222,9 +223,6 @@ func TestApi_Provision(t *testing.T) {
mod.ValidateMock = func() error { mod.ValidateMock = func() error {
return errors.New("foo") return errors.New("foo")
} }
mod.ChecksMock = func() ([]health.CheckerOption, error) {
return nil, nil
}
return gotenberg.NewContext( return gotenberg.NewContext(
gotenberg.ParsedFlags{ gotenberg.ParsedFlags{
FlagSet: new(Api).Descriptor().FlagSet, FlagSet: new(Api).Descriptor().FlagSet,
@@ -347,6 +345,9 @@ func TestApi_Provision(t *testing.T) {
mod3.ChecksMock = func() ([]health.CheckerOption, error) { mod3.ChecksMock = func() ([]health.CheckerOption, error) {
return []health.CheckerOption{health.WithDisabledAutostart()}, nil return []health.CheckerOption{health.WithDisabledAutostart()}, nil
} }
mod3.ReadyMock = func() error {
return nil
}
mod4 := &struct { mod4 := &struct {
gotenberg.ModuleMock gotenberg.ModuleMock
@@ -643,141 +644,176 @@ func TestApi_Validate(t *testing.T) {
} }
func TestApi_Start(t *testing.T) { func TestApi_Start(t *testing.T) {
mod := new(Api) for _, tc := range []struct {
mod.port = 3000 scenario string
mod.rootPath = "/" readyFn []func() error
mod.disableHealthCheckLogging = true expectError bool
mod.routes = []Route{ }{
{ {
Method: http.MethodPost, scenario: "at least one module not ready",
Path: "/forms/foo", readyFn: []func() error{
IsMultipart: true, func() error { return nil },
DisableLogging: true, func() error { return errors.New("not ready") },
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*Context)
ctx.outputPaths = []string{
"/tests/test/testdata/api/sample1.txt",
}
return nil
}, },
expectError: true,
}, },
{ {
Method: http.MethodPost, scenario: "success",
Path: "/forms/bar", readyFn: []func() error{
IsMultipart: true, func() error { return nil },
Handler: func(_ echo.Context) error { return errors.New("foo") }, func() error { return nil },
},
expectError: false,
}, },
} } {
mod.externalMiddlewares = []Middleware{ t.Run(tc.scenario, func(t *testing.T) {
{ mod := new(Api)
Stack: PreRouterStack, mod.port = 3000
Handler: func() echo.MiddlewareFunc { mod.startTimeout = time.Duration(30) * time.Second
return func(next echo.HandlerFunc) echo.HandlerFunc { mod.rootPath = "/"
return func(c echo.Context) error { mod.disableHealthCheckLogging = true
return next(c) mod.routes = []Route{
} {
} Method: http.MethodPost,
}(), Path: "/forms/foo",
}, IsMultipart: true,
{ DisableLogging: true,
Stack: MultipartStack, Handler: func(c echo.Context) error {
Handler: func() echo.MiddlewareFunc { ctx := c.Get("context").(*Context)
return func(next echo.HandlerFunc) echo.HandlerFunc { ctx.outputPaths = []string{
return func(c echo.Context) error { "/tests/test/testdata/api/sample1.txt",
return next(c) }
}
}
}(),
},
{
Stack: DefaultStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
}
mod.fs = gotenberg.NewFileSystem()
mod.logger = zap.NewNop()
err := mod.Start() return nil
if err != nil { },
t.Fatalf("expected no error but got: %v", err) },
} {
Method: http.MethodPost,
Path: "/forms/bar",
IsMultipart: true,
Handler: func(_ echo.Context) error { return errors.New("foo") },
},
}
mod.externalMiddlewares = []Middleware{
{
Stack: PreRouterStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Stack: MultipartStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Stack: DefaultStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
}
mod.readyFn = tc.readyFn
mod.fs = gotenberg.NewFileSystem()
mod.logger = zap.NewNop()
// health request. err := mod.Start()
recorder := httptest.NewRecorder() if !tc.expectError && err != nil {
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
mod.srv.ServeHTTP(recorder, healthRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
// "multipart/form-data" request.
multipartRequest := func(url string) *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
}()
err := writer.WriteField("foo", "foo") if tc.expectError && err == nil {
if err != nil { t.Fatal("expected error but got none")
t.Fatalf("expected no error but got: %v", err) }
}
part, err := writer.CreateFormFile("foo.txt", "foo.txt") if tc.expectError {
if err != nil { return
t.Fatalf("expected no error but got: %v", err) }
}
_, err = part.Write([]byte("foo")) // health request.
if err != nil { recorder := httptest.NewRecorder()
t.Fatalf("expected no error but got: %v", err) healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
}
req := httptest.NewRequest(http.MethodPost, url, body) mod.srv.ServeHTTP(recorder, healthRequest)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
return req // "multipart/form-data" request.
} multipartRequest := func(url string) *http.Request {
body := &bytes.Buffer{}
recorder = httptest.NewRecorder() writer := multipart.NewWriter(body)
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/foo"))
if recorder.Code != http.StatusOK { defer func() {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) err := writer.Close()
} if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
recorder = httptest.NewRecorder() err := writer.WriteField("foo", "foo")
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/bar")) if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if recorder.Code != http.StatusInternalServerError { part, err := writer.CreateFormFile("foo.txt", "foo.txt")
t.Errorf("expected %d status code but got %d", http.StatusInternalServerError, recorder.Code) if err != nil {
} t.Fatalf("expected no error but got: %v", err)
}
err = mod.srv.Shutdown(context.TODO()) _, err = part.Write([]byte("foo"))
if err != nil { if err != nil {
t.Errorf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, url, body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}
recorder = httptest.NewRecorder()
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/foo"))
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
recorder = httptest.NewRecorder()
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/bar"))
if recorder.Code != http.StatusInternalServerError {
t.Errorf("expected %d status code but got %d", http.StatusInternalServerError, recorder.Code)
}
err = mod.srv.Shutdown(context.TODO())
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
})
} }
} }

View File

@@ -105,12 +105,17 @@ func (provider *MiddlewareProviderMock) Middlewares() ([]Middleware, error) {
// HealthCheckerMock is mock for the [HealthChecker] interface. // HealthCheckerMock is mock for the [HealthChecker] interface.
type HealthCheckerMock struct { type HealthCheckerMock struct {
ChecksMock func() ([]health.CheckerOption, error) ChecksMock func() ([]health.CheckerOption, error)
ReadyMock func() error
} }
func (mod *HealthCheckerMock) Checks() ([]health.CheckerOption, error) { func (mod *HealthCheckerMock) Checks() ([]health.CheckerOption, error) {
return mod.ChecksMock() return mod.ChecksMock()
} }
func (mod *HealthCheckerMock) Ready() error {
return mod.ReadyMock()
}
// Interface guards. // Interface guards.
var ( var (
_ Router = (*RouterMock)(nil) _ Router = (*RouterMock)(nil)

View File

@@ -148,10 +148,18 @@ func TestHealthCheckerMock(t *testing.T) {
ChecksMock: func() ([]health.CheckerOption, error) { ChecksMock: func() ([]health.CheckerOption, error) {
return nil, nil return nil, nil
}, },
ReadyMock: func() error {
return nil
},
} }
_, err := mock.Checks() _, err := mock.Checks()
if err != nil { if err != nil {
t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err) t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err)
} }
err = mock.Ready()
if err != nil {
t.Errorf("expected no error from HealthCheckerMock.Ready, but got: %v", err)
}
} }

View File

@@ -226,7 +226,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("chromium", flag.ExitOnError) fs := flag.NewFlagSet("chromium", flag.ExitOnError)
fs.Int64("chromium-restart-after", 0, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature") fs.Int64("chromium-restart-after", 0, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion") fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
fs.Duration("chromium-start-timeout", time.Duration(10)*time.Second, "Maximum duration to wait for Chromium to start or restart") fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode") fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost") fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost")
fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors") fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors")
@@ -387,6 +387,34 @@ func (mod *Chromium) Checks() ([]health.CheckerOption, error) {
}, nil }, nil
} }
// Ready returns no error if the module is ready.
func (mod *Chromium) Ready() error {
if !mod.autoStart {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), mod.args.wsUrlReadTimeout)
defer cancel()
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
for {
select {
case <-ctx.Done():
ticker.Stop()
return fmt.Errorf("context done while waiting for Chromium to be ready: %w", ctx.Err())
case <-ticker.C:
ok := mod.browser.Healthy(mod.logger)
if ok {
ticker.Stop()
return nil
}
continue
}
}
}
// Chromium returns an [Api] for interacting with Chromium for converting HTML // Chromium returns an [Api] for interacting with Chromium for converting HTML
// documents to PDF. // documents to PDF.
func (mod *Chromium) Chromium() (Api, error) { func (mod *Chromium) Chromium() (Api, error) {

View File

@@ -389,6 +389,61 @@ func TestChromium_Checks(t *testing.T) {
} }
} }
func TestChromium_Ready(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
startTimeout time.Duration
browser browser
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
startTimeout: time.Duration(30) * time.Second,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: false,
},
{
scenario: "auto-start: context done",
autoStart: true,
startTimeout: time.Duration(200) * time.Millisecond,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: true,
},
{
scenario: "auto-start success",
autoStart: true,
startTimeout: time.Duration(30) * time.Second,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return true
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.autoStart = tc.autoStart
mod.args = browserArguments{wsUrlReadTimeout: tc.startTimeout}
mod.browser = tc.browser
err := mod.Ready()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestChromium_Chromium(t *testing.T) { func TestChromium_Chromium(t *testing.T) {
mod := new(Chromium) mod := new(Chromium)

View File

@@ -179,7 +179,6 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
// completed or an error is encountered. // completed or an error is encountered.
func runBatch(ctx context.Context, fn ...func() error) error { func runBatch(ctx context.Context, fn ...func() error) error {
eg, _ := errgroup.WithContext(ctx) eg, _ := errgroup.WithContext(ctx)
for _, f := range fn { for _, f := range fn {
eg.Go(f) eg.Go(f)
} }

View File

@@ -304,12 +304,11 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
select { select {
case <-ctx.Done(): case <-ctx.Done():
ticker.Stop() ticker.Stop()
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err()) return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
case <-ticker.C: case <-ticker.C:
var ok bool var ok bool
evaluate := chromedp.Evaluate(expression, &ok) evaluate := chromedp.Evaluate(expression, &ok)
err := evaluate.Do(ctx) err := evaluate.Do(ctx)
if err != nil { if err != nil {
return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression) return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression)
@@ -317,7 +316,6 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
if ok { if ok {
ticker.Stop() ticker.Stop()
return nil return nil
} }

View File

@@ -81,7 +81,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("api", flag.ExitOnError) fs := flag.NewFlagSet("api", flag.ExitOnError)
fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature") fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature")
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion") fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
fs.Duration("libreoffice-start-timeout", time.Duration(10)*time.Second, "Maximum duration to wait for LibreOffice to start or restart") fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
return fs return fs
}(), }(),
@@ -221,6 +221,34 @@ func (a *Api) Checks() ([]health.CheckerOption, error) {
}, nil }, nil
} }
// Ready returns no error if the module is ready.
func (a *Api) Ready() error {
if !a.autoStart {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), a.args.startTimeout)
defer cancel()
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
for {
select {
case <-ctx.Done():
ticker.Stop()
return fmt.Errorf("context done while waiting for LibreOffice to be ready: %w", ctx.Err())
case <-ticker.C:
ok := a.libreOffice.Healthy(a.logger)
if ok {
ticker.Stop()
return nil
}
continue
}
}
}
// LibreOffice returns a [Uno] for interacting with LibreOffice. // LibreOffice returns a [Uno] for interacting with LibreOffice.
func (a *Api) LibreOffice() (Uno, error) { func (a *Api) LibreOffice() (Uno, error) {
return a, nil return a, nil

View File

@@ -339,6 +339,61 @@ func TestApi_Checks(t *testing.T) {
} }
} }
func TestChromium_Ready(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
startTimeout time.Duration
libreOffice libreOffice
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
startTimeout: time.Duration(30) * time.Second,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: false,
},
{
scenario: "auto-start: context done",
autoStart: true,
startTimeout: time.Duration(200) * time.Millisecond,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: true,
},
{
scenario: "auto-start success",
autoStart: true,
startTimeout: time.Duration(30) * time.Second,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return true
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.autoStart = tc.autoStart
a.args = libreOfficeArguments{startTimeout: tc.startTimeout}
a.libreOffice = tc.libreOffice
err := a.Ready()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestApi_LibreOffice(t *testing.T) { func TestApi_LibreOffice(t *testing.T) {
a := new(Api) a := new(Api)

View File

@@ -18,7 +18,7 @@ func init() {
// LibreOfficePdfEngine interacts with the LibreOffice (Universal Network Objects) API // LibreOfficePdfEngine interacts with the LibreOffice (Universal Network Objects) API
// and implements the [gotenberg.PdfEngine] interface. // and implements the [gotenberg.PdfEngine] interface.
type LibreOfficePdfEngine struct { type LibreOfficePdfEngine struct {
unoAPI api.Uno unoApi api.Uno
} }
// Descriptor returns a [LibreOfficePdfEngine]'s module descriptor. // Descriptor returns a [LibreOfficePdfEngine]'s module descriptor.
@@ -36,12 +36,12 @@ func (engine *LibreOfficePdfEngine) Provision(ctx *gotenberg.Context) error {
return fmt.Errorf("get LibreOffice Uno provider: %w", err) return fmt.Errorf("get LibreOffice Uno provider: %w", err)
} }
unoAPI, err := provider.(api.Provider).LibreOffice() unoApi, err := provider.(api.Provider).LibreOffice()
if err != nil { if err != nil {
return fmt.Errorf("get LibreOffice Uno: %w", err) return fmt.Errorf("get LibreOffice Uno: %w", err)
} }
engine.unoAPI = unoAPI engine.unoApi = unoApi
return nil return nil
} }
@@ -56,7 +56,7 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported] // PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
// error. // error.
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
err := engine.unoAPI.Pdf(ctx, logger, inputPath, outputPath, api.Options{ err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, api.Options{
PdfFormats: formats, PdfFormats: formats,
}) })

View File

@@ -153,7 +153,7 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
engine := &LibreOfficePdfEngine{unoAPI: tc.api} engine := &LibreOfficePdfEngine{unoApi: tc.api}
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
if !tc.expectError && err != nil { if !tc.expectError && err != nil {