diff --git a/.gitignore b/.gitignore index 24fd0f00..1fb3e993 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /coverage.html -/coverage.txt \ No newline at end of file +/coverage.txt +/TODO.txt \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 7612ff17..694efe46 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,18 +1,11 @@ linters-settings: gci: sections: - - standard # Standard section: captures all standard packages. - - default # Default section: contains all imports that could not be matched to another section type. - - prefix(github.com/gotenberg/gotenberg/v7) # Ensure that this is always at the top and always has a line break. - # Skip generated files. - # Default: true + - standard + - default + - prefix(github.com/gotenberg/gotenberg/v7) skip-generated: true - # Skip vendor files. - # Default: true skip-vendor: true - # Enable custom order of sections. - # If `true`, make the section order the same as the order of `sections`. - # Default: false custom-order: true linters: diff --git a/Makefile b/Makefile index 73269960..984d584c 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,9 @@ API_TIMEOUT=30s API_ROOT_PATH=/ API_TRACE_HEADER=Gotenberg-Trace API_DISABLE_HEALTH_CHECK_LOGGING=false -CHROMIUM_FAILED_STARTS_THRESHOLD=5 +CHROMIUM_RESTART_AFTER=0 +CHROMIUM_AUTO_START=false +CHROMIUM_START_TIMEOUT=10s CHROMIUM_INCOGNITO=false CHROMIUM_ALLOW_INSECURE_LOCALHOST=false CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false @@ -80,7 +82,9 @@ run: ## Start a Gotenberg container --api-root-path=$(API_ROOT_PATH) \ --api-trace-header=$(API_TRACE_HEADER) \ --api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \ - --chromium-failed-starts-threshold=$(CHROMIUM_FAILED_STARTS_THRESHOLD) \ + --chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \ + --chromium-auto-start=$(CHROMIUM_AUTO_START) \ + --chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \ --chromium-incognito=$(CHROMIUM_INCOGNITO) \ --chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \ --chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \ @@ -146,8 +150,9 @@ fmt: ## Format the code and "optimize" the dependencies gci write -s standard -s default -s "prefix(github.com/gotenberg/gotenberg/v7)" --skip-generated --skip-vendor --custom-order . go mod tidy +# go install golang.org/x/tools/cmd/godoc@latest .PHONY: godoc -godoc: ## Run a webserver with Gotenberg godoc (go get golang.org/x/tools/cmd/godoc) +godoc: ## Run a webserver with Gotenberg godoc $(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v7) godoc -http=:6060 diff --git a/go.mod b/go.mod index 11875b2c..9d2d26ef 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,21 @@ module github.com/gotenberg/gotenberg/v7 go 1.21 require ( - github.com/alexliesenfeld/health v0.7.0 - github.com/andybalholm/brotli v1.0.5 // indirect - github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 - github.com/chromedp/chromedp v0.9.2 + github.com/alexliesenfeld/health v0.8.0 + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/chromedp/cdproto v0.0.0-20231019002500-864b42864d36 + github.com/chromedp/chromedp v0.9.3 github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.3.1 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.4 - github.com/klauspost/compress v1.17.0 // indirect + github.com/klauspost/compress v1.17.1 // indirect github.com/klauspost/pgzip v1.2.6 // indirect - github.com/labstack/echo/v4 v4.11.1 + github.com/labstack/echo/v4 v4.11.2 github.com/labstack/gommon v0.4.0 - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mholt/archiver/v3 v3.5.1 - github.com/microcosm-cc/bluemonday v1.0.25 + github.com/microcosm-cc/bluemonday v1.0.26 github.com/nwaples/rardecode v1.1.3 // indirect github.com/pdfcpu/pdfcpu v0.5.0 github.com/pierrec/lz4/v4 v4.1.18 // indirect @@ -29,7 +29,7 @@ require ( go.uber.org/zap v1.26.0 golang.org/x/crypto v0.14.0 // indirect golang.org/x/image v0.13.0 // indirect - golang.org/x/net v0.16.0 + golang.org/x/net v0.17.0 golang.org/x/sync v0.4.0 golang.org/x/sys v0.13.0 // indirect golang.org/x/term v0.13.0 @@ -45,7 +45,6 @@ require ( github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.3.0 // indirect - github.com/golang/protobuf v1.5.3 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/hhrutter/lzw v1.0.0 // indirect github.com/hhrutter/tiff v1.0.1 // indirect @@ -53,10 +52,10 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.5.0 // indirect - github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum index 67a4f492..60bda6ca 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,19 @@ -github.com/alexliesenfeld/health v0.7.0 h1:U3mSZ3ussRbGx+/rXBjNVxjLX5cKaNh8Ly9Hx3q+yfY= -github.com/alexliesenfeld/health v0.7.0/go.mod h1:6Nnjbu7vBYHoZqIuZeOnTpnW7OH14ulR+wIBE2QuJ8I= +github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk= +github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc= github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= -github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= -github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= -github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 h1:/5dwcyi5WOawM1Iz6MjrYqB90TRIdZv3O0fVHEJb86w= -github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= -github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw= -github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= +github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/cdproto v0.0.0-20231019002500-864b42864d36 h1:bZQXbfLJ/7qq7CKZ7F1wgrY91SeBbuTcQtv6xjeHpMQ= +github.com/chromedp/cdproto v0.0.0-20231019002500-864b42864d36/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/esg= +github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -26,13 +26,9 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0= github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= @@ -57,8 +53,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= -github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.1 h1:NE3C767s2ak2bweCZo3+rdP4U/HoyVXLv/X9f2gPS5g= +github.com/klauspost/compress v1.17.1/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= @@ -67,8 +63,8 @@ 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/labstack/echo/v4 v4.11.1 h1:dEpLU2FLg4UVmvCGPuk/APjlH6GDpbEPti61srUUUs4= -github.com/labstack/echo/v4 v4.11.1/go.mod h1:YuYRTSM3CHs2ybfrL8Px48bO6BAnYIN4l8wSTMP6BDQ= +github.com/labstack/echo/v4 v4.11.2 h1:T+cTLQxWCDfqDEoydYm5kCobjmHwOwcv4OJAPHilmdE= +github.com/labstack/echo/v4 v4.11.2/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= @@ -80,16 +76,16 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= -github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg= -github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE= +github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= +github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= @@ -108,8 +104,8 @@ github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1 github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= -github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= -github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -122,15 +118,12 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= @@ -152,9 +145,8 @@ golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg= golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk= -golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= -golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -170,7 +162,6 @@ golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/gotenberg/flags.go b/pkg/gotenberg/flags.go index 8b2b8d00..334e255a 100644 --- a/pkg/gotenberg/flags.go +++ b/pkg/gotenberg/flags.go @@ -80,6 +80,28 @@ func (f *ParsedFlags) MustDeprecatedBool(deprecated string, newName string) bool return f.MustBool(newName) } +// MustInt64 returns the int64 value of a flag given by name. +// It panics if an error occurs. +func (f *ParsedFlags) MustInt64(name string) int64 { + val, err := f.GetInt64(name) + if err != nil { + panic(err) + } + + return val +} + +// MustDeprecatedInt64 returns the int64 value of a deprecated flag if it was +// explicitly set or the int64 value of the new flag. +// It panics if an error occurs. +func (f *ParsedFlags) MustDeprecatedInt64(deprecated string, newName string) int64 { + if f.Changed(deprecated) { + return f.MustInt64(deprecated) + } + + return f.MustInt64(newName) +} + // MustInt returns the int value of a flag given by name. // It panics if an error occurs. func (f *ParsedFlags) MustInt(name string) int { diff --git a/pkg/gotenberg/flags_test.go b/pkg/gotenberg/flags_test.go index 23361637..a5ff9d94 100644 --- a/pkg/gotenberg/flags_test.go +++ b/pkg/gotenberg/flags_test.go @@ -252,6 +252,87 @@ func TestParsedFlags_MustDeprecatedBool(t *testing.T) { } } +func TestParsedFlags_MustInt64(t *testing.T) { + fs := flag.NewFlagSet("tests", flag.ContinueOnError) + fs.Int64("foo", 0, "") + + err := fs.Parse([]string{"--foo=1"}) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + parsedFlags := ParsedFlags{FlagSet: fs} + + for i, tc := range []struct { + name string + expectPanic bool + }{ + { + name: "foo", + }, + { + name: "bar", + expectPanic: true, + }, + } { + func() { + if tc.expectPanic { + defer func() { + if r := recover(); r == nil { + t.Errorf("test %d: expected panic but got none", i) + } + }() + } + + if !tc.expectPanic { + defer func() { + if r := recover(); r != nil { + t.Errorf("test %d: expected no panic but got: %v", i, r) + } + }() + } + + parsedFlags.MustInt64(tc.name) + }() + } +} + +func TestParsedFlags_MustDeprecatedInt64(t *testing.T) { + for i, tc := range []struct { + rawFlags []string + expectValue int64 + }{ + { + rawFlags: []string{"--foo=1"}, + expectValue: 1, + }, + { + rawFlags: []string{"--bar=2"}, + expectValue: 2, + }, + { + rawFlags: []string{"--foo=1", "--bar=2"}, + expectValue: 1, + }, + } { + fs := flag.NewFlagSet("tests", flag.ContinueOnError) + fs.Int64("foo", 0, "") + fs.Int64("bar", 0, "") + + parsedFlags := ParsedFlags{FlagSet: fs} + + err := parsedFlags.Parse(tc.rawFlags) + if err != nil { + t.Fatalf("test %d: expected no error but got: %v", i, err) + } + + actual := parsedFlags.MustDeprecatedInt64("foo", "bar") + if actual != tc.expectValue { + t.Errorf("test %d: expected %d but got %d", i, tc.expectValue, actual) + } + } +} + func TestParsedFlags_MustInt(t *testing.T) { fs := flag.NewFlagSet("tests", flag.ContinueOnError) fs.Int("foo", 0, "") diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index 134dbe1a..43502c6d 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -6,53 +6,106 @@ import ( "go.uber.org/zap" ) -// ModuleMock is a mock for the Module interface. +// ModuleMock is a mock for the [Module] interface. type ModuleMock struct { DescriptorMock func() ModuleDescriptor } -func (mod ModuleMock) Descriptor() ModuleDescriptor { +func (mod *ModuleMock) Descriptor() ModuleDescriptor { return mod.DescriptorMock() } -// ValidatorMock is a mock for the Validator interface. +// ValidatorMock is a mock for the [Validator] interface. type ValidatorMock struct { ValidateMock func() error } -func (mod ValidatorMock) Validate() error { +func (mod *ValidatorMock) Validate() error { return mod.ValidateMock() } -// PDFEngineMock is a mock for the PDFEngine interface. +// PDFEngineMock is a mock for the [PDFEngine] interface. type PDFEngineMock struct { MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error ConvertMock func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error } -func (engine PDFEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { +func (engine *PDFEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return engine.MergeMock(ctx, logger, inputPaths, outputPath) } -func (engine PDFEngineMock) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { +func (engine *PDFEngineMock) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { return engine.ConvertMock(ctx, logger, format, inputPath, outputPath) } -// PDFEngineProviderMock is a mock for the PDFEngineProvider interface. +// PDFEngineProviderMock is a mock for the [PDFEngineProvider] interface. type PDFEngineProviderMock struct { PDFEngineMock func() (PDFEngine, error) } -func (provider PDFEngineProviderMock) PDFEngine() (PDFEngine, error) { +func (provider *PDFEngineProviderMock) PDFEngine() (PDFEngine, error) { return provider.PDFEngineMock() } -// LoggerProviderMock is a mock for the LoggerProvider interface. +// ProcessMock is a mock for the [Process] interface. +type ProcessMock struct { + StartMock func(logger *zap.Logger) error + StopMock func(logger *zap.Logger) error + HealthyMock func(logger *zap.Logger) bool +} + +func (p *ProcessMock) Start(logger *zap.Logger) error { + return p.StartMock(logger) +} + +func (p *ProcessMock) Stop(logger *zap.Logger) error { + return p.StopMock(logger) +} + +func (p *ProcessMock) Healthy(logger *zap.Logger) bool { + return p.HealthyMock(logger) +} + +// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface. +type ProcessSupervisorMock struct { + LaunchMock func() error + ShutdownMock func() error + HealthyMock func() bool + RunMock func(ctx context.Context, logger *zap.Logger, task func() error) error + ReqQueueSizeMock func() int64 + RestartsCountMock func() int64 +} + +func (s *ProcessSupervisorMock) Launch() error { + return s.LaunchMock() +} + +func (s *ProcessSupervisorMock) Shutdown() error { + return s.ShutdownMock() +} + +func (s *ProcessSupervisorMock) Healthy() bool { + return s.HealthyMock() +} + +func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *zap.Logger, task func() error) error { + return s.RunMock(ctx, logger, task) +} + +func (s *ProcessSupervisorMock) ReqQueueSize() int64 { + return s.ReqQueueSizeMock() +} + +func (s *ProcessSupervisorMock) RestartsCount() int64 { + return s.RestartsCountMock() +} + +// LoggerProviderMock is a mock for the [LoggerProvider] interface. type LoggerProviderMock struct { LoggerMock func(mod Module) (*zap.Logger, error) } -func (provider LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) { +func (provider *LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) { return provider.LoggerMock(mod) } @@ -62,5 +115,7 @@ var ( _ Validator = (*ValidatorMock)(nil) _ PDFEngine = (*PDFEngineMock)(nil) _ PDFEngineProvider = (*PDFEngineProviderMock)(nil) + _ Process = (*ProcessMock)(nil) + _ ProcessSupervisor = (*ProcessSupervisorMock)(nil) _ LoggerProvider = (*LoggerProviderMock)(nil) ) diff --git a/pkg/gotenberg/mocks_test.go b/pkg/gotenberg/mocks_test.go index 4d19ff31..62301e2b 100644 --- a/pkg/gotenberg/mocks_test.go +++ b/pkg/gotenberg/mocks_test.go @@ -8,7 +8,7 @@ import ( ) func TestModuleMock(t *testing.T) { - mock := ModuleMock{ + mock := &ModuleMock{ DescriptorMock: func() ModuleDescriptor { return ModuleDescriptor{ID: "foo", New: func() Module { return nil @@ -17,12 +17,12 @@ func TestModuleMock(t *testing.T) { } if mock.Descriptor().ID != "foo" { - t.Errorf("expected ID '%s' from mock.Descriptor(), but got '%s'", "foo", mock.Descriptor().ID) + t.Errorf("expected ID '%s' from ModuleMock.Descriptor, but got '%s'", "foo", mock.Descriptor().ID) } } func TestValidatorMock(t *testing.T) { - mock := ValidatorMock{ + mock := &ValidatorMock{ ValidateMock: func() error { return nil }, @@ -30,12 +30,12 @@ func TestValidatorMock(t *testing.T) { err := mock.Validate() if err != nil { - t.Errorf("expected no error from mock.Validate(), but got: %v", err) + t.Errorf("expected no error from ValidatorMock.Validate, but got: %v", err) } } func TestPDFEngineMock(t *testing.T) { - mock := PDFEngineMock{ + mock := &PDFEngineMock{ MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return nil }, @@ -46,37 +46,119 @@ func TestPDFEngineMock(t *testing.T) { err := mock.Merge(context.Background(), zap.NewNop(), nil, "") if err != nil { - t.Errorf("expected no error from mock.Merge(), but got: %v", err) + t.Errorf("expected no error from PDFEngineMock.Merge, but got: %v", err) } err = mock.Convert(context.Background(), zap.NewNop(), "", "", "") if err != nil { - t.Errorf("expected no error from mock.Convert(), but got: %v", err) + t.Errorf("expected no error from PDFEngineMock.Convert, but got: %v", err) } } func TestPDFEngineProviderMock(t *testing.T) { - mock := PDFEngineProviderMock{ + mock := &PDFEngineProviderMock{ PDFEngineMock: func() (PDFEngine, error) { - return PDFEngineMock{}, nil + return new(PDFEngineMock), nil }, } _, err := mock.PDFEngine() if err != nil { - t.Errorf("expected no error from mock.PDFEngine(), but got: %v", err) + t.Errorf("expected no error from PDFEngineProviderMock.PDFEngine, but got: %v", err) + } +} + +func TestProcessMock(t *testing.T) { + mock := &ProcessMock{ + StartMock: func(logger *zap.Logger) error { + return nil + }, + StopMock: func(logger *zap.Logger) error { + return nil + }, + HealthyMock: func(logger *zap.Logger) bool { + return true + }, + } + + err := mock.Start(zap.NewNop()) + if err != nil { + t.Errorf("expected no error from ProcessMock.Start, but got: %v", err) + } + + err = mock.Stop(zap.NewNop()) + if err != nil { + t.Errorf("expected no error from ProcessMock.Stop, but got: %v", err) + } + + healthy := mock.Healthy(zap.NewNop()) + if !healthy { + t.Error("expected true from ProcessMock.Healthy, but got false") + } +} + +func TestProcessSupervisorMock(t *testing.T) { + mock := &ProcessSupervisorMock{ + LaunchMock: func() error { + return nil + }, + ShutdownMock: func() error { + return nil + }, + HealthyMock: func() bool { + return true + }, + RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error { + return nil + }, + ReqQueueSizeMock: func() int64 { + return 0 + }, + RestartsCountMock: func() int64 { + return 0 + }, + } + + err := mock.Launch() + if err != nil { + t.Errorf("expected no error from ProcessSupervisorMock.Launch, but got: %v", err) + } + + err = mock.Shutdown() + if err != nil { + t.Errorf("expected no error from ProcessSupervisorMock.Shutdown, but got: %v", err) + } + + healthy := mock.Healthy() + if !healthy { + t.Error("expected true from ProcessSupervisorMock.Healthy, but got false") + } + + err = mock.Run(context.TODO(), zap.NewNop(), nil) + if err != nil { + t.Errorf("expected no error from ProcessSupervisorMock.Run, but got: %v", err) + } + + size := mock.ReqQueueSize() + if size != 0 { + t.Errorf("expected 0 from ProcessSupervisorMock.ReqQueueSize, but got: %d", size) + } + + restarts := mock.RestartsCount() + if restarts != 0 { + t.Errorf("expected 0 from ProcessSupervisorMock.RestartsCount, but got: %d", restarts) } } func TestLoggerProviderMock(t *testing.T) { - mock := LoggerProviderMock{ + mock := &LoggerProviderMock{ LoggerMock: func(mod Module) (*zap.Logger, error) { return nil, nil }, } - _, err := mock.Logger(ModuleMock{}) + _, err := mock.Logger(new(ModuleMock)) if err != nil { - t.Errorf("expected no error from mock.Logger(), but got: %v", err) + t.Errorf("expected no error from LoggerProviderMock.Logger, but got: %v", err) } } diff --git a/pkg/gotenberg/supervisor.go b/pkg/gotenberg/supervisor.go new file mode 100644 index 00000000..809596ed --- /dev/null +++ b/pkg/gotenberg/supervisor.go @@ -0,0 +1,244 @@ +package gotenberg + +import ( + "context" + "fmt" + "sync/atomic" + + "go.uber.org/zap" +) + +// Process is an interface that represents an abstract process +// and provides methods for starting, stopping, and checking the health of the +// process. +// +// Implementations of this interface should handle the actual logic for +// starting, stopping, and ensuring the process's health. +type Process interface { + // Start initiates the process and returns an error if the process cannot + // be started. + Start(logger *zap.Logger) error + + // Stop terminates the process and returns an error if the process cannot + // be stopped. + Stop(logger *zap.Logger) error + + // Healthy checks the health of the process. It returns true if the process + // is healthy; otherwise, it returns false. + Healthy(logger *zap.Logger) bool +} + +// ProcessSupervisor provides methods to manage a [Process], including +// starting, stopping, and ensuring its health. +// +// Additionally, it allows for the execution of tasks while managing the +// process's state and provides functionality for limiting the number of +// requests that can be handled by the process, as well as managing a request +// queue. +type ProcessSupervisor interface { + // Launch starts the managed [Process]. + Launch() error + + // Shutdown stops the managed [Process]. + Shutdown() error + + // Healthy checks and returns the health status of the managed [Process]. + // + // If the process has not been started or is restarting, it is considered + // healthy and true is returned. Otherwise, it returns the health status of + // the actual process. + Healthy() bool + + // Run executes a provided task while managing the state of the [Process]. + // + // Run manages the request queue and may restart the process if it is not + // healthy or if the number of handled requests exceeds the maximum limit. + // + // It returns an error if the task cannot be run or if the process state + // cannot be managed properly. + Run(ctx context.Context, logger *zap.Logger, task func() error) error + + // ReqQueueSize returns the current size of the request queue. + ReqQueueSize() int64 + + // RestartsCount returns the current number of restart. + RestartsCount() int64 +} + +type processSupervisor struct { + logger *zap.Logger + process Process + maxReqLimit int64 + mutexChan chan struct{} + firstStart atomic.Bool + reqCounter atomic.Int64 + reqQueueSize atomic.Int64 + restartsCounter atomic.Int64 + isRestarting atomic.Bool +} + +// NewProcessSupervisor initializes a new [ProcessSupervisor]. +func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit int64) ProcessSupervisor { + b := &processSupervisor{ + logger: logger, + process: process, + mutexChan: make(chan struct{}, 1), + maxReqLimit: maxReqLimit, + } + b.reqCounter.Store(0) + b.reqQueueSize.Store(0) + b.restartsCounter.Store(0) + b.isRestarting.Store(false) + + return b +} + +func (s *processSupervisor) Launch() error { + s.logger.Debug("start process") + err := s.process.Start(s.logger) + if err != nil { + return fmt.Errorf("start process: %w", err) + } + + s.firstStart.Store(true) + s.logger.Debug("process successfully started") + + return nil +} + +func (s *processSupervisor) Shutdown() error { + s.logger.Debug("shutdown process") + err := s.process.Stop(s.logger) + if err != nil { + return fmt.Errorf("shutdown process: %w", err) + } + + s.logger.Debug("process successfully shutdown") + + return nil +} + +func (s *processSupervisor) restart() error { + if s.isRestarting.Load() { + s.logger.Debug("process already restarting, skip restart") + + return nil + } + + s.logger.Debug("restart process") + s.isRestarting.Store(true) + defer s.isRestarting.Store(false) + + err := s.Shutdown() + if err != nil { + // No big deal? Chances are it's already stopped. + s.logger.Debug(fmt.Sprintf("stop process before restart: %s", err)) + } + + err = s.Launch() + if err != nil { + return fmt.Errorf("restart process: %w", err) + } + + s.reqCounter.Store(0) + s.restartsCounter.Add(1) + s.logger.Debug("process successfully restarted") + + return nil +} + +func (s *processSupervisor) Healthy() bool { + if !s.firstStart.Load() { + // A non-started process is always healthy. + return true + } + + if s.isRestarting.Load() { + // A restarting process is always healthy. + return true + } + + return s.process.Healthy(s.logger) +} + +func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error { + s.reqQueueSize.Add(1) + + select { + case s.mutexChan <- struct{}{}: + logger.Debug("process lock acquired") + s.reqQueueSize.Add(-1) + s.reqCounter.Add(1) + + defer func() { + logger.Debug("process lock released") + <-s.mutexChan + }() + + if !s.firstStart.Load() { + err := s.runWithDeadline(ctx, func() error { + return s.Launch() + }) + if err != nil { + return fmt.Errorf("process first start: %w", err) + } + } + + if !s.Healthy() { + s.logger.Debug("process is unhealthy, cannot handle task, restarting...") + err := s.runWithDeadline(ctx, func() error { + return s.restart() + }) + if err != nil { + return fmt.Errorf("process restart before task: %w", err) + } + } + + if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit { + s.logger.Debug("max request limit reached, restarting...") + err := s.runWithDeadline(ctx, func() error { + return s.restart() + }) + if err != nil { + return fmt.Errorf("process restart before task: %w", err) + } + } + + // FIXME: no error wrapping because it leaks on Chromium console exceptions output. + return s.runWithDeadline(ctx, task) + case <-ctx.Done(): + logger.Debug("failed to acquire process lock before deadline") + s.reqQueueSize.Add(-1) + + return fmt.Errorf("acquire process lock: %w", ctx.Err()) + } +} + +func (s *processSupervisor) runWithDeadline(ctx context.Context, task func() error) error { + runChan := make(chan error, 1) + go func() { + runChan <- task() + }() + + for { + select { + case err := <-runChan: + return err + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (s *processSupervisor) ReqQueueSize() int64 { + return s.reqQueueSize.Load() +} + +func (s *processSupervisor) RestartsCount() int64 { + return s.restartsCounter.Load() +} + +// Interface guards. +var ( + _ ProcessSupervisor = (*processSupervisor)(nil) +) diff --git a/pkg/gotenberg/supervisor_test.go b/pkg/gotenberg/supervisor_test.go new file mode 100644 index 00000000..980db021 --- /dev/null +++ b/pkg/gotenberg/supervisor_test.go @@ -0,0 +1,570 @@ +package gotenberg + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "go.uber.org/zap" +) + +func TestProcessSupervisor_Launch(t *testing.T) { + for _, tc := range []struct { + scenario string + startError error + expectError bool + firstStartSet bool + }{ + { + scenario: "successful launch", + startError: nil, + expectError: false, + firstStartSet: true, + }, + { + scenario: "failed launch", + startError: errors.New("start error"), + expectError: true, + firstStartSet: false, + }, + { + scenario: "process already started", + startError: nil, + expectError: false, + firstStartSet: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + + process := &ProcessMock{ + StartMock: func(logger *zap.Logger) error { + return tc.startError + }, + } + + ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor) + if tc.firstStartSet { + ps.firstStart.Store(true) + } + + err := ps.Launch() + + 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") + } + + if tc.firstStartSet && !ps.firstStart.Load() { + t.Error("expected firstStart to be set but it was not") + } + }) + } +} + +func TestProcessSupervisor_Shutdown(t *testing.T) { + for _, tc := range []struct { + scenario string + stopError error + expectError bool + }{ + { + scenario: "successful shutdown", + stopError: nil, + expectError: false, + }, + { + scenario: "failed shutdown", + stopError: errors.New("stop error"), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + + process := &ProcessMock{ + StopMock: func(logger *zap.Logger) error { + return tc.stopError + }, + } + + ps := NewProcessSupervisor(logger, process, 5) + err := ps.Shutdown() + + 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 TestProcessSupervisor_restart(t *testing.T) { + for _, tc := range []struct { + scenario string + initiallyRestarting bool + startError error + stopError error + expectError bool + }{ + { + scenario: "already restarting", + initiallyRestarting: true, + expectError: false, + }, + { + scenario: "successful restart", + startError: nil, + stopError: nil, + expectError: false, + }, + { + scenario: "failed to stop during restart", + startError: nil, + stopError: errors.New("stop error"), + expectError: false, + }, + { + scenario: "failed to start during restart", + startError: errors.New("start error"), + stopError: nil, + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + + process := &ProcessMock{ + StartMock: func(logger *zap.Logger) error { + return tc.startError + }, + StopMock: func(logger *zap.Logger) error { + return tc.stopError + }, + } + + ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor) + if tc.initiallyRestarting { + ps.isRestarting.Store(true) + } + + err := ps.restart() + + 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 TestProcessSupervisor_Healthy(t *testing.T) { + for _, tc := range []struct { + scenario string + initiallyStarted bool + initiallyRestarting bool + processHealthy bool + expectHealthy bool + }{ + { + scenario: "non-started process is always healthy", + initiallyStarted: false, + expectHealthy: true, + }, + { + scenario: "restarting process is always healthy", + initiallyStarted: true, + initiallyRestarting: true, + expectHealthy: true, + }, + { + scenario: "process reports as healthy", + initiallyStarted: true, + processHealthy: true, + expectHealthy: true, + }, + { + scenario: "process reports as unhealthy", + initiallyStarted: true, + processHealthy: false, + expectHealthy: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + + process := &ProcessMock{ + HealthyMock: func(logger *zap.Logger) bool { + return tc.processHealthy + }, + } + + ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor) + if tc.initiallyStarted { + ps.firstStart.Store(true) + } + if tc.initiallyRestarting { + ps.isRestarting.Store(true) + } + + healthy := ps.Healthy() + + if healthy != tc.expectHealthy { + t.Fatalf("expected healthy to be %v but got %v", tc.expectHealthy, healthy) + } + }) + } +} + +func TestProcessSupervisor_Run(t *testing.T) { + for _, tc := range []struct { + scenario string + initiallyStarted bool + startError error + processHealthy bool + maxReqLimit int64 + tasksToRun int + taskError error + expectError bool + expectedStartCalls int64 + expectedHealthyCalls int64 + expectedStopCalls int64 + }{ + { + scenario: "successfully run task on non-started process", + initiallyStarted: false, + processHealthy: true, + maxReqLimit: 2, + tasksToRun: 1, + expectError: false, + expectedStartCalls: 1, + expectedHealthyCalls: 1, + expectedStopCalls: 0, + }, + { + scenario: "cannot launch non-started process", + initiallyStarted: false, + startError: errors.New("launch error"), + processHealthy: true, + maxReqLimit: 2, + tasksToRun: 1, + expectError: true, + expectedStartCalls: 1, + expectedHealthyCalls: 0, + expectedStopCalls: 0, + }, + { + scenario: "run task with unhealthy process causing restart", + initiallyStarted: true, + processHealthy: false, + maxReqLimit: 2, + tasksToRun: 1, + expectError: false, + expectedStartCalls: 1, + expectedHealthyCalls: 1, + expectedStopCalls: 1, + }, + { + scenario: "cannot restart unhealthy process", + startError: errors.New("start error"), + initiallyStarted: true, + processHealthy: false, + maxReqLimit: 2, + tasksToRun: 1, + expectError: true, + expectedStartCalls: 1, + expectedHealthyCalls: 1, + expectedStopCalls: 1, + }, + { + scenario: "run tasks reaching max request limit causing restart", + initiallyStarted: true, + processHealthy: true, + maxReqLimit: 2, + tasksToRun: 3, + expectError: false, + expectedStartCalls: 1, + expectedHealthyCalls: 3, + expectedStopCalls: 1, + }, + { + scenario: "cannot restart after reaching max request limit", + startError: errors.New("start error"), + initiallyStarted: true, + processHealthy: true, + maxReqLimit: 2, + tasksToRun: 2, + expectError: true, + expectedStartCalls: 1, + expectedHealthyCalls: 2, + expectedStopCalls: 1, + }, + { + scenario: "task error", + initiallyStarted: true, + processHealthy: true, + maxReqLimit: 0, + tasksToRun: 1, + taskError: errors.New("task error"), + expectError: true, + expectedStartCalls: 0, + expectedHealthyCalls: 1, + expectedStopCalls: 0, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + + var startCalls, healthyCalls, stopCalls atomic.Int64 + startCalls.Store(0) + healthyCalls.Store(0) + stopCalls.Store(0) + + process := &ProcessMock{ + StartMock: func(logger *zap.Logger) error { + startCalls.Add(1) + return tc.startError + }, + StopMock: func(logger *zap.Logger) error { + stopCalls.Add(1) + return nil + }, + HealthyMock: func(logger *zap.Logger) bool { + healthyCalls.Add(1) + return tc.processHealthy + }, + } + + ps := NewProcessSupervisor(logger, process, tc.maxReqLimit).(*processSupervisor) + if tc.initiallyStarted { + ps.firstStart.Store(true) + } + + task := func() error { + return tc.taskError + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + var wg sync.WaitGroup + errorChan := make(chan error, tc.tasksToRun) + + for i := 0; i < tc.tasksToRun; i++ { + wg.Add(1) + go func() { + defer wg.Done() + err := ps.Run(ctx, logger, task) + if err != nil { + errorChan <- err + } + }() + } + + wg.Wait() + close(errorChan) + + for err := range errorChan { + if tc.expectError && err == nil { + t.Fatal("expected an error but got none") + } + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + } + + if startCalls.Load() != tc.expectedStartCalls { + t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load()) + } + + if healthyCalls.Load() != tc.expectedHealthyCalls { + t.Errorf("expected %d process.Healthy calls, got %d", tc.expectedHealthyCalls, healthyCalls.Load()) + } + + if stopCalls.Load() != tc.expectedStopCalls { + t.Errorf("expected %d process.Stop calls, got %d", tc.expectedStopCalls, stopCalls.Load()) + } + }) + } +} + +func TestProcessSupervisor_runWithDeadline(t *testing.T) { + for _, tc := range []struct { + scenario string + ctxDone bool + expectError bool + }{ + { + scenario: "task finished", + ctxDone: false, + expectError: false, + }, + { + scenario: "context expired", + ctxDone: true, + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0).(*processSupervisor) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + if tc.ctxDone { + cancel() + } + + err := ps.runWithDeadline(ctx, func() error { + return nil + }) + + if tc.expectError && err == nil { + t.Fatal("expected an error but got none") + } + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + }) + } +} + +func TestProcessSupervisor_ReqQueueSize(t *testing.T) { + logger := zap.NewNop() + process := &ProcessMock{ + StartMock: func(logger *zap.Logger) error { + return nil + }, + HealthyMock: func(logger *zap.Logger) bool { + return true + }, + } + ps := NewProcessSupervisor(logger, process, 0).(*processSupervisor) + + // Simulating a lock. + ps.mutexChan <- struct{}{} + + if ps.ReqQueueSize() != 0 { + t.Fatalf("expected queue size to be 0 but got %d", ps.ReqQueueSize()) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var wg sync.WaitGroup + errorChan := make(chan error, 10) + + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + err := ps.Run(ctx, logger, func() error { + return nil + }) + if err != nil { + errorChan <- err + } + }() + } + + // We have to wait a little bit so that the request queue size may change. + time.Sleep(10 * time.Millisecond) + + if ps.ReqQueueSize() != 10 { + t.Fatalf("expected queue size to be 10 but got %d", ps.ReqQueueSize()) + } + + wg.Wait() + close(errorChan) + + for err := range errorChan { + if err == nil { + t.Error("expected a lock error but got none") + } + } + + if ps.ReqQueueSize() != 0 { + t.Errorf("expected queue size to be 0 but got %d", ps.ReqQueueSize()) + } +} + +func TestProcessSupervisor_RestartsCount(t *testing.T) { + for _, tc := range []struct { + scenario string + initialRestartsCount int64 + restartAttempts int + startError error + stopError error + expectedRestartsCount int64 + }{ + { + scenario: "no restarts, counter remains 0", + initialRestartsCount: 0, + restartAttempts: 0, + expectedRestartsCount: 0, + }, + { + scenario: "successful restart increases counter", + initialRestartsCount: 0, + restartAttempts: 1, + startError: nil, + stopError: nil, + expectedRestartsCount: 1, + }, + { + scenario: "failed to stop during restart, no impact", + initialRestartsCount: 0, + restartAttempts: 1, + startError: nil, + stopError: errors.New("stop error"), + expectedRestartsCount: 1, + }, + { + scenario: "multiple successful restarts", + initialRestartsCount: 0, + restartAttempts: 3, + startError: nil, + stopError: nil, + expectedRestartsCount: 3, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + + process := &ProcessMock{ + StartMock: func(logger *zap.Logger) error { + return tc.startError + }, + StopMock: func(logger *zap.Logger) error { + return tc.stopError + }, + } + + ps := NewProcessSupervisor(logger, process, 0).(*processSupervisor) + ps.restartsCounter.Store(tc.initialRestartsCount) + + for i := 0; i < tc.restartAttempts; i++ { + _ = ps.restart() + } + + actualRestartsCount := ps.RestartsCount() + if actualRestartsCount != tc.expectedRestartsCount { + t.Fatalf("expected restarts count to be %d, but got %d", tc.expectedRestartsCount, actualRestartsCount) + } + }) + } +} diff --git a/pkg/modules/api/mocks.go b/pkg/modules/api/mocks.go index be6360d8..b0949e4f 100644 --- a/pkg/modules/api/mocks.go +++ b/pkg/modules/api/mocks.go @@ -20,6 +20,15 @@ func (ctx *ContextMock) SetDirPath(path string) { ctx.dirPath = path } +// DirPath returns the context's working directory path. +// +// ctx := &api.ContextMock{Context: &api.Context{}} +// ctx.SetDirPath("/foo") +// dirPath := ctx.DirPath() +func (ctx *ContextMock) DirPath() string { + return ctx.dirPath +} + // SetValues sets the values. // // ctx := &api.ContextMock{Context: &api.Context{}} diff --git a/pkg/modules/api/mocks_test.go b/pkg/modules/api/mocks_test.go index dc5b4722..585f45fc 100644 --- a/pkg/modules/api/mocks_test.go +++ b/pkg/modules/api/mocks_test.go @@ -20,6 +20,18 @@ func TestContextMock_SetDirPath(t *testing.T) { } } +func TestContextMock_DirPath(t *testing.T) { + mock := &ContextMock{&Context{}} + mock.SetDirPath("/foo") + + actual := mock.DirPath() + expect := "/foo" + + if actual != expect { + t.Errorf("expected '%s' but got '%s'", expect, actual) + } +} + func TestContextMock_SetValues(t *testing.T) { mock := &ContextMock{&Context{}} mock.SetValues(map[string][]string{ diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go new file mode 100644 index 00000000..31926929 --- /dev/null +++ b/pkg/modules/chromium/browser.go @@ -0,0 +1,301 @@ +package chromium + +import ( + "context" + "errors" + "fmt" + "os" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/chromedp/cdproto/fetch" + "github.com/chromedp/cdproto/network" + "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/chromedp" + "go.uber.org/zap" + + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" +) + +type browser interface { + gotenberg.Process + pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error +} + +type browserArguments struct { + // Executor args. + binPath string + incognito bool + allowInsecureLocalhost bool + ignoreCertificateErrors bool + disableWebSecurity bool + allowFileAccessFromFiles bool + hostResolverRules string + proxyServer string + wsUrlReadTimeout time.Duration + + // Tasks specific. + allowList *regexp.Regexp + denyList *regexp.Regexp + disableJavaScript bool +} + +type chromiumBrowser struct { + initialCtx context.Context + ctx context.Context + cancelFunc context.CancelFunc + userProfileDirPath string + ctxMu sync.RWMutex + isStarted atomic.Bool + + arguments browserArguments + fs *gotenberg.FileSystem +} + +func newChromiumBrowser(arguments browserArguments) browser { + b := &chromiumBrowser{ + initialCtx: context.Background(), + arguments: arguments, + fs: gotenberg.NewFileSystem(), + } + b.isStarted.Store(false) + + return b +} + +func (b *chromiumBrowser) Start(logger *zap.Logger) error { + if b.isStarted.Load() { + return errors.New("browser is already started") + } + + debug := &debugLogger{logger: logger} + b.userProfileDirPath = b.fs.NewDirPath() + + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.CombinedOutput(debug), + chromedp.ExecPath(b.arguments.binPath), + chromedp.NoSandbox, + // See: + // https://github.com/gotenberg/gotenberg/issues/327 + // https://github.com/chromedp/chromedp/issues/904 + chromedp.DisableGPU, + // See: + // https://github.com/puppeteer/puppeteer/issues/661 + // https://github.com/puppeteer/puppeteer/issues/2410 + chromedp.Flag("font-render-hinting", "none"), + chromedp.UserDataDir(b.userProfileDirPath), + ) + + if b.arguments.incognito { + opts = append(opts, chromedp.Flag("incognito", b.arguments.incognito)) + } + + if b.arguments.allowInsecureLocalhost { + // See https://github.com/gotenberg/gotenberg/issues/488. + opts = append(opts, chromedp.Flag("allow-insecure-localhost", true)) + } + + if b.arguments.ignoreCertificateErrors { + opts = append(opts, chromedp.IgnoreCertErrors) + } + + if b.arguments.disableWebSecurity { + opts = append(opts, chromedp.Flag("disable-web-security", true)) + } + + if b.arguments.allowFileAccessFromFiles { + // See https://github.com/gotenberg/gotenberg/issues/356. + opts = append(opts, chromedp.Flag("allow-file-access-from-files", true)) + } + + if b.arguments.hostResolverRules != "" { + // See https://github.com/gotenberg/gotenberg/issues/488. + opts = append(opts, chromedp.Flag("host-resolver-rules", b.arguments.hostResolverRules)) + } + + if b.arguments.proxyServer != "" { + // See https://github.com/gotenberg/gotenberg/issues/376. + opts = append(opts, chromedp.ProxyServer(b.arguments.proxyServer)) + } + + // See https://github.com/gotenberg/gotenberg/issues/524. + opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout)) + + allocatorCtx, allocatorCancel := chromedp.NewExecAllocator(b.initialCtx, opts...) + ctx, cancel := chromedp.NewContext(allocatorCtx, chromedp.WithDebugf(debug.Printf)) + + err := chromedp.Run(ctx) + if err != nil { + cancel() + allocatorCancel() + return fmt.Errorf("run exec allocator: %w", err) + } + + b.ctxMu.Lock() + defer b.ctxMu.Unlock() + + // We have to keep the context around, as we need it to create a new tabs + // later. + b.ctx = ctx + b.cancelFunc = func() { + cancel() + allocatorCancel() + } + b.isStarted.Store(true) + + return nil +} + +func (b *chromiumBrowser) Stop(logger *zap.Logger) error { + if !b.isStarted.Load() { + return errors.New("browser is already stopped") + } + + // Always remove the user profile directory created by Chromium. + copyUserProfileDirPath := b.userProfileDirPath + defer func(userProfileDirPath string) { + go func() { + // FIXME: Chromium seems to recreate the user profile directory + // right after its deletion if we do not wait a certain amount + // of time before re-deleting it. + <-time.After(10 * time.Second) + + err := os.RemoveAll(userProfileDirPath) + if err != nil { + logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err)) + } + + logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath)) + }() + }(copyUserProfileDirPath) + + b.ctxMu.Lock() + defer b.ctxMu.Unlock() + + b.cancelFunc() + b.ctx = nil + b.userProfileDirPath = "" + b.isStarted.Store(false) + + return nil +} + +func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool { + // Good to know: the supervisor does not call this method if no first start + // or if the process is restarting. + + if !b.isStarted.Load() { + // Non-started browser but not restarting? + return false + } + + b.ctxMu.RLock() + defer b.ctxMu.RUnlock() + + taskCtx, cancel := chromedp.NewContext(b.ctx) + defer cancel() + + err := chromedp.Run(taskCtx, chromedp.Navigate("about:blank")) + if err != nil { + logger.Error(fmt.Sprintf("browser health check failed: %s", err)) + return false + } + + return true +} + +func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + if !b.isStarted.Load() { + return errors.New("browser not started, cannot handle PDF conversion") + } + + // We validate the "main" URL against our allow / deny lists. + if !b.arguments.allowList.MatchString(url) { + return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", url, ErrUrlNotAuthorized) + } + + if b.arguments.denyList.String() != "" && b.arguments.denyList.MatchString(url) { + return fmt.Errorf("'%s' matches the expression from the denied list: %w", url, ErrUrlNotAuthorized) + } + + deadline, ok := ctx.Deadline() + if !ok { + return errors.New("context has no deadline") + } + + b.ctxMu.RLock() + defer b.ctxMu.RUnlock() + + timeoutCtx, timeoutCancel := context.WithTimeout(b.ctx, time.Until(deadline)) + defer timeoutCancel() + + taskCtx, taskCancel := chromedp.NewContext(timeoutCtx) + defer taskCancel() + + // We validate all others requests against our allow / deny lists. + // If a request does not pass the validation, we make it fail. + listenForEventRequestPaused(taskCtx, logger, b.arguments.allowList, b.arguments.denyList) + + var ( + consoleExceptions error + consoleExceptionsMu sync.RWMutex + ) + + // See https://github.com/gotenberg/gotenberg/issues/262. + if options.FailOnConsoleExceptions && !b.arguments.disableJavaScript { + listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu) + } + + tasks := chromedp.Tasks{ + network.Enable(), + fetch.Enable(), + runtime.Enable(), + disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), + extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders), + navigateActionFunc(logger, url), + hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), + forceExactColorsActionFunc(), + emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), + waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), + waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), + printToPdfActionFunc(logger, outputPath, options), + } + + err := chromedp.Run(taskCtx, tasks...) + if err != nil { + errMessage := err.Error() + + if strings.Contains(errMessage, "Show invalid printer settings error (-32000)") || strings.Contains(errMessage, "content area is empty (-32602)") { + return ErrInvalidPrinterSettings + } + + if strings.Contains(errMessage, "Page range syntax error") { + return ErrPageRangesSyntaxError + } + + if strings.Contains(errMessage, "rpcc: message too large") { + return ErrRpccMessageTooLarge + } + + return fmt.Errorf("print to PDF: %w", err) + } + + // See https://github.com/gotenberg/gotenberg/issues/262. + consoleExceptionsMu.RLock() + defer consoleExceptionsMu.RUnlock() + + if consoleExceptions != nil { + return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions) + } + + return nil +} + +// Interface guards. +var ( + _ gotenberg.Process = (*chromiumBrowser)(nil) + _ browser = (*chromiumBrowser)(nil) +) diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go new file mode 100644 index 00000000..835d0921 --- /dev/null +++ b/pkg/modules/chromium/browser_test.go @@ -0,0 +1,1042 @@ +package chromium + +import ( + "context" + "errors" + "fmt" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" +) + +func TestChromiumBrowser_Start(t *testing.T) { + for _, tc := range []struct { + scenario string + browser browser + expectError bool + cleanup bool + }{ + { + scenario: "successful start", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + }, + ), + expectError: false, + cleanup: true, + }, + { + scenario: "all browser arguments", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + incognito: true, + allowInsecureLocalhost: true, + ignoreCertificateErrors: true, + disableWebSecurity: true, + allowFileAccessFromFiles: true, + hostResolverRules: "MAP forgery.docker.localhost traefik", + proxyServer: "1.2.3.4", + }, + ), + expectError: false, + cleanup: true, + }, + { + scenario: "browser already started", + browser: func() browser { + b := new(chromiumBrowser) + b.isStarted.Store(true) + return b + }(), + expectError: true, + cleanup: false, + }, + { + scenario: "browser start error", + browser: func() browser { + b := newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + }, + ).(*chromiumBrowser) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + b.initialCtx = ctx + + return b + }(), + expectError: true, + cleanup: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + err := tc.browser.Start(logger) + + if tc.cleanup { + defer func(b browser, logger *zap.Logger) { + err = b.Stop(logger) + if err != nil { + t.Fatalf("expected no error while cleaning up, but got: %v", err) + } + }(tc.browser, logger) + } + + 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 TestChromiumBrowser_Stop(t *testing.T) { + for _, tc := range []struct { + scenario string + browser browser + setup func(browser browser, logger *zap.Logger) error + expectError bool + }{ + { + scenario: "successful stop", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + }, + ), + setup: func(b browser, logger *zap.Logger) error { + return b.Start(logger) + }, + expectError: false, + }, + { + scenario: "browser already stopped", + browser: func() browser { + b := new(chromiumBrowser) + b.isStarted.Store(false) + return b + }(), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + + if tc.setup != nil { + err := tc.setup(tc.browser, logger) + if err != nil { + t.Fatalf("setup error: %v", err) + } + } + + err := tc.browser.Stop(logger) + + 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 TestChromiumBrowser_Healthy(t *testing.T) { + for _, tc := range []struct { + scenario string + browser browser + setup func(browser browser, logger *zap.Logger) error + expectHealthy bool + cleanup bool + }{ + { + scenario: "healthy browser", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + }, + ), + setup: func(b browser, logger *zap.Logger) error { + return b.Start(logger) + }, + expectHealthy: true, + cleanup: true, + }, + { + scenario: "browser not started", + browser: func() browser { + b := new(chromiumBrowser) + b.isStarted.Store(false) + return b + }(), + expectHealthy: false, + cleanup: false, + }, + { + scenario: "unhealthy browser", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + }, + ), + setup: func(b browser, logger *zap.Logger) error { + _ = b.Start(logger) + b.(*chromiumBrowser).cancelFunc() + + return nil + }, + expectHealthy: false, + cleanup: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + + if tc.setup != nil { + err := tc.setup(tc.browser, logger) + if err != nil { + t.Fatalf("setup error: %v", err) + } + } + + if tc.cleanup { + defer func(b browser, logger *zap.Logger) { + err := b.Stop(logger) + if err != nil { + t.Fatalf("expected no error while cleaning up, but got: %v", err) + } + }(tc.browser, logger) + } + + healthy := tc.browser.Healthy(logger) + + if !tc.expectHealthy && healthy { + t.Fatal("expected unhealthy browser but got an healthy one") + } + + if tc.expectHealthy && !healthy { + t.Fatal("expected a healthy browser but got an unhealthy one") + } + }) + } +} + +func TestChromiumBrowser_pdf(t *testing.T) { + for _, tc := range []struct { + scenario string + browser browser + fs *gotenberg.FileSystem + options Options + noDeadline bool + start bool + expectError bool + expectedError error + expectedLogEntries []string + }{ + { + scenario: "browser not started", + browser: func() browser { + b := new(chromiumBrowser) + b.isStarted.Store(false) + return b + }(), + fs: gotenberg.NewFileSystem(), + noDeadline: false, + start: false, + expectError: true, + }, + { + scenario: "ErrUrlNotAuthorized: main URL does not match the allowed list", + browser: func() browser { + b := new(chromiumBrowser) + b.arguments = browserArguments{ + allowList: regexp.MustCompile("^file:///[^tmp].*"), + } + b.isStarted.Store(true) + return b + }(), + fs: gotenberg.NewFileSystem(), + noDeadline: false, + start: false, + expectError: true, + expectedError: ErrUrlNotAuthorized, + }, + { + scenario: "ErrUrlNotAuthorized: main URL does match the denied list", + browser: func() browser { + b := new(chromiumBrowser) + b.arguments = browserArguments{ + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile("^file:///tmp.*"), + } + b.isStarted.Store(true) + return b + }(), + fs: gotenberg.NewFileSystem(), + noDeadline: false, + start: false, + expectError: true, + expectedError: ErrUrlNotAuthorized, + }, + { + scenario: "ErrUrlNotAuthorized: main URL does match the denied list", + browser: func() browser { + b := new(chromiumBrowser) + b.arguments = browserArguments{ + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + } + b.isStarted.Store(true) + return b + }(), + fs: gotenberg.NewFileSystem(), + noDeadline: true, + start: false, + expectError: true, + }, + { + scenario: "a request does not match the allowed list", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile("^file:///tmp.*"), + denyList: regexp.MustCompile(""), + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "'file:///etc/passwd' does not match the expression from the allowed list", + }, + }, + { + scenario: "a request does match the denied list", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile("^file:///[^tmp].*"), + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "'file:///etc/passwd' matches the expression from the denied list", + }, + }, + { + scenario: "ErrConsoleExceptions", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: Options{ + FailOnConsoleExceptions: true, + }, + noDeadline: false, + start: true, + expectError: true, + expectedError: ErrConsoleExceptions, + }, + { + scenario: "disable JavaScript", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + disableJavaScript: true, + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "disable JavaScript", + "JavaScript disabled, skipping wait delay", + "JavaScript disabled, skipping wait expression", + }, + }, + { + scenario: "extra HTTP headers", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("
Screen media type
"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: Options{ + EmulatedMediaType: "screen", + }, + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "emulate media type 'screen'", + }, + }, + { + scenario: "wait delay: context done", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: Options{ + WaitDelay: time.Duration(10) * time.Second, + }, + noDeadline: false, + start: true, + expectError: true, + expectedLogEntries: []string{ + "wait '10s' before print", + }, + }, + { + scenario: "wait delay", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: Options{ + WaitDelay: time.Duration(1) * time.Millisecond, + }, + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "wait '1ms' before print", + }, + }, + { + scenario: "wait for expression: context done", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + html := ` + +` + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(html), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: Options{ + WaitForExpression: "window.status === 'ready'", + }, + noDeadline: false, + start: true, + expectError: true, + expectedLogEntries: []string{ + "wait until 'window.status === 'ready'' is true before print", + }, + }, + { + scenario: "ErrInvalidEvaluationExpression", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("