diff --git a/Makefile b/Makefile index f7f40116..72741812 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,7 @@ API-DOWNLOAD-FROM-DENY-LIST= API-DOWNLOAD-FROM-FROM-MAX-RETRY=4 API-DISABLE-DOWNLOAD-FROM=false API_DISABLE_HEALTH_CHECK_LOGGING=false +API_ENABLE_DEBUG_ROUTE=false CHROMIUM_RESTART_AFTER=10 CHROMIUM_MAX_QUEUE_SIZE=0 CHROMIUM_AUTO_START=false @@ -107,6 +108,7 @@ run: ## Start a Gotenberg container --api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \ --api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \ --api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \ + --api-enable-debug-route=$(API_ENABLE_DEBUG_ROUTE) \ --chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \ --chromium-auto-start=$(CHROMIUM_AUTO_START) \ --chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \ diff --git a/cmd/gotenberg.go b/cmd/gotenberg.go index 7c3d37da..69aa381b 100644 --- a/cmd/gotenberg.go +++ b/cmd/gotenberg.go @@ -108,6 +108,9 @@ func Run() { }(l.(gotenberg.SystemLogger)) } + // Build the debug data. + gotenberg.BuildDebug(ctx) + quit := make(chan os.Signal, 1) // We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) or SIGTERM (Kubernetes). diff --git a/pkg/gotenberg/debug.go b/pkg/gotenberg/debug.go index 36940b8e..a89a9b0a 100644 --- a/pkg/gotenberg/debug.go +++ b/pkg/gotenberg/debug.go @@ -1,4 +1,65 @@ package gotenberg -// Version is the... version of the Gotenberg application. -var Version = "snapshot" +import ( + "runtime" + "sync" + + flag "github.com/spf13/pflag" +) + +// DebugInfo gathers data for debugging. +type DebugInfo struct { + Version string `json:"version"` + Architecture string `json:"architecture"` + Modules []string `json:"modules"` + ModulesAdditionalData map[string]map[string]interface{} `json:"modules_additional_data"` + Flags map[string]interface{} `json:"flags"` +} + +// BuildDebug builds the debug data from modules. +func BuildDebug(ctx *Context) { + debugMu.Lock() + defer debugMu.Unlock() + + debug = &DebugInfo{ + Version: Version, + Architecture: runtime.GOARCH, + Modules: make([]string, len(ctx.moduleInstances)), + ModulesAdditionalData: make(map[string]map[string]interface{}), + Flags: make(map[string]interface{}), + } + + i := 0 + for ID, mod := range ctx.moduleInstances { + debug.Modules[i] = ID + i++ + + debuggable, ok := mod.(Debuggable) + if !ok { + continue + } + + debug.ModulesAdditionalData[ID] = debuggable.Debug() + } + + ctx.ParsedFlags().VisitAll(func(f *flag.Flag) { + debug.Flags[f.Name] = f.Value.String() + }) +} + +// Debug returns the debug data. +func Debug() DebugInfo { + debugMu.Lock() + defer debugMu.Unlock() + + if debug == nil { + return DebugInfo{} + } + + return *debug +} + +var ( + debug *DebugInfo + debugMu sync.Mutex +) diff --git a/pkg/gotenberg/debug_test.go b/pkg/gotenberg/debug_test.go new file mode 100644 index 00000000..60406084 --- /dev/null +++ b/pkg/gotenberg/debug_test.go @@ -0,0 +1,72 @@ +package gotenberg + +import ( + "reflect" + "runtime" + "testing" + + flag "github.com/spf13/pflag" +) + +func TestBuildDebug(t *testing.T) { + if !reflect.DeepEqual(Debug(), DebugInfo{}) { + t.Errorf("Debug() should return empty debug data") + } + + fs := flag.NewFlagSet("gotenberg", flag.ExitOnError) + fs.String("foo", "bar", "Set foo") + ctx := NewContext(ParsedFlags{ + FlagSet: fs, + }, func() []ModuleDescriptor { + mod1 := &struct { + ModuleMock + }{} + mod1.DescriptorMock = func() ModuleDescriptor { + return ModuleDescriptor{ID: "foo", New: func() Module { return mod1 }} + } + mod2 := &struct { + ModuleMock + DebuggableMock + }{} + mod2.DescriptorMock = func() ModuleDescriptor { + return ModuleDescriptor{ID: "bar", New: func() Module { return mod2 }} + } + mod2.DebugMock = func() map[string]interface{} { + return map[string]interface{}{ + "foo": "bar", + } + } + + return []ModuleDescriptor{mod1.Descriptor(), mod2.Descriptor()} + }()) + + // Load modules. + _, err := ctx.Modules(new(Module)) + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + + // Build debug data. + BuildDebug(ctx) + + expect := DebugInfo{ + Version: Version, + Architecture: runtime.GOARCH, + Modules: []string{ + "foo", + "bar", + }, + ModulesAdditionalData: map[string]map[string]interface{}{ + "bar": { + "foo": "bar", + }, + }, + Flags: map[string]interface{}{ + "foo": "bar", + }, + } + + if !reflect.DeepEqual(expect, Debug()) { + t.Errorf("expected '%+v', bug got '%+v'", expect, Debug()) + } +} diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index a771c8c3..458216f9 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -34,6 +34,14 @@ func (mod *ValidatorMock) Validate() error { return mod.ValidateMock() } +type DebuggableMock struct { + DebugMock func() map[string]interface{} +} + +func (mod *DebuggableMock) Debug() map[string]interface{} { + return mod.DebugMock() +} + // PdfEngineMock is a mock for the [PdfEngine] interface. // //nolint:dupl diff --git a/pkg/gotenberg/mocks_test.go b/pkg/gotenberg/mocks_test.go index 0608f2c2..d813499a 100644 --- a/pkg/gotenberg/mocks_test.go +++ b/pkg/gotenberg/mocks_test.go @@ -48,6 +48,21 @@ func TestValidatorMock(t *testing.T) { } } +func TestDebuggableMock(t *testing.T) { + mock := &DebuggableMock{ + DebugMock: func() map[string]interface{} { + return map[string]interface{}{ + "foo": "bar", + } + }, + } + + d := mock.Debug() + if d == nil { + t.Errorf("expected debug data, but got nil") + } +} + func TestPDFEngineMock(t *testing.T) { mock := &PdfEngineMock{ MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { diff --git a/pkg/gotenberg/modules.go b/pkg/gotenberg/modules.go index e6ee875c..3df31459 100644 --- a/pkg/gotenberg/modules.go +++ b/pkg/gotenberg/modules.go @@ -75,6 +75,12 @@ type SystemLogger interface { SystemMessages() []string } +// Debuggable is a module interface for modules which want to provide +// additional debug data. +type Debuggable interface { + Debug() map[string]interface{} +} + // MustRegisterModule registers a module. // // To register a module, create an init() method in the module main go file: diff --git a/pkg/gotenberg/version.go b/pkg/gotenberg/version.go new file mode 100644 index 00000000..36940b8e --- /dev/null +++ b/pkg/gotenberg/version.go @@ -0,0 +1,4 @@ +package gotenberg + +// Version is the... version of the Gotenberg application. +var Version = "snapshot" diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go index e7aaf492..b31d0ebd 100644 --- a/pkg/modules/api/api.go +++ b/pkg/modules/api/api.go @@ -42,6 +42,7 @@ type Api struct { basicAuthPassword string downloadFromCfg downloadFromConfig disableHealthCheckLogging bool + enableDebugRoute bool routes []Route externalMiddlewares []Middleware @@ -187,6 +188,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature") fs.Bool("api-disable-download-from", false, "Disable the download from feature") fs.Bool("api-disable-health-check-logging", false, "Disable health check logging") + fs.Bool("api-enable-debug-route", false, "Enable the debug route") return fs }(), New: func() gotenberg.Module { return new(Api) }, @@ -212,6 +214,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { disable: flags.MustBool("api-disable-download-from"), } a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging") + a.enableDebugRoute = flags.MustBool("api-enable-debug-route") // Port from env? portEnvVar := flags.MustString("api-port-from-env") @@ -365,9 +368,10 @@ func (a *Api) Validate() error { return err } - routesMap := make(map[string]string, len(a.routes)+2) + routesMap := make(map[string]string, len(a.routes)+3) routesMap["/health"] = "/health" routesMap["/version"] = "/version" + routesMap["/debug"] = "/debug" for _, route := range a.routes { if route.Path == "" { @@ -529,7 +533,7 @@ func (a *Api) Start() error { hardTimeoutMiddleware(hardTimeout), ) - // ...and the version route. + // ...the version route. a.srv.GET( fmt.Sprintf("%s%s", a.rootPath, "version"), func(c echo.Context) error { @@ -538,6 +542,17 @@ func (a *Api) Start() error { securityMiddleware, ) + // ...and the debug route. + if a.enableDebugRoute { + a.srv.GET( + fmt.Sprintf("%s%s", a.rootPath, "debug"), + func(c echo.Context) error { + return c.JSONPretty(http.StatusOK, gotenberg.Debug(), " ") + }, + securityMiddleware, + ) + } + // Wait for all modules to be ready. ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout) defer cancel() diff --git a/pkg/modules/api/api_test.go b/pkg/modules/api/api_test.go index cd5c3e3a..465adce3 100644 --- a/pkg/modules/api/api_test.go +++ b/pkg/modules/api/api_test.go @@ -786,6 +786,7 @@ func TestApi_Start(t *testing.T) { mod.basicAuthUsername = "foo" mod.basicAuthPassword = "bar" mod.disableHealthCheckLogging = true + mod.enableDebugRoute = true mod.routes = []Route{ { Method: http.MethodPost, @@ -908,6 +909,15 @@ func TestApi_Start(t *testing.T) { t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) } + // debug request. + recorder = httptest.NewRecorder() + debugRequest := httptest.NewRequest(http.MethodGet, "/debug", nil) + debugRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword) + mod.srv.ServeHTTP(recorder, debugRequest) + 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{} diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 7dfa8fa0..b356f7d9 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -5,6 +5,9 @@ import ( "errors" "fmt" "os" + "os/exec" + "strings" + "syscall" "time" "github.com/alexliesenfeld/health" @@ -484,6 +487,23 @@ func (mod *Chromium) Stop(ctx context.Context) error { return fmt.Errorf("stop Chromium: %w", err) } +// Debug returns additional debug data. +func (mod *Chromium) Debug() map[string]interface{} { + debug := make(map[string]interface{}) + + cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + debug["version"] = err.Error() + return debug + } + + debug["version"] = strings.TrimSpace(string(output)) + return debug +} + // Metrics returns the metrics. func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) { return []gotenberg.Metric{ @@ -593,6 +613,7 @@ var ( _ gotenberg.Provisioner = (*Chromium)(nil) _ gotenberg.Validator = (*Chromium)(nil) _ gotenberg.App = (*Chromium)(nil) + _ gotenberg.Debuggable = (*Chromium)(nil) _ gotenberg.MetricsProvider = (*Chromium)(nil) _ api.HealthChecker = (*Chromium)(nil) _ api.Router = (*Chromium)(nil) diff --git a/pkg/modules/chromium/chromium_test.go b/pkg/modules/chromium/chromium_test.go index 38f28c1e..720bc943 100644 --- a/pkg/modules/chromium/chromium_test.go +++ b/pkg/modules/chromium/chromium_test.go @@ -336,6 +336,54 @@ func TestChromium_Stop(t *testing.T) { } } +func TestChromium_Debug(t *testing.T) { + for _, tc := range []struct { + scenario string + mod *Chromium + expect map[string]interface{} + doNotExpect map[string]interface{} + }{ + { + scenario: "cannot determine version", + mod: &Chromium{ + args: browserArguments{ + binPath: "foo", + }, + }, + expect: map[string]interface{}{ + "version": `exec: "foo": executable file not found in $PATH`, + }, + }, + { + scenario: "success", + mod: &Chromium{ + args: browserArguments{ + binPath: "echo", + }, + }, + doNotExpect: map[string]interface{}{ + "version": `exec: "echo": executable file not found in $PATH`, + }, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + d := tc.mod.Debug() + + if tc.expect != nil { + if !reflect.DeepEqual(d, tc.expect) { + t.Errorf("expected '%v' but got '%v'", tc.expect, d) + } + } + + if tc.doNotExpect != nil { + if reflect.DeepEqual(d, tc.doNotExpect) { + t.Errorf("did not expect '%v'", d) + } + } + }) + } +} + func TestChromium_Metrics(t *testing.T) { mod := new(Chromium) mod.supervisor = &gotenberg.ProcessSupervisorMock{ diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index 8fafc27d..f1e07cdf 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -5,7 +5,10 @@ import ( "errors" "fmt" "os" + "os/exec" "reflect" + "strings" + "syscall" "github.com/barasher/go-exiftool" "go.uber.org/zap" @@ -53,6 +56,23 @@ func (engine *ExifTool) Validate() error { return nil } +// Debug returns additional debug data. +func (engine *ExifTool) Debug() map[string]interface{} { + debug := make(map[string]interface{}) + + cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + debug["version"] = err.Error() + return debug + } + + debug["version"] = strings.TrimSpace(string(output)) + return debug +} + // Merge is not available in this implementation. func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) @@ -161,5 +181,6 @@ var ( _ gotenberg.Module = (*ExifTool)(nil) _ gotenberg.Provisioner = (*ExifTool)(nil) _ gotenberg.Validator = (*ExifTool)(nil) + _ gotenberg.Debuggable = (*ExifTool)(nil) _ gotenberg.PdfEngine = (*ExifTool)(nil) ) diff --git a/pkg/modules/exiftool/exiftool_test.go b/pkg/modules/exiftool/exiftool_test.go index 00fca5a2..87576b69 100644 --- a/pkg/modules/exiftool/exiftool_test.go +++ b/pkg/modules/exiftool/exiftool_test.go @@ -73,6 +73,50 @@ func TestExifTool_Validate(t *testing.T) { } } +func TestExifTool_Debug(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *ExifTool + expect map[string]interface{} + doNotExpect map[string]interface{} + }{ + { + scenario: "cannot determine version", + engine: &ExifTool{ + binPath: "foo", + }, + expect: map[string]interface{}{ + "version": `exec: "foo": executable file not found in $PATH`, + }, + }, + { + scenario: "success", + engine: &ExifTool{ + binPath: "echo", + }, + doNotExpect: map[string]interface{}{ + "version": `exec: "echo": executable file not found in $PATH`, + }, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + d := tc.engine.Debug() + + if tc.expect != nil { + if !reflect.DeepEqual(d, tc.expect) { + t.Errorf("expected '%v' but got '%v'", tc.expect, d) + } + } + + if tc.doNotExpect != nil { + if reflect.DeepEqual(d, tc.doNotExpect) { + t.Errorf("did not expect '%v'", d) + } + } + }) + } +} + func TestExiftool_Merge(t *testing.T) { engine := new(ExifTool) err := engine.Merge(context.Background(), zap.NewNop(), nil, "") diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index e32712cf..ca8b482d 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -5,6 +5,9 @@ import ( "errors" "fmt" "os" + "os/exec" + "strings" + "syscall" "time" "github.com/alexliesenfeld/health" @@ -305,6 +308,23 @@ func (a *Api) Stop(ctx context.Context) error { return fmt.Errorf("stop LibreOffice: %w", err) } +// Debug returns additional debug data. +func (a *Api) Debug() map[string]interface{} { + debug := make(map[string]interface{}) + + cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + debug["version"] = err.Error() + return debug + } + + debug["version"] = strings.TrimSpace(string(output)) + return debug +} + // Metrics returns the metrics. func (a *Api) Metrics() ([]gotenberg.Metric, error) { return []gotenberg.Metric{ @@ -536,6 +556,7 @@ var ( _ gotenberg.Provisioner = (*Api)(nil) _ gotenberg.Validator = (*Api)(nil) _ gotenberg.App = (*Api)(nil) + _ gotenberg.Debuggable = (*Api)(nil) _ gotenberg.MetricsProvider = (*Api)(nil) _ api.HealthChecker = (*Api)(nil) _ Uno = (*Api)(nil) diff --git a/pkg/modules/libreoffice/api/api_test.go b/pkg/modules/libreoffice/api/api_test.go index b9480b85..7df16d9c 100644 --- a/pkg/modules/libreoffice/api/api_test.go +++ b/pkg/modules/libreoffice/api/api_test.go @@ -277,6 +277,54 @@ func TestApi_Stop(t *testing.T) { } } +func TestPdfTk_Debug(t *testing.T) { + for _, tc := range []struct { + scenario string + a *Api + expect map[string]interface{} + doNotExpect map[string]interface{} + }{ + { + scenario: "cannot determine version", + a: &Api{ + args: libreOfficeArguments{ + binPath: "foo", + }, + }, + expect: map[string]interface{}{ + "version": `exec: "foo": executable file not found in $PATH`, + }, + }, + { + scenario: "success", + a: &Api{ + args: libreOfficeArguments{ + binPath: "echo", + }, + }, + doNotExpect: map[string]interface{}{ + "version": `exec: "echo": executable file not found in $PATH`, + }, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + d := tc.a.Debug() + + if tc.expect != nil { + if !reflect.DeepEqual(d, tc.expect) { + t.Errorf("expected '%v' but got '%v'", tc.expect, d) + } + } + + if tc.doNotExpect != nil { + if reflect.DeepEqual(d, tc.doNotExpect) { + t.Errorf("did not expect '%v'", d) + } + } + }) + } +} + func TestApi_Metrics(t *testing.T) { a := new(Api) a.supervisor = &gotenberg.ProcessSupervisorMock{ diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index 2e82f879..4a468c1a 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -5,7 +5,10 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" + "strings" + "syscall" "go.uber.org/zap" @@ -52,6 +55,32 @@ func (engine *PdfCpu) Validate() error { return nil } +// Debug returns additional debug data. +func (engine *PdfCpu) Debug() map[string]interface{} { + debug := make(map[string]interface{}) + + cmd := exec.Command(engine.binPath, "version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + debug["version"] = err.Error() + return debug + } + + debug["version"] = "Unable to determine pdfcpu version" + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "pdfcpu:") { + debug["version"] = strings.TrimSpace(strings.TrimPrefix(line, "pdfcpu:")) + break + } + } + + return debug +} + // Merge combines multiple PDFs into a single PDF. func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { var args []string @@ -132,5 +161,6 @@ var ( _ gotenberg.Module = (*PdfCpu)(nil) _ gotenberg.Provisioner = (*PdfCpu)(nil) _ gotenberg.Validator = (*PdfCpu)(nil) + _ gotenberg.Debuggable = (*PdfCpu)(nil) _ gotenberg.PdfEngine = (*PdfCpu)(nil) ) diff --git a/pkg/modules/pdfcpu/pdfcpu_test.go b/pkg/modules/pdfcpu/pdfcpu_test.go index 063f42e6..b44003c3 100644 --- a/pkg/modules/pdfcpu/pdfcpu_test.go +++ b/pkg/modules/pdfcpu/pdfcpu_test.go @@ -71,6 +71,59 @@ func TestPdfCpu_Validate(t *testing.T) { } } +func TestPdfCpu_Debug(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *PdfCpu + expect map[string]interface{} + doNotExpect map[string]interface{} + }{ + { + scenario: "cannot determine version (command error)", + engine: &PdfCpu{ + binPath: "foo", + }, + expect: map[string]interface{}{ + "version": `exec: "foo": executable file not found in $PATH`, + }, + }, + { + scenario: "cannot determine version (no pdfcpu)", + engine: &PdfCpu{ + binPath: "echo", + }, + expect: map[string]interface{}{ + "version": "Unable to determine pdfcpu version", + }, + }, + { + scenario: "success", + engine: &PdfCpu{ + binPath: "pdfcpu", + }, + doNotExpect: map[string]interface{}{ + "version": "Unable to determine pdfcpu version", + }, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + d := tc.engine.Debug() + + if tc.expect != nil { + if !reflect.DeepEqual(d, tc.expect) { + t.Errorf("expected '%v' but got '%v'", tc.expect, d) + } + } + + if tc.doNotExpect != nil { + if reflect.DeepEqual(d, tc.doNotExpect) { + t.Errorf("did not expect '%v'", d) + } + } + }) + } +} + func TestPdfCpu_Merge(t *testing.T) { for _, tc := range []struct { scenario string diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index 39094fa8..d4395173 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -1,11 +1,14 @@ package pdftk import ( + "bytes" "context" "errors" "fmt" "os" + "os/exec" "path/filepath" + "syscall" "go.uber.org/zap" @@ -52,6 +55,29 @@ func (engine *PdfTk) Validate() error { return nil } +// Debug returns additional debug data. +func (engine *PdfTk) Debug() map[string]interface{} { + debug := make(map[string]interface{}) + + cmd := exec.Command(engine.binPath, "--version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + debug["version"] = err.Error() + return debug + } + + lines := bytes.SplitN(output, []byte("\n"), 2) + if len(lines) > 0 { + debug["version"] = string(lines[0]) + } else { + debug["version"] = "Unable to determine PDFtk version" + } + + return debug +} + // Split splits a given PDF file. func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { var args []string @@ -124,5 +150,6 @@ var ( _ gotenberg.Module = (*PdfTk)(nil) _ gotenberg.Provisioner = (*PdfTk)(nil) _ gotenberg.Validator = (*PdfTk)(nil) + _ gotenberg.Debuggable = (*PdfTk)(nil) _ gotenberg.PdfEngine = (*PdfTk)(nil) ) diff --git a/pkg/modules/pdftk/pdftk_test.go b/pkg/modules/pdftk/pdftk_test.go index 4ca0415e..0a49aeda 100644 --- a/pkg/modules/pdftk/pdftk_test.go +++ b/pkg/modules/pdftk/pdftk_test.go @@ -71,6 +71,50 @@ func TestPdfTk_Validate(t *testing.T) { } } +func TestPdfTk_Debug(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *PdfTk + expect map[string]interface{} + doNotExpect map[string]interface{} + }{ + { + scenario: "cannot determine version", + engine: &PdfTk{ + binPath: "foo", + }, + expect: map[string]interface{}{ + "version": `exec: "foo": executable file not found in $PATH`, + }, + }, + { + scenario: "success", + engine: &PdfTk{ + binPath: "echo", + }, + doNotExpect: map[string]interface{}{ + "version": `exec: "echo": executable file not found in $PATH`, + }, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + d := tc.engine.Debug() + + if tc.expect != nil { + if !reflect.DeepEqual(d, tc.expect) { + t.Errorf("expected '%v' but got '%v'", tc.expect, d) + } + } + + if tc.doNotExpect != nil { + if reflect.DeepEqual(d, tc.doNotExpect) { + t.Errorf("did not expect '%v'", d) + } + } + }) + } +} + func TestPdfTk_Merge(t *testing.T) { for _, tc := range []struct { scenario string diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 7271a2fd..95294002 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -1,11 +1,14 @@ package qpdf import ( + "bytes" "context" "errors" "fmt" "os" + "os/exec" "path/filepath" + "syscall" "go.uber.org/zap" @@ -52,6 +55,29 @@ func (engine *QPdf) Validate() error { return nil } +// Debug returns additional debug data. +func (engine *QPdf) Debug() map[string]interface{} { + debug := make(map[string]interface{}) + + cmd := exec.Command(engine.binPath, "--version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + debug["version"] = err.Error() + return debug + } + + lines := bytes.SplitN(output, []byte("\n"), 2) + if len(lines) > 0 { + debug["version"] = string(lines[0]) + } else { + debug["version"] = "Unable to determine QPDF version" + } + + return debug +} + // Split splits a given PDF file. func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { var args []string @@ -142,5 +168,6 @@ var ( _ gotenberg.Module = (*QPdf)(nil) _ gotenberg.Provisioner = (*QPdf)(nil) _ gotenberg.Validator = (*QPdf)(nil) + _ gotenberg.Debuggable = (*QPdf)(nil) _ gotenberg.PdfEngine = (*QPdf)(nil) ) diff --git a/pkg/modules/qpdf/qpdf_test.go b/pkg/modules/qpdf/qpdf_test.go index b7eed392..53972429 100644 --- a/pkg/modules/qpdf/qpdf_test.go +++ b/pkg/modules/qpdf/qpdf_test.go @@ -73,6 +73,50 @@ func TestQPdf_Validate(t *testing.T) { } } +func TestQPdf_Debug(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *QPdf + expect map[string]interface{} + doNotExpect map[string]interface{} + }{ + { + scenario: "cannot determine version", + engine: &QPdf{ + binPath: "foo", + }, + expect: map[string]interface{}{ + "version": `exec: "foo": executable file not found in $PATH`, + }, + }, + { + scenario: "success", + engine: &QPdf{ + binPath: "echo", + }, + doNotExpect: map[string]interface{}{ + "version": `exec: "echo": executable file not found in $PATH`, + }, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + d := tc.engine.Debug() + + if tc.expect != nil { + if !reflect.DeepEqual(d, tc.expect) { + t.Errorf("expected '%v' but got '%v'", tc.expect, d) + } + } + + if tc.doNotExpect != nil { + if reflect.DeepEqual(d, tc.doNotExpect) { + t.Errorf("did not expect '%v'", d) + } + } + }) + } +} + func TestQPdf_Merge(t *testing.T) { for _, tc := range []struct { scenario string