diff --git a/Makefile b/Makefile index aa806f3f..21d0a253 100644 --- a/Makefile +++ b/Makefile @@ -29,13 +29,14 @@ build: ## Build the Gotenberg's Docker image GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s API_PORT=3000 API_PORT_FROM_ENV= +API_START_TIMEOUT=30s API_TIMEOUT=30s API_ROOT_PATH=/ API_TRACE_HEADER=Gotenberg-Trace API_DISABLE_HEALTH_CHECK_LOGGING=false CHROMIUM_RESTART_AFTER=0 CHROMIUM_AUTO_START=false -CHROMIUM_START_TIMEOUT=10s +CHROMIUM_START_TIMEOUT=20s CHROMIUM_INCOGNITO=false CHROMIUM_ALLOW_INSECURE_LOCALHOST=false CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false @@ -49,7 +50,7 @@ CHROMIUM_DISABLE_JAVASCRIPT=false CHROMIUM_DISABLE_ROUTES=false LIBREOFFICE_RESTART_AFTER=10 LIBREOFFICE_AUTO_START=false -LIBREOFFICE_START_TIMEOUT=10s +LIBREOFFICE_START_TIMEOUT=20s LIBREOFFICE_DISABLE_ROUTES=false LOG_LEVEL=info LOG_FORMAT=auto @@ -79,6 +80,7 @@ run: ## Start a Gotenberg container --gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \ --api-port=$(API_PORT) \ --api-port-from-env=$(API_PORT_FROM_ENV) \ + --api-start-timeout=$(API_START_TIMEOUT) \ --api-timeout=$(API_TIMEOUT) \ --api-root-path=$(API_ROOT_PATH) \ --api-trace-header=$(API_TRACE_HEADER) \ diff --git a/cmd/gotenberg.go b/cmd/gotenberg.go index 2b60df9a..f3d58a82 100644 --- a/cmd/gotenberg.go +++ b/cmd/gotenberg.go @@ -84,7 +84,6 @@ func Run() { startupMessage := app.StartupMessage() if startupMessage == "" { fmt.Printf("[SYSTEM] %s: application started\n", id) - return } @@ -144,7 +143,6 @@ func Run() { } fmt.Printf("[SYSTEM] %s: application stopped\n", id) - return nil } }(a.(gotenberg.App))) diff --git a/go.mod b/go.mod index 076e90cf..4aedbf31 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/chromedp/cdproto v0.0.0-20231205062650-00455a960d61 github.com/chromedp/chromedp v0.9.3 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-retryablehttp v0.7.5 github.com/klauspost/compress v1.17.4 // indirect diff --git a/go.sum b/go.sum index d4889a51..1a967cc5 100644 --- a/go.sum +++ b/go.sum @@ -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.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 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.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +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/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go index bb4b9d42..5d5e151a 100644 --- a/pkg/modules/api/api.go +++ b/pkg/modules/api/api.go @@ -17,6 +17,7 @@ import ( "go.uber.org/multierr" "go.uber.org/zap" "golang.org/x/net/http2" + "golang.org/x/sync/errgroup" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" ) @@ -31,6 +32,7 @@ type Api struct { port int readTimeout time.Duration writeTimeout time.Duration + startTimeout time.Duration timeout time.Duration rootPath string traceHeader string @@ -39,6 +41,7 @@ type Api struct { routes []Route externalMiddlewares []Middleware healthChecks []health.CheckerOption + readyFn []func() error fs *gotenberg.FileSystem logger *zap.Logger srv *echo.Echo @@ -147,6 +150,7 @@ type Middleware struct { // See https://github.com/alexliesenfeld/health for more details. type HealthChecker interface { Checks() ([]health.CheckerOption, error) + Ready() error } // Descriptor returns an [Api]'s module descriptor. @@ -157,6 +161,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { fs := flag.NewFlagSet("api", flag.ExitOnError) 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.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start") fs.Duration("api-read-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to read a complete request, including the body") fs.Duration("api-process-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to process a request") fs.Duration("api-write-timeout", time.Duration(30)*time.Second, "Set the maximum duration before timing out writes of the response") @@ -184,6 +189,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { func (a *Api) Provision(ctx *gotenberg.Context) error { flags := ctx.ParsedFlags() a.port = flags.MustInt("api-port") + a.startTimeout = flags.MustDuration("api-start-timeout") a.readTimeout = flags.MustDeprecatedDuration("api-read-timeout", "api-timeout") a.writeTimeout = flags.MustDeprecatedDuration("api-write-timeout", "api-timeout") a.timeout = flags.MustDeprecatedDuration("api-process-timeout", "api-timeout") @@ -275,6 +281,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { } a.healthChecks = append(a.healthChecks, checks...) + a.readyFn = append(a.readyFn, healthChecker.Ready) } // Logger. @@ -446,12 +453,25 @@ func (a *Api) Start() error { func() echo.HandlerFunc { checks := append(a.healthChecks, health.WithTimeout(a.timeout)) checker := health.NewChecker(checks...) - return echo.WrapHandler(health.NewHandler(checker)) }(), 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. go func() { server := &http2.Server{} diff --git a/pkg/modules/api/api_test.go b/pkg/modules/api/api_test.go index 3eea5616..2c23f860 100644 --- a/pkg/modules/api/api_test.go +++ b/pkg/modules/api/api_test.go @@ -10,6 +10,7 @@ import ( "os" "reflect" "testing" + "time" "github.com/alexliesenfeld/health" "github.com/labstack/echo/v4" @@ -222,9 +223,6 @@ func TestApi_Provision(t *testing.T) { mod.ValidateMock = func() error { return errors.New("foo") } - mod.ChecksMock = func() ([]health.CheckerOption, error) { - return nil, nil - } return gotenberg.NewContext( gotenberg.ParsedFlags{ FlagSet: new(Api).Descriptor().FlagSet, @@ -347,6 +345,9 @@ func TestApi_Provision(t *testing.T) { mod3.ChecksMock = func() ([]health.CheckerOption, error) { return []health.CheckerOption{health.WithDisabledAutostart()}, nil } + mod3.ReadyMock = func() error { + return nil + } mod4 := &struct { gotenberg.ModuleMock @@ -643,141 +644,176 @@ func TestApi_Validate(t *testing.T) { } func TestApi_Start(t *testing.T) { - mod := new(Api) - mod.port = 3000 - mod.rootPath = "/" - mod.disableHealthCheckLogging = true - mod.routes = []Route{ + for _, tc := range []struct { + scenario string + readyFn []func() error + expectError bool + }{ { - Method: http.MethodPost, - Path: "/forms/foo", - IsMultipart: true, - DisableLogging: true, - Handler: func(c echo.Context) error { - ctx := c.Get("context").(*Context) - ctx.outputPaths = []string{ - "/tests/test/testdata/api/sample1.txt", - } - - return nil + scenario: "at least one module not ready", + readyFn: []func() error{ + func() error { return nil }, + func() error { return errors.New("not ready") }, }, + expectError: true, }, { - Method: http.MethodPost, - Path: "/forms/bar", - IsMultipart: true, - Handler: func(_ echo.Context) error { return errors.New("foo") }, + scenario: "success", + readyFn: []func() error{ + func() error { return nil }, + func() error { return nil }, + }, + expectError: false, }, - } - 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.fs = gotenberg.NewFileSystem() - mod.logger = zap.NewNop() + } { + t.Run(tc.scenario, func(t *testing.T) { + mod := new(Api) + mod.port = 3000 + mod.startTimeout = time.Duration(30) * time.Second + mod.rootPath = "/" + mod.disableHealthCheckLogging = true + mod.routes = []Route{ + { + Method: http.MethodPost, + Path: "/forms/foo", + IsMultipart: true, + DisableLogging: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*Context) + ctx.outputPaths = []string{ + "/tests/test/testdata/api/sample1.txt", + } - err := mod.Start() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } + return nil + }, + }, + { + 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. - recorder := httptest.NewRecorder() - 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 { + err := mod.Start() + if !tc.expectError && err != nil { t.Fatalf("expected no error but got: %v", err) } - }() - err := writer.WriteField("foo", "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } - part, err := writer.CreateFormFile("foo.txt", "foo.txt") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } + if tc.expectError { + return + } - _, err = part.Write([]byte("foo")) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } + // health request. + recorder := httptest.NewRecorder() + healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil) - req := httptest.NewRequest(http.MethodPost, url, body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) + mod.srv.ServeHTTP(recorder, healthRequest) + 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() - mod.srv.ServeHTTP(recorder, multipartRequest("/forms/foo")) + writer := multipart.NewWriter(body) - if recorder.Code != http.StatusOK { - t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) - } + defer func() { + err := writer.Close() + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + }() - recorder = httptest.NewRecorder() - mod.srv.ServeHTTP(recorder, multipartRequest("/forms/bar")) + err := writer.WriteField("foo", "foo") + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } - if recorder.Code != http.StatusInternalServerError { - t.Errorf("expected %d status code but got %d", http.StatusInternalServerError, recorder.Code) - } + part, err := writer.CreateFormFile("foo.txt", "foo.txt") + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } - err = mod.srv.Shutdown(context.TODO()) - if err != nil { - t.Errorf("expected no error but got: %v", err) + _, err = part.Write([]byte("foo")) + if err != nil { + 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) + } + }) } } diff --git a/pkg/modules/api/mocks.go b/pkg/modules/api/mocks.go index 670ca20e..63a65d73 100644 --- a/pkg/modules/api/mocks.go +++ b/pkg/modules/api/mocks.go @@ -105,12 +105,17 @@ func (provider *MiddlewareProviderMock) Middlewares() ([]Middleware, error) { // HealthCheckerMock is mock for the [HealthChecker] interface. type HealthCheckerMock struct { ChecksMock func() ([]health.CheckerOption, error) + ReadyMock func() error } func (mod *HealthCheckerMock) Checks() ([]health.CheckerOption, error) { return mod.ChecksMock() } +func (mod *HealthCheckerMock) Ready() error { + return mod.ReadyMock() +} + // Interface guards. var ( _ Router = (*RouterMock)(nil) diff --git a/pkg/modules/api/mocks_test.go b/pkg/modules/api/mocks_test.go index 20e8bd67..b0e31771 100644 --- a/pkg/modules/api/mocks_test.go +++ b/pkg/modules/api/mocks_test.go @@ -148,10 +148,18 @@ func TestHealthCheckerMock(t *testing.T) { ChecksMock: func() ([]health.CheckerOption, error) { return nil, nil }, + ReadyMock: func() error { + return nil + }, } _, err := mock.Checks() if err != nil { 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) + } } diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 8fc8826b..be89902d 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -231,7 +231,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor { 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.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-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost") fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors") @@ -408,6 +408,34 @@ func (mod *Chromium) Checks() ([]health.CheckerOption, error) { }, 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 // documents to PDF. func (mod *Chromium) Chromium() (Api, error) { diff --git a/pkg/modules/chromium/chromium_test.go b/pkg/modules/chromium/chromium_test.go index f66156a0..b8eba265 100644 --- a/pkg/modules/chromium/chromium_test.go +++ b/pkg/modules/chromium/chromium_test.go @@ -399,6 +399,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) { mod := new(Chromium) diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go index 09fc2ee1..257821b2 100644 --- a/pkg/modules/chromium/events.go +++ b/pkg/modules/chromium/events.go @@ -179,7 +179,6 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func() // completed or an error is encountered. func runBatch(ctx context.Context, fn ...func() error) error { eg, _ := errgroup.WithContext(ctx) - for _, f := range fn { eg.Go(f) } diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go index bf740c43..f131f93c 100644 --- a/pkg/modules/chromium/tasks.go +++ b/pkg/modules/chromium/tasks.go @@ -295,12 +295,11 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip select { case <-ctx.Done(): ticker.Stop() - return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err()) case <-ticker.C: var ok bool - evaluate := chromedp.Evaluate(expression, &ok) + err := evaluate.Do(ctx) if err != nil { return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression) @@ -308,7 +307,6 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip if ok { ticker.Stop() - return nil } diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index 288ef10a..6840e0ef 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -97,7 +97,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { 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.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 }(), @@ -277,6 +277,34 @@ func (a *Api) Checks() ([]health.CheckerOption, error) { }, 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. func (a *Api) LibreOffice() (Uno, error) { return a, nil diff --git a/pkg/modules/libreoffice/api/api_test.go b/pkg/modules/libreoffice/api/api_test.go index aa220fb7..73737607 100644 --- a/pkg/modules/libreoffice/api/api_test.go +++ b/pkg/modules/libreoffice/api/api_test.go @@ -364,6 +364,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) { a := new(Api) diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index 9029e8f7..c33032ba 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -18,7 +18,7 @@ func init() { // LibreOfficePdfEngine interacts with the LibreOffice (Universal Network Objects) API // and implements the [gotenberg.PdfEngine] interface. type LibreOfficePdfEngine struct { - unoAPI api.Uno + unoApi api.Uno } // 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) } - unoAPI, err := provider.(api.Provider).LibreOffice() + unoApi, err := provider.(api.Provider).LibreOffice() if err != nil { return fmt.Errorf("get LibreOffice Uno: %w", err) } - engine.unoAPI = unoAPI + engine.unoApi = unoApi 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] // 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, }) diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go index dd8d5e90..97ef2fa7 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go @@ -153,7 +153,7 @@ func TestLibreOfficePdfEngine_Convert(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{}, "", "") if !tc.expectError && err != nil {