mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-17 09:43:17 +01:00
Compare commits
18 Commits
v8.36.0
...
e29b7cb4f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e29b7cb4f5 | ||
|
|
23d59f3133 | ||
|
|
8a0de7d5d7 | ||
|
|
3c691cbebc | ||
|
|
0e83f737b4 | ||
|
|
57b048c611 | ||
|
|
d79e174c6f | ||
|
|
88ddaed09b | ||
|
|
8944db131c | ||
|
|
676570074a | ||
|
|
11179cb271 | ||
|
|
c2a85f92d0 | ||
|
|
7dbff18e65 | ||
|
|
923e5f71eb | ||
|
|
334f859d95 | ||
|
|
0819514b7b | ||
|
|
c636a52666 | ||
|
|
0b16b0ab34 |
2
.github/workflows/continuous-integration.yml
vendored
2
.github/workflows/continuous-integration.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Run linters
|
- name: Run linters
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.12.2
|
version: v2.13.2
|
||||||
|
|
||||||
lint-prettier:
|
lint-prettier:
|
||||||
name: Lint non-Golang codebase
|
name: Lint non-Golang codebase
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -38,6 +38,8 @@ API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false
|
|||||||
API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false
|
API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false
|
||||||
API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY=false
|
API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY=false
|
||||||
API_DOWNLOAD_FROM_MAX_RETRY=4
|
API_DOWNLOAD_FROM_MAX_RETRY=4
|
||||||
|
API_DOWNLOAD_FROM_MAX_CONCURRENCY=10
|
||||||
|
API_DOWNLOAD_FROM_MAX_ENTRIES=0
|
||||||
API_DISABLE_DOWNLOAD_FROM=false
|
API_DISABLE_DOWNLOAD_FROM=false
|
||||||
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
||||||
API_DISABLE_ROOT_ROUTE_TELEMETRY=true
|
API_DISABLE_ROOT_ROUTE_TELEMETRY=true
|
||||||
@@ -210,11 +212,22 @@ lint-prettier: ## Lint non-Golang codebase
|
|||||||
|
|
||||||
.PHONY: lint-todo
|
.PHONY: lint-todo
|
||||||
lint-todo: ## Find TODOs in Golang codebase
|
lint-todo: ## Find TODOs in Golang codebase
|
||||||
golangci-lint run --no-config --disable-all --enable godox
|
golangci-lint run --no-config --default=none --enable godox
|
||||||
|
|
||||||
|
# TODO: restore a plain "go fix ./..." once both modernizers stop rewriting this
|
||||||
|
# codebase into code that does not compile. Re-check by dropping a flag and
|
||||||
|
# running "make fmt && make lint". Removing an analyzer upstream makes go fix
|
||||||
|
# fail with "flag provided but not defined", so this cannot rot silently.
|
||||||
|
# embedlit folds a post-literal field assignment into a literal that already
|
||||||
|
# sets that key, producing a duplicate field name (cmd/gotenberg.go). Fixed in
|
||||||
|
# Go 1.27.1, so drop that flag once the toolchain moves past 1.27.0.
|
||||||
|
# See https://github.com/golang/go/issues/81101.
|
||||||
|
# errorsastype rewrites errors.As to errors.AsType[T] without checking that T
|
||||||
|
# satisfies error, which breaks on api.HttpError since it does not embed
|
||||||
|
# error. Still broken as of Go 1.27.1.
|
||||||
.PHONY: fmt
|
.PHONY: fmt
|
||||||
fmt: ## Format Golang codebase and "optimize" the dependencies
|
fmt: ## Format Golang codebase and "optimize" the dependencies
|
||||||
go fix ./...
|
go fix -embedlit=false -errorsastype=false ./...
|
||||||
golangci-lint fmt
|
golangci-lint fmt
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ARG instructions do not create additional layers. Instead, next layers will
|
# ARG instructions do not create additional layers. Instead, next layers will
|
||||||
# concatenate them. Also, we have to repeat ARG instructions in each build
|
# concatenate them. Also, we have to repeat ARG instructions in each build
|
||||||
# stage that uses them.
|
# stage that uses them.
|
||||||
ARG GOLANG_VERSION=1.26.5
|
ARG GOLANG_VERSION=1.27.0
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
# pdfcpu binary build stage
|
# pdfcpu binary build stage
|
||||||
@@ -88,7 +88,7 @@ RUN apt-get update -qq \
|
|||||||
|
|
||||||
WORKDIR /downloads
|
WORKDIR /downloads
|
||||||
|
|
||||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.4.0/unoconv -o unoconverter \
|
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.5.0/unoconv -o unoconverter \
|
||||||
&& chmod +x unoconverter
|
&& chmod +x unoconverter
|
||||||
|
|
||||||
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
||||||
@@ -259,7 +259,7 @@ RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/so
|
|||||||
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
|
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
|
||||||
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
|
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
||||||
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
|
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw libreoffice-math python3-uno \
|
||||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||||
&& ln -s /usr/bin/python3 /usr/bin/python \
|
&& ln -s /usr/bin/python3 /usr/bin/python \
|
||||||
# Cleanup.
|
# Cleanup.
|
||||||
@@ -386,7 +386,7 @@ RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/so
|
|||||||
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
|
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
|
||||||
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
|
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
||||||
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
|
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw libreoffice-math python3-uno \
|
||||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||||
&& ln -s /usr/bin/python3 /usr/bin/python \
|
&& ln -s /usr/bin/python3 /usr/bin/python \
|
||||||
# Cleanup.
|
# Cleanup.
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ services:
|
|||||||
- "--api-download-from-deny-public-ips=${API_DOWNLOAD_FROM_DENY_PUBLIC_IPS}"
|
- "--api-download-from-deny-public-ips=${API_DOWNLOAD_FROM_DENY_PUBLIC_IPS}"
|
||||||
- "--api-download-from-enable-environment-proxy=${API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY}"
|
- "--api-download-from-enable-environment-proxy=${API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY}"
|
||||||
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
|
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
|
||||||
|
- "--api-download-from-max-concurrency=${API_DOWNLOAD_FROM_MAX_CONCURRENCY}"
|
||||||
|
- "--api-download-from-max-entries=${API_DOWNLOAD_FROM_MAX_ENTRIES}"
|
||||||
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
|
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
|
||||||
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
|
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
|
||||||
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
|
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
|
||||||
|
|||||||
17
go.mod
17
go.mod
@@ -1,11 +1,12 @@
|
|||||||
module github.com/gotenberg/gotenberg/v8
|
module github.com/gotenberg/gotenberg/v8
|
||||||
|
|
||||||
go 1.26.5
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alexliesenfeld/health v0.8.1
|
github.com/alexliesenfeld/health v0.8.1
|
||||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
|
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
|
||||||
github.com/chromedp/chromedp v0.14.2 // pinned: v0.15.x breaks the headless print-mode paint pipeline (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts). See https://github.com/gotenberg/gotenberg/issues/1535.
|
github.com/chromedp/chromedp v0.14.2 // pinned: v0.15.x breaks the headless print-mode paint pipeline (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts). See https://github.com/gotenberg/gotenberg/issues/1535.
|
||||||
|
github.com/coreos/go-oidc/v3 v3.20.0
|
||||||
github.com/cucumber/godog v0.16.0
|
github.com/cucumber/godog v0.16.0
|
||||||
github.com/dlclark/regexp2 v1.12.0
|
github.com/dlclark/regexp2 v1.12.0
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984
|
github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984
|
||||||
@@ -20,10 +21,11 @@ require (
|
|||||||
github.com/prometheus/client_golang v1.24.1
|
github.com/prometheus/client_golang v1.24.1
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7
|
github.com/shirou/gopsutil/v4 v4.26.7
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/testcontainers/testcontainers-go v0.44.0
|
github.com/testcontainers/testcontainers-go v0.44.0
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0
|
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
|
||||||
go.opentelemetry.io/otel v1.45.0
|
go.opentelemetry.io/otel v1.45.0
|
||||||
go.opentelemetry.io/otel/log v0.21.0
|
go.opentelemetry.io/otel/log v0.21.0
|
||||||
go.opentelemetry.io/otel/metric v1.45.0
|
go.opentelemetry.io/otel/metric v1.45.0
|
||||||
@@ -37,11 +39,6 @@ require (
|
|||||||
golang.org/x/text v0.41.0
|
golang.org/x/text v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
@@ -64,7 +61,6 @@ require (
|
|||||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||||
github.com/cucumber/gherkin/go/v42 v42.0.1 // indirect
|
github.com/cucumber/gherkin/go/v42 v42.0.1 // indirect
|
||||||
github.com/cucumber/messages/go/v34 v34.2.1 // indirect
|
github.com/cucumber/messages/go/v34 v34.2.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-connections v0.8.1 // indirect
|
github.com/docker/go-connections v0.8.1 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
@@ -106,7 +102,6 @@ require (
|
|||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.28 // indirect
|
github.com/pierrec/lz4/v4 v4.1.28 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.70.1 // indirect
|
github.com/prometheus/common v0.70.1 // indirect
|
||||||
@@ -136,6 +131,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||||
golang.org/x/crypto v0.55.0 // indirect
|
golang.org/x/crypto v0.55.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
@@ -143,7 +139,6 @@ require (
|
|||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
|
||||||
google.golang.org/grpc v1.83.0 // indirect
|
google.golang.org/grpc v1.83.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.12 // indirect
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
21
go.sum
21
go.sum
@@ -55,7 +55,6 @@ github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgX
|
|||||||
github.com/cucumber/messages/go/v34 v34.2.1 h1:qBPEl+HhNJuRX8Kjaw1Pm60KOODZf2/4WQAi0SR/neE=
|
github.com/cucumber/messages/go/v34 v34.2.1 h1:qBPEl+HhNJuRX8Kjaw1Pm60KOODZf2/4WQAi0SR/neE=
|
||||||
github.com/cucumber/messages/go/v34 v34.2.1/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
|
github.com/cucumber/messages/go/v34 v34.2.1/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
@@ -130,10 +129,6 @@ github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl
|
|||||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||||
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||||
@@ -192,7 +187,6 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA
|
|||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
|
github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
|
||||||
github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
@@ -206,8 +200,6 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
|
|||||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||||
github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc=
|
github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc=
|
||||||
@@ -228,8 +220,8 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q
|
|||||||
github.com/stretchr/testify v1.7.1/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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
|
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
|
||||||
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
|
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
|
||||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||||
@@ -303,6 +295,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
@@ -333,15 +327,12 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 h1:
|
|||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
|
||||||
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||||
@@ -74,6 +75,24 @@ func buildResource(ctx context.Context, logger *slog.Logger, serviceName, servic
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OTEL_*_EXPORTER select the exporter for each signal. autoexport treats an
|
||||||
|
// unset or empty value as a request for the OTLP exporter, which then fails
|
||||||
|
// against the default localhost:4318 endpoint when nothing listens there and,
|
||||||
|
// for metrics, keeps retrying on the periodic reader's timer. Gotenberg keeps
|
||||||
|
// telemetry opt-in: a signal with no exporter configured is built without one
|
||||||
|
// and stays inert. See https://github.com/gotenberg/gotenberg/issues/1643.
|
||||||
|
const (
|
||||||
|
tracesExporterEnvKey = "OTEL_TRACES_EXPORTER"
|
||||||
|
metricsExporterEnvKey = "OTEL_METRICS_EXPORTER"
|
||||||
|
logsExporterEnvKey = "OTEL_LOGS_EXPORTER"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exporterConfigured reports whether the operator selected an exporter for the
|
||||||
|
// signal owning envKey. An unset or blank value keeps that signal off.
|
||||||
|
func exporterConfigured(envKey string) bool {
|
||||||
|
return strings.TrimSpace(os.Getenv(envKey)) != ""
|
||||||
|
}
|
||||||
|
|
||||||
// InitTracerProvider initializes the OpenTelemetry tracer provider.
|
// InitTracerProvider initializes the OpenTelemetry tracer provider.
|
||||||
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
|
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
|
||||||
initOtelLogger(logger)
|
initOtelLogger(logger)
|
||||||
@@ -86,13 +105,14 @@ func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
|||||||
trace.WithResource(res),
|
trace.WithResource(res),
|
||||||
}
|
}
|
||||||
|
|
||||||
traceExporter, err := autoexport.NewSpanExporter(ctx)
|
if exporterConfigured(tracesExporterEnvKey) {
|
||||||
if err != nil {
|
traceExporter, err := autoexport.NewSpanExporter(ctx)
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
if !autoexport.IsNoneSpanExporter(traceExporter) {
|
if !autoexport.IsNoneSpanExporter(traceExporter) {
|
||||||
traceOpts = append(traceOpts, trace.WithBatcher(traceExporter))
|
traceOpts = append(traceOpts, trace.WithBatcher(traceExporter))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
traceProvider := trace.NewTracerProvider(traceOpts...)
|
traceProvider := trace.NewTracerProvider(traceOpts...)
|
||||||
@@ -119,13 +139,14 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
|||||||
}
|
}
|
||||||
metricOpts = append(metricOpts, exemplarFilterOptions()...)
|
metricOpts = append(metricOpts, exemplarFilterOptions()...)
|
||||||
|
|
||||||
metricReader, err := autoexport.NewMetricReader(ctx)
|
if exporterConfigured(metricsExporterEnvKey) {
|
||||||
if err != nil {
|
metricReader, err := autoexport.NewMetricReader(ctx)
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
if !autoexport.IsNoneMetricReader(metricReader) {
|
if !autoexport.IsNoneMetricReader(metricReader) {
|
||||||
metricOpts = append(metricOpts, metric.WithReader(metricReader))
|
metricOpts = append(metricOpts, metric.WithReader(metricReader))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
meterProvider := metric.NewMeterProvider(metricOpts...)
|
meterProvider := metric.NewMeterProvider(metricOpts...)
|
||||||
@@ -157,13 +178,14 @@ func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
|||||||
log.WithResource(res),
|
log.WithResource(res),
|
||||||
}
|
}
|
||||||
|
|
||||||
logExporter, err := autoexport.NewLogExporter(ctx)
|
if exporterConfigured(logsExporterEnvKey) {
|
||||||
if err != nil {
|
logExporter, err := autoexport.NewLogExporter(ctx)
|
||||||
return nil, nil, err
|
if err != nil {
|
||||||
}
|
return nil, nil, err
|
||||||
|
}
|
||||||
if !autoexport.IsNoneLogExporter(logExporter) {
|
if !autoexport.IsNoneLogExporter(logExporter) {
|
||||||
logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter)))
|
logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loggerProvider := log.NewLoggerProvider(logOpts...)
|
loggerProvider := log.NewLoggerProvider(logOpts...)
|
||||||
|
|||||||
@@ -89,6 +89,49 @@ func TestInitTracerProvider_HonorsSamplerEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestExporterConfigured pins the opt-in gate: an unset or blank
|
||||||
|
// OTEL_*_EXPORTER keeps the signal off, so Gotenberg never wires the OTLP
|
||||||
|
// exporter that autoexport would otherwise default to and fail to reach at
|
||||||
|
// localhost:4318. See https://github.com/gotenberg/gotenberg/issues/1643.
|
||||||
|
func TestExporterConfigured(t *testing.T) {
|
||||||
|
const key = "OTEL_METRICS_EXPORTER"
|
||||||
|
|
||||||
|
orig, had := os.LookupEnv(key)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if had {
|
||||||
|
os.Setenv(key, orig)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
os.Unsetenv(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
unset bool
|
||||||
|
value string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"unset", true, "", false},
|
||||||
|
{"empty", false, "", false},
|
||||||
|
{"whitespace only", false, " ", false},
|
||||||
|
{"none", false, "none", true},
|
||||||
|
{"otlp", false, "otlp", true},
|
||||||
|
{"padded value", false, " otlp ", true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
if tc.unset {
|
||||||
|
os.Unsetenv(key)
|
||||||
|
} else {
|
||||||
|
os.Setenv(key, tc.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := exporterConfigured(key); got != tc.want {
|
||||||
|
t.Errorf("exporterConfigured(%q) = %v, want %v", key, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExemplarFilterOptions(t *testing.T) {
|
func TestExemplarFilterOptions(t *testing.T) {
|
||||||
t.Run("default pins trace-based", func(t *testing.T) {
|
t.Run("default pins trace-based", func(t *testing.T) {
|
||||||
if v, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {
|
if v, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ type ProcessSupervisor interface {
|
|||||||
// Healthy checks and returns the health status of the managed [Process].
|
// Healthy checks and returns the health status of the managed [Process].
|
||||||
//
|
//
|
||||||
// A non-started process is considered healthy (startup is deferred until
|
// A non-started process is considered healthy (startup is deferred until
|
||||||
// the first request). Returns false if the process is currently restarting
|
// the first request), as is one going through a planned restart, since it
|
||||||
// or is reported unhealthy by the underlying [Process].
|
// keeps serving traffic. Returns false during an unplanned restart or when
|
||||||
|
// the underlying [Process] reports unhealthy.
|
||||||
Healthy() bool
|
Healthy() bool
|
||||||
|
|
||||||
// Run executes a provided task while managing the state of the [Process].
|
// Run executes a provided task while managing the state of the [Process].
|
||||||
@@ -103,6 +104,28 @@ const healthCheckCacheTTL = 2 * time.Second
|
|||||||
// this. See https://github.com/gotenberg/gotenberg/issues/1561.
|
// this. See https://github.com/gotenberg/gotenberg/issues/1561.
|
||||||
const healthFailureThreshold = 2
|
const healthFailureThreshold = 2
|
||||||
|
|
||||||
|
// Restart reasons, also reported as the gotenberg.process.start.reason span
|
||||||
|
// attribute by [processSupervisor.tracedLaunch]. Only
|
||||||
|
// [restartReasonMaxRequests] is a planned restart: it fires on a healthy
|
||||||
|
// process that reached its conversion limit, so the node keeps serving
|
||||||
|
// traffic throughout. The others signal a process that cannot serve.
|
||||||
|
const (
|
||||||
|
restartReasonFirstStart = "first_start"
|
||||||
|
restartReasonUnhealthy = "unhealthy"
|
||||||
|
restartReasonMaxRequests = "max_requests"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultEagerRestartTimeout bounds the restart triggered after the maximum
|
||||||
|
// request limit. That restart runs on a background context, unlike the one from
|
||||||
|
// ensureHealthy which inherits the request deadline, so without a deadline of
|
||||||
|
// its own the drain loop in [processSupervisor.doRestartLocked] would wait
|
||||||
|
// forever on a task that never completes. That would pin isRestarting and,
|
||||||
|
// with it, the health reported by [processSupervisor.Healthy]. Sized well above
|
||||||
|
// --api-timeout (30s by default) plus the engine start timeouts (20s by
|
||||||
|
// default) so it never fires while tasks are merely slow. The eager restart is
|
||||||
|
// opportunistic: on expiry it aborts, and the next task retries it.
|
||||||
|
const defaultEagerRestartTimeout = 2 * time.Minute
|
||||||
|
|
||||||
type processSupervisor struct {
|
type processSupervisor struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
engine string
|
engine string
|
||||||
@@ -118,11 +141,16 @@ type processSupervisor struct {
|
|||||||
// transient failure (such as a cold-start timeout) must not poison the
|
// transient failure (such as a cold-start timeout) must not poison the
|
||||||
// supervisor for the rest of the container's lifetime. See
|
// supervisor for the rest of the container's lifetime. See
|
||||||
// https://github.com/gotenberg/gotenberg/issues/1538.
|
// https://github.com/gotenberg/gotenberg/issues/1538.
|
||||||
firstStartMu sync.Mutex
|
firstStartMu sync.Mutex
|
||||||
reqCounter atomic.Int64
|
reqCounter atomic.Int64
|
||||||
reqQueueSize atomic.Int64
|
reqQueueSize atomic.Int64
|
||||||
restartsCounter atomic.Int64
|
restartsCounter atomic.Int64
|
||||||
isRestarting atomic.Bool
|
isRestarting atomic.Bool
|
||||||
|
// restartPlanned records whether the in-flight restart is a planned one
|
||||||
|
// (see [restartReasonMaxRequests]). Written before isRestarting and never
|
||||||
|
// cleared, so a reader that observed isRestarting always sees the matching
|
||||||
|
// kind. See [processSupervisor.Healthy].
|
||||||
|
restartPlanned atomic.Bool
|
||||||
activeTasks atomic.Int64
|
activeTasks atomic.Int64
|
||||||
restartMutex sync.Mutex
|
restartMutex sync.Mutex
|
||||||
idleShutdownTimeout time.Duration
|
idleShutdownTimeout time.Duration
|
||||||
@@ -135,6 +163,9 @@ type processSupervisor struct {
|
|||||||
consecutiveHealthFailures atomic.Int64 // reset to 0 on every successful probe
|
consecutiveHealthFailures atomic.Int64 // reset to 0 on every successful probe
|
||||||
idleMu sync.Mutex // protects idleStopChan
|
idleMu sync.Mutex // protects idleStopChan
|
||||||
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
||||||
|
// eagerRestartTimeout bounds the restart from maybeRestartAfterTask.
|
||||||
|
// Defaults to [defaultEagerRestartTimeout]; only tests shorten it.
|
||||||
|
eagerRestartTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProcessSupervisor initializes a new [ProcessSupervisor]. engine names the
|
// NewProcessSupervisor initializes a new [ProcessSupervisor]. engine names the
|
||||||
@@ -158,6 +189,7 @@ func NewProcessSupervisor(logger *slog.Logger, engine string, process Process, m
|
|||||||
maxQueueSize: maxQueueSize,
|
maxQueueSize: maxQueueSize,
|
||||||
maxConcurrency: maxConcurrency,
|
maxConcurrency: maxConcurrency,
|
||||||
idleShutdownTimeout: idleShutdownTimeout,
|
idleShutdownTimeout: idleShutdownTimeout,
|
||||||
|
eagerRestartTimeout: defaultEagerRestartTimeout,
|
||||||
}
|
}
|
||||||
b.reqCounter.Store(0)
|
b.reqCounter.Store(0)
|
||||||
b.reqQueueSize.Store(0)
|
b.reqQueueSize.Store(0)
|
||||||
@@ -212,12 +244,18 @@ func (s *processSupervisor) restart() error {
|
|||||||
s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
|
s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset the counter on the attempt, not on its outcome. Leaving it at the
|
||||||
|
// limit after a failed launch re-triggers maybeRestartAfterTask on every
|
||||||
|
// subsequent task, producing back-to-back restarts. Recovering a process
|
||||||
|
// that will not start is ensureHealthy's job: it restarts synchronously
|
||||||
|
// before running a task, and reports the failure to the caller.
|
||||||
|
s.reqCounter.Store(0)
|
||||||
|
|
||||||
err = s.Launch()
|
err = s.Launch()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("restart process: %w", err)
|
return fmt.Errorf("restart process: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.reqCounter.Store(0)
|
|
||||||
s.restartsCounter.Add(1)
|
s.restartsCounter.Add(1)
|
||||||
s.logger.DebugContext(context.Background(), "process successfully restarted")
|
s.logger.DebugContext(context.Background(), "process successfully restarted")
|
||||||
|
|
||||||
@@ -234,9 +272,17 @@ func (s *processSupervisor) Healthy() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.isRestarting.Load() {
|
if s.isRestarting.Load() {
|
||||||
// A restarting process is not yet healthy. This gives load balancers
|
// A planned restart is routine maintenance: the process reached the
|
||||||
// honest information so they can avoid routing traffic to this node.
|
// limit set by --chromium-restart-after (env CHROMIUM_RESTART_AFTER) or
|
||||||
return false
|
// --libreoffice-restart-after (env LIBREOFFICE_RESTART_AFTER) while
|
||||||
|
// healthy. Tasks arriving during it are requeued by acquireSlot, not
|
||||||
|
// rejected, so the node still serves traffic and must report healthy. A
|
||||||
|
// probe sent between two conversions used to fail here.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1648.
|
||||||
|
//
|
||||||
|
// An unplanned restart keeps reporting unhealthy, which gives load
|
||||||
|
// balancers honest information so they can avoid routing traffic here.
|
||||||
|
return s.restartPlanned.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache hit: a recent probe succeeded. Skip the CDP roundtrip so probe
|
// Cache hit: a recent probe succeeded. Skip the CDP roundtrip so probe
|
||||||
@@ -484,7 +530,7 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.tracedLaunch(ctx, "first_start", func() error {
|
err := s.tracedLaunch(ctx, restartReasonFirstStart, func() error {
|
||||||
return s.runWithDeadline(ctx, s.Launch)
|
return s.runWithDeadline(ctx, s.Launch)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -525,7 +571,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
|
|||||||
|
|
||||||
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
|
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
|
||||||
|
|
||||||
if err := s.doRestart(ctx, "unhealthy"); err != nil {
|
if err := s.doRestart(ctx, restartReasonUnhealthy); err != nil {
|
||||||
return fmt.Errorf("process restart before task: %w", err)
|
return fmt.Errorf("process restart before task: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,9 +579,10 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// maybeRestartAfterTask checks if the maximum request limit has been reached
|
// maybeRestartAfterTask checks if the maximum request limit has been reached
|
||||||
// and, if so, triggers an asynchronous restart. If a restart is initiated, it
|
// and, if so, triggers an asynchronous restart bounded by
|
||||||
// takes ownership of the caller's semaphore slot (the caller must not release
|
// [defaultEagerRestartTimeout]. If a restart is initiated, it takes ownership
|
||||||
// it). Returns true if ownership was taken.
|
// of the caller's semaphore slot (the caller must not release it). Returns true
|
||||||
|
// if ownership was taken.
|
||||||
func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
||||||
if s.maxReqLimit <= 0 || s.reqCounter.Load() < s.maxReqLimit {
|
if s.maxReqLimit <= 0 || s.reqCounter.Load() < s.maxReqLimit {
|
||||||
return false
|
return false
|
||||||
@@ -548,7 +595,10 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
|||||||
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
|
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
restartErr := s.doRestartLocked(context.Background(), "max_requests")
|
ctx, cancel := context.WithTimeout(context.Background(), s.eagerRestartTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
restartErr := s.doRestartLocked(ctx, restartReasonMaxRequests)
|
||||||
s.restartMutex.Unlock()
|
s.restartMutex.Unlock()
|
||||||
if restartErr != nil {
|
if restartErr != nil {
|
||||||
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
||||||
@@ -571,6 +621,10 @@ func (s *processSupervisor) doRestart(ctx context.Context, reason string) error
|
|||||||
|
|
||||||
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
|
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
|
||||||
func (s *processSupervisor) doRestartLocked(ctx context.Context, reason string) error {
|
func (s *processSupervisor) doRestartLocked(ctx context.Context, reason string) error {
|
||||||
|
// Publish the kind before raising the flag. [processSupervisor.Healthy]
|
||||||
|
// reads restartPlanned only after it observes isRestarting, so this
|
||||||
|
// ordering keeps it from pairing a new restart with a stale kind.
|
||||||
|
s.restartPlanned.Store(reason == restartReasonMaxRequests)
|
||||||
s.isRestarting.Store(true)
|
s.isRestarting.Store(true)
|
||||||
defer s.isRestarting.Store(false)
|
defer s.isRestarting.Store(false)
|
||||||
|
|
||||||
|
|||||||
@@ -160,13 +160,49 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_restart_ResetsCounterOnFailedLaunch verifies that a
|
||||||
|
// restart whose launch fails still clears the request counter. Leaving it at
|
||||||
|
// the limit makes maybeRestartAfterTask re-fire on every subsequent task.
|
||||||
|
func TestProcessSupervisor_restart_ResetsCounterOnFailedLaunch(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
const maxReqLimit = 5
|
||||||
|
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(_ *slog.Logger) error { return errors.New("start error") },
|
||||||
|
StopMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, maxReqLimit, 0, 1, 0).(*processSupervisor)
|
||||||
|
ps.reqCounter.Store(maxReqLimit)
|
||||||
|
|
||||||
|
err := ps.restart()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ps.reqCounter.Load(); got != 0 {
|
||||||
|
t.Fatalf("expected the request counter to be reset but got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ps.restartsCounter.Load(); got != 0 {
|
||||||
|
t.Fatalf("expected the restarts counter to stay at 0 but got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.maybeRestartAfterTask(logger) {
|
||||||
|
t.Fatal("expected no further eager restart to be triggered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessSupervisor_Healthy(t *testing.T) {
|
func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
initiallyStarted bool
|
initiallyStarted bool
|
||||||
initiallyRestarting bool
|
initiallyRestarting bool
|
||||||
processHealthy bool
|
initiallyRestartPlanned bool
|
||||||
expectHealthy bool
|
processHealthy bool
|
||||||
|
expectHealthy bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "non-started process is healthy",
|
scenario: "non-started process is healthy",
|
||||||
@@ -179,6 +215,13 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
|||||||
initiallyRestarting: true,
|
initiallyRestarting: true,
|
||||||
expectHealthy: false,
|
expectHealthy: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "process going through a planned restart is healthy",
|
||||||
|
initiallyStarted: true,
|
||||||
|
initiallyRestarting: true,
|
||||||
|
initiallyRestartPlanned: true,
|
||||||
|
expectHealthy: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "process reports as healthy",
|
scenario: "process reports as healthy",
|
||||||
initiallyStarted: true,
|
initiallyStarted: true,
|
||||||
@@ -208,6 +251,9 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
|||||||
if tc.initiallyRestarting {
|
if tc.initiallyRestarting {
|
||||||
ps.isRestarting.Store(true)
|
ps.isRestarting.Store(true)
|
||||||
}
|
}
|
||||||
|
if tc.initiallyRestartPlanned {
|
||||||
|
ps.restartPlanned.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
healthy := ps.Healthy()
|
healthy := ps.Healthy()
|
||||||
|
|
||||||
@@ -257,6 +303,204 @@ func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_Healthy_PlannedRestart reproduces
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/1648. It drives the real
|
||||||
|
// Run() path until the maximum request limit triggers the eager restart, then
|
||||||
|
// asserts the supervisor reports healthy while that restart is in flight.
|
||||||
|
// Tasks arriving during it are requeued by acquireSlot, not rejected, so the
|
||||||
|
// node still serves traffic.
|
||||||
|
func TestProcessSupervisor_Healthy_PlannedRestart(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
const maxReqLimit = 10
|
||||||
|
|
||||||
|
restarting := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
var (
|
||||||
|
starts atomic.Int64
|
||||||
|
signalOne sync.Once
|
||||||
|
)
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(_ *slog.Logger) error {
|
||||||
|
// Hold the restart open so the assertions below run inside the
|
||||||
|
// window that used to report unhealthy.
|
||||||
|
if starts.Add(1) > 1 {
|
||||||
|
signalOne.Do(func() { close(restarting) })
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
StopMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, maxReqLimit, 0, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
|
for i := range maxReqLimit {
|
||||||
|
err := ps.Run(context.Background(), logger, func() error { return nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("task %d: expected no error but got: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-restarting:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatalf("expected an eager restart after %d tasks", maxReqLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ps.isRestarting.Load() {
|
||||||
|
t.Fatal("expected the supervisor to be restarting")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ps.restartPlanned.Load() {
|
||||||
|
t.Fatal("expected the restart to be flagged as planned")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ps.Healthy() {
|
||||||
|
t.Fatal("expected a planned restart to report healthy")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_Healthy_UnplannedRestart verifies the counterpart of
|
||||||
|
// [TestProcessSupervisor_Healthy_PlannedRestart]: a restart triggered by an
|
||||||
|
// unhealthy process keeps reporting unhealthy, so load balancers get honest
|
||||||
|
// information.
|
||||||
|
func TestProcessSupervisor_Healthy_UnplannedRestart(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
restarting := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
var signalOne sync.Once
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(_ *slog.Logger) error {
|
||||||
|
signalOne.Do(func() { close(restarting) })
|
||||||
|
<-release
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
StopMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool { return false },
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||||
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = ps.ensureHealthy(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-restarting:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("expected an unhealthy restart to be triggered")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ps.isRestarting.Load() {
|
||||||
|
t.Fatal("expected the supervisor to be restarting")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.restartPlanned.Load() {
|
||||||
|
t.Fatal("expected the restart not to be flagged as planned")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.Healthy() {
|
||||||
|
t.Fatal("expected an unplanned restart to report unhealthy")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_doRestartLocked_DrainDeadline verifies that a drain
|
||||||
|
// unable to acquire every slot gives up on the context deadline and clears
|
||||||
|
// isRestarting. Without a deadline on the eager restart, a task that never
|
||||||
|
// completes would pin the flag and, since a planned restart reports healthy,
|
||||||
|
// leave the supervisor claiming health forever.
|
||||||
|
func TestProcessSupervisor_doRestartLocked_DrainDeadline(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
var starts atomic.Int64
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(_ *slog.Logger) error {
|
||||||
|
starts.Add(1)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
StopMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// A concurrency of 2 makes the drain acquire one slot on top of the one the
|
||||||
|
// triggering task hands over. Fill the semaphore so it never can, mimicking
|
||||||
|
// a concurrent task that never completes.
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, 1, 0, 2, 0).(*processSupervisor)
|
||||||
|
ps.semaphore <- struct{}{}
|
||||||
|
ps.semaphore <- struct{}{}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := ps.doRestartLocked(ctx, restartReasonMaxRequests)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the drain to fail on the context deadline")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.isRestarting.Load() {
|
||||||
|
t.Fatal("expected isRestarting to be cleared after a failed drain")
|
||||||
|
}
|
||||||
|
|
||||||
|
if starts.Load() != 0 {
|
||||||
|
t.Fatalf("expected no restart attempt after a failed drain but got %d", starts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_maybeRestartAfterTask_Bounded verifies that the eager
|
||||||
|
// restart runs under a deadline. A concurrent task that never completes blocks
|
||||||
|
// the drain, and without a bound the restart goroutine would wait forever with
|
||||||
|
// isRestarting pinned, leaving Healthy() reporting a planned restart for good.
|
||||||
|
func TestProcessSupervisor_maybeRestartAfterTask_Bounded(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
StopMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, 1, 0, 2, 0).(*processSupervisor)
|
||||||
|
ps.eagerRestartTimeout = 100 * time.Millisecond
|
||||||
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
|
// Wedge one slot so the drain, which needs one on top of the slot the
|
||||||
|
// triggering task hands over, can never complete.
|
||||||
|
ps.semaphore <- struct{}{}
|
||||||
|
|
||||||
|
err := ps.Run(context.Background(), logger, func() error { return nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitFor := func(what string, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for ps.isRestarting.Load() != want {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("timed out waiting for the eager restart to %s", what)
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waitFor("start", true)
|
||||||
|
waitFor("give up on its deadline", false)
|
||||||
|
}
|
||||||
|
|
||||||
// TestProcessSupervisor_Healthy_CachesPositiveResult verifies that a
|
// TestProcessSupervisor_Healthy_CachesPositiveResult verifies that a
|
||||||
// successful probe is cached for [healthCheckCacheTTL] so subsequent
|
// successful probe is cached for [healthCheckCacheTTL] so subsequent
|
||||||
// supervisor.Healthy() calls do not re-issue the underlying process
|
// supervisor.Healthy() calls do not re-issue the underlying process
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ type downloadFromConfig struct {
|
|||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
enableEnvironmentProxy bool
|
enableEnvironmentProxy bool
|
||||||
maxRetry int
|
maxRetry int
|
||||||
|
maxConcurrency int
|
||||||
|
maxEntries int
|
||||||
disable bool
|
disable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +214,8 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet")
|
fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet")
|
||||||
fs.Bool("api-download-from-enable-environment-proxy", false, "Route downloadFrom fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
|
fs.Bool("api-download-from-enable-environment-proxy", false, "Route downloadFrom fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
|
||||||
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
|
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
|
||||||
|
fs.Int("api-download-from-max-concurrency", 10, "Set the maximum number of downloadFrom entries fetched concurrently per request - bounds the outbound fan-out. Set to 0 to disable this feature")
|
||||||
|
fs.Int("api-download-from-max-entries", 0, "Set the maximum number of downloadFrom entries allowed per request. Set to 0 to disable this feature")
|
||||||
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
|
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
|
||||||
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
|
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
|
||||||
fs.Bool("api-disable-root-route-telemetry", true, "Disable telemetry for the root route")
|
fs.Bool("api-disable-root-route-telemetry", true, "Disable telemetry for the root route")
|
||||||
@@ -255,6 +259,8 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
|
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
|
||||||
enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"),
|
enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"),
|
||||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
||||||
|
maxConcurrency: flags.MustInt("api-download-from-max-concurrency"),
|
||||||
|
maxEntries: flags.MustInt("api-download-from-max-entries"),
|
||||||
disable: flags.MustBool("api-disable-download-from"),
|
disable: flags.MustBool("api-disable-download-from"),
|
||||||
}
|
}
|
||||||
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
|
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
|
||||||
@@ -404,6 +410,18 @@ func (a *Api) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.downloadFromCfg.maxConcurrency < 0 {
|
||||||
|
err = errors.Join(err,
|
||||||
|
fmt.Errorf("download from max concurrency must not be negative, got %d; set --api-download-from-max-concurrency (env API_DOWNLOAD_FROM_MAX_CONCURRENCY) to 0 to disable the limit", a.downloadFromCfg.maxConcurrency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.downloadFromCfg.maxEntries < 0 {
|
||||||
|
err = errors.Join(err,
|
||||||
|
fmt.Errorf("download from max entries must not be negative, got %d; set --api-download-from-max-entries (env API_DOWNLOAD_FROM_MAX_ENTRIES) to 0 to disable the limit", a.downloadFromCfg.maxEntries),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
||||||
err = errors.Join(err,
|
err = errors.Join(err,
|
||||||
errors.New("both TLS certificate and key files must be set"),
|
errors.New("both TLS certificate and key files must be set"),
|
||||||
|
|||||||
@@ -222,6 +222,16 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject oversized arrays at the trust boundary, before allocating
|
||||||
|
// the results slice or spawning any goroutine, so a compact request
|
||||||
|
// cannot inflate into an unbounded fan-out.
|
||||||
|
if downloadFromCfg.maxEntries > 0 && len(dls) > downloadFromCfg.maxEntries {
|
||||||
|
return nil, cancel, WrapError(
|
||||||
|
fmt.Errorf("too many downloadFrom entries: got %d, max %d", len(dls), downloadFromCfg.maxEntries),
|
||||||
|
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field value: too many entries, the maximum is %d", downloadFromCfg.maxEntries)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Each goroutine writes to its own results slot. The main
|
// Each goroutine writes to its own results slot. The main
|
||||||
// goroutine merges into ctx.files, ctx.diskToOriginal, and
|
// goroutine merges into ctx.files, ctx.diskToOriginal, and
|
||||||
// ctx.filesByField after eg.Wait() to avoid concurrent map
|
// ctx.filesByField after eg.Wait() to avoid concurrent map
|
||||||
@@ -232,6 +242,13 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
results := make([]downloadFromResult, len(dls))
|
results := make([]downloadFromResult, len(dls))
|
||||||
|
|
||||||
eg, _ := errgroup.WithContext(ctx)
|
eg, _ := errgroup.WithContext(ctx)
|
||||||
|
// Bound the number of in-flight downloads. Each entry allocates a
|
||||||
|
// retryable client, an outbound transport, a span, and logger state,
|
||||||
|
// so an unbounded array would otherwise exhaust process memory. A
|
||||||
|
// value of 0 keeps the fan-out unbounded.
|
||||||
|
if downloadFromCfg.maxConcurrency > 0 {
|
||||||
|
eg.SetLimit(downloadFromCfg.maxConcurrency)
|
||||||
|
}
|
||||||
for i, dl := range dls {
|
for i, dl := range dls {
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
deadline, ok := ctx.Deadline()
|
deadline, ok := ctx.Deadline()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -209,6 +211,137 @@ func TestNewContext_DownloadFromConcurrentMapWrites(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An oversized downloadFrom array must be rejected at the trust boundary with
|
||||||
|
// a 400, before any download goroutine is spawned.
|
||||||
|
// https://github.com/gotenberg/gotenberg/security/advisories/GHSA-6vqw-2jgm-4x88
|
||||||
|
func TestNewContext_DownloadFromMaxEntries(t *testing.T) {
|
||||||
|
var hits atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="download.txt"`)
|
||||||
|
_, _ = w.Write([]byte("downloaded"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dls := make([]downloadFrom, 3)
|
||||||
|
for i := range dls {
|
||||||
|
dls[i] = downloadFrom{Url: fmt.Sprintf("%s/file?i=%d", server.URL, i)}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(dls)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal downloadFrom payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
err = writer.WriteField("downloadFrom", string(payload))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write downloadFrom field: %v", err)
|
||||||
|
}
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
downloadFromCfg := downloadFromConfig{maxEntries: 2}
|
||||||
|
|
||||||
|
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("newContext returned no error, want a 400 for too many entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpErr HttpError
|
||||||
|
if !errors.As(err, &httpErr) {
|
||||||
|
t.Fatalf("error %v is not an HttpError", err)
|
||||||
|
}
|
||||||
|
if status, _ := httpErr.HttpError(); status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("HTTP status = %d, want %d", status, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
if got := hits.Load(); got != 0 {
|
||||||
|
t.Fatalf("server hits = %d, want 0 (rejected before any download)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The number of in-flight downloadFrom fetches must never exceed the
|
||||||
|
// configured concurrency limit, regardless of array length.
|
||||||
|
// https://github.com/gotenberg/gotenberg/security/advisories/GHSA-6vqw-2jgm-4x88
|
||||||
|
func TestNewContext_DownloadFromMaxConcurrency(t *testing.T) {
|
||||||
|
const (
|
||||||
|
downloads = 8
|
||||||
|
maxConcurrency = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
var current, peak atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
inFlight := current.Add(1)
|
||||||
|
for {
|
||||||
|
observed := peak.Load()
|
||||||
|
if inFlight <= observed || peak.CompareAndSwap(observed, inFlight) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
current.Add(-1)
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("download-%s.txt", r.URL.Query().Get("i"))
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
_, _ = w.Write([]byte("downloaded"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dls := make([]downloadFrom, downloads)
|
||||||
|
for i := range dls {
|
||||||
|
dls[i] = downloadFrom{Url: fmt.Sprintf("%s/file?i=%d", server.URL, i)}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(dls)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal downloadFrom payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
err = writer.WriteField("downloadFrom", string(payload))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write downloadFrom field: %v", err)
|
||||||
|
}
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
downloadFromCfg := downloadFromConfig{maxConcurrency: maxConcurrency}
|
||||||
|
|
||||||
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if got := len(ctx.files); got != downloads {
|
||||||
|
t.Fatalf("downloaded files = %d, want %d", got, downloads)
|
||||||
|
}
|
||||||
|
if got := peak.Load(); got > maxConcurrency {
|
||||||
|
t.Fatalf("peak concurrency = %d, want <= %d", got, maxConcurrency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSanitizeFilename(t *testing.T) {
|
func TestSanitizeFilename(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
|
|||||||
@@ -612,7 +612,7 @@ func (form *FormData) mustValue(key string, target any, defaultValue any) *FormD
|
|||||||
val, ok := form.values[key]
|
val, ok := form.values[key]
|
||||||
|
|
||||||
if !ok || val[0] == "" {
|
if !ok || val[0] == "" {
|
||||||
switch t := (target).(type) {
|
switch t := target.(type) {
|
||||||
case *string:
|
case *string:
|
||||||
*t = defaultValue.(string)
|
*t = defaultValue.(string)
|
||||||
case *bool:
|
case *bool:
|
||||||
@@ -659,7 +659,7 @@ func (form *FormData) mustMandatoryField(key string, target any) *FormData {
|
|||||||
func (form *FormData) mustAssign(key, value string, target any) *FormData {
|
func (form *FormData) mustAssign(key, value string, target any) *FormData {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch t := (target).(type) {
|
switch t := target.(type) {
|
||||||
case *string:
|
case *string:
|
||||||
*t = value
|
*t = value
|
||||||
case *bool:
|
case *bool:
|
||||||
|
|||||||
@@ -523,8 +523,45 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
cancelOnMainPageError: taskCancel,
|
cancelOnMainPageError: taskCancel,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
crashed error
|
||||||
|
crashedMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1640.
|
||||||
|
listenForEventTargetCrashed(taskCtx, logger, eventTargetCrashedOptions{
|
||||||
|
crashed: &crashed,
|
||||||
|
crashedMu: &crashedMu,
|
||||||
|
cancel: taskCancel,
|
||||||
|
})
|
||||||
|
|
||||||
runErr := chromedp.Run(taskCtx, tasks...)
|
runErr := chromedp.Run(taskCtx, tasks...)
|
||||||
|
|
||||||
|
// A crashed renderer is the root cause of every other failure this
|
||||||
|
// conversion may have recorded, so check it first.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1640.
|
||||||
|
crashedMu.RLock()
|
||||||
|
defer crashedMu.RUnlock()
|
||||||
|
|
||||||
|
if crashed != nil {
|
||||||
|
return fmt.Errorf("handle tasks: %w", crashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The browser context is only ever canceled when the browser process
|
||||||
|
// dies or is stopped, never on a request timeout. If the run failed
|
||||||
|
// and the browser context is done, the conversion failed because the
|
||||||
|
// browser went away mid-flight; fail fast with the same crash error
|
||||||
|
// instead of letting the error fall through as a generic context
|
||||||
|
// cancellation. The check is gated on runErr so a successful
|
||||||
|
// conversion is never discarded by a browser death that lands right
|
||||||
|
// after it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1640.
|
||||||
|
if runErr != nil {
|
||||||
|
if err := b.ctx.Err(); err != nil {
|
||||||
|
return fmt.Errorf("handle tasks: %w", ErrChromiumCrashed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check event-driven errors first — they take priority over chromedp.Run
|
// Check event-driven errors first — they take priority over chromedp.Run
|
||||||
// errors because they carry the actual root cause (e.g., HTTP 500 from
|
// errors because they carry the actual root cause (e.g., HTTP 500 from
|
||||||
// the main page). When we cancel taskCtx on a main page error,
|
// the main page). When we cancel taskCtx on a main page error,
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ var (
|
|||||||
// ErrResourceLoadingFailed happens when one or more resources failed to load.
|
// ErrResourceLoadingFailed happens when one or more resources failed to load.
|
||||||
ErrResourceLoadingFailed = errors.New("resource loading failed")
|
ErrResourceLoadingFailed = errors.New("resource loading failed")
|
||||||
|
|
||||||
|
// ErrChromiumCrashed happens when the Chromium renderer crashes during a
|
||||||
|
// conversion.
|
||||||
|
ErrChromiumCrashed = errors.New("chromium crashed")
|
||||||
|
|
||||||
// PDF specific.
|
// PDF specific.
|
||||||
|
|
||||||
// ErrOmitBackgroundWithoutPrintBackground happens if
|
// ErrOmitBackgroundWithoutPrintBackground happens if
|
||||||
@@ -1106,6 +1110,8 @@ func chromiumErrorType(err error, queueReason string) string {
|
|||||||
errors.Is(err, ErrInvalidEvaluationExpression),
|
errors.Is(err, ErrInvalidEvaluationExpression),
|
||||||
errors.Is(err, ErrInvalidSelectorQuery):
|
errors.Is(err, ErrInvalidSelectorQuery):
|
||||||
return gotenberg.ErrorTypeInvalidInput
|
return gotenberg.ErrorTypeInvalidInput
|
||||||
|
case errors.Is(err, ErrChromiumCrashed):
|
||||||
|
return "chromium_unavailable"
|
||||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
|
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
|
||||||
return queueReason
|
return queueReason
|
||||||
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func TestChromiumErrorType(t *testing.T) {
|
|||||||
{"invalid resource http status", ErrInvalidResourceHttpStatusCode, "chromium_unavailable", "invalid_input"},
|
{"invalid resource http status", ErrInvalidResourceHttpStatusCode, "chromium_unavailable", "invalid_input"},
|
||||||
{"loading failed", ErrLoadingFailed, "chromium_unavailable", "invalid_input"},
|
{"loading failed", ErrLoadingFailed, "chromium_unavailable", "invalid_input"},
|
||||||
{"resource loading failed", ErrResourceLoadingFailed, "chromium_unavailable", "invalid_input"},
|
{"resource loading failed", ErrResourceLoadingFailed, "chromium_unavailable", "invalid_input"},
|
||||||
|
{"crashed", ErrChromiumCrashed, "chromium_unavailable", "chromium_unavailable"},
|
||||||
{"invalid evaluation expression", ErrInvalidEvaluationExpression, "chromium_unavailable", "invalid_input"},
|
{"invalid evaluation expression", ErrInvalidEvaluationExpression, "chromium_unavailable", "invalid_input"},
|
||||||
{"invalid selector query", ErrInvalidSelectorQuery, "chromium_unavailable", "invalid_input"},
|
{"invalid selector query", ErrInvalidSelectorQuery, "chromium_unavailable", "invalid_input"},
|
||||||
{"pdf queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_unavailable", "chromium_unavailable"},
|
{"pdf queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_unavailable", "chromium_unavailable"},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/chromedp/cdproto/cdp"
|
"github.com/chromedp/cdproto/cdp"
|
||||||
"github.com/chromedp/cdproto/fetch"
|
"github.com/chromedp/cdproto/fetch"
|
||||||
|
"github.com/chromedp/cdproto/inspector"
|
||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
"github.com/chromedp/cdproto/runtime"
|
"github.com/chromedp/cdproto/runtime"
|
||||||
@@ -521,6 +522,38 @@ func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, con
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type eventTargetCrashedOptions struct {
|
||||||
|
crashed *error
|
||||||
|
crashedMu *sync.RWMutex
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenForEventTargetCrashed listens for the Inspector.targetCrashed event,
|
||||||
|
// which Chromium sends when the renderer serving the conversion's tab
|
||||||
|
// crashes. chromedp enables the Inspector domain on every target but does
|
||||||
|
// not handle this event: left alone, the in-flight CDP command never
|
||||||
|
// receives a response and the conversion blocks until the request deadline.
|
||||||
|
// Record the crash and cancel the task context so the conversion fails fast
|
||||||
|
// instead.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1640.
|
||||||
|
func listenForEventTargetCrashed(ctx context.Context, logger *slog.Logger, options eventTargetCrashedOptions) {
|
||||||
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
|
if _, ok := ev.(*inspector.EventTargetCrashed); ok {
|
||||||
|
logger.DebugContext(ctx, "event EventTargetCrashed fired")
|
||||||
|
|
||||||
|
options.crashedMu.Lock()
|
||||||
|
defer options.crashedMu.Unlock()
|
||||||
|
|
||||||
|
*options.crashed = ErrChromiumCrashed
|
||||||
|
|
||||||
|
// Cancel the task context so the in-flight CDP command aborts
|
||||||
|
// immediately instead of waiting for a response the crashed
|
||||||
|
// renderer can never send.
|
||||||
|
options.cancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// waitForEventDomContentEventFired registers a listener for the
|
// waitForEventDomContentEventFired registers a listener for the
|
||||||
// DomContentEventFired event and returns a waiter that blocks until the
|
// DomContentEventFired event and returns a waiter that blocks until the
|
||||||
// event fires or ctx is done. The listener registers at call time, not
|
// event fires or ctx is done. The listener registers at call time, not
|
||||||
|
|||||||
@@ -1020,6 +1020,16 @@ func handleChromiumError(err error, options Options) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, ErrChromiumCrashed) {
|
||||||
|
return api.WrapError(
|
||||||
|
err,
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
"Chromium crashed while processing the request. Retry, or reduce the workload if the problem persists.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if errors.Is(err, ErrInvalidEvaluationExpression) {
|
if errors.Is(err, ErrInvalidEvaluationExpression) {
|
||||||
if options.WaitForExpression == "" {
|
if options.WaitForExpression == "" {
|
||||||
// We do not expect the 'waitWindowStatus' form field to return
|
// We do not expect the 'waitWindowStatus' form field to return
|
||||||
|
|||||||
51
pkg/modules/chromium/routes_test.go
Normal file
51
pkg/modules/chromium/routes_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHandleChromiumError_Crashed pins the mapping of a Chromium renderer
|
||||||
|
// crash to a 503 Service Unavailable. When the renderer crashes mid-conversion,
|
||||||
|
// the request must fail fast with 503 rather than hang until the deadline and
|
||||||
|
// surface as a generic timeout.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1640.
|
||||||
|
func TestHandleChromiumError_Crashed(t *testing.T) {
|
||||||
|
// Mirror the wrapping done by [chromiumBrowser.do].
|
||||||
|
err := handleChromiumError(fmt.Errorf("handle tasks: %w", ErrChromiumCrashed), Options{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
status, message := api.ParseError(err)
|
||||||
|
if status != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want %d (message: %s)", status, http.StatusServiceUnavailable, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "Chromium crashed while processing the request. Retry, or reduce the workload if the problem persists."
|
||||||
|
if message != want {
|
||||||
|
t.Errorf("message = %q, want %q", message, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleChromiumError_CrashedTakesPrecedence guards the ordering in
|
||||||
|
// [handleChromiumError]: a crash is a server-side failure and must map to 503
|
||||||
|
// even when the error chain also carries a marker that another branch would
|
||||||
|
// map to a client-error status.
|
||||||
|
func TestHandleChromiumError_CrashedTakesPrecedence(t *testing.T) {
|
||||||
|
err := handleChromiumError(
|
||||||
|
fmt.Errorf("handle tasks: %w; %w", ErrChromiumCrashed, ErrInvalidHttpStatusCode),
|
||||||
|
Options{},
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
status, _ := api.ParseError(err)
|
||||||
|
if status != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -361,7 +361,7 @@ func TestSofficeProxyEnv_OverridesExisting(t *testing.T) {
|
|||||||
// Old proxy values must be gone, not duplicated. Count exact-case keys.
|
// Old proxy values must be gone, not duplicated. Count exact-case keys.
|
||||||
counts := map[string]int{}
|
counts := map[string]int{}
|
||||||
for _, kv := range out {
|
for _, kv := range out {
|
||||||
key := strings.SplitN(kv, "=", 2)[0]
|
key, _, _ := strings.Cut(kv, "=")
|
||||||
counts[key]++
|
counts[key]++
|
||||||
}
|
}
|
||||||
for _, key := range []string{"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY"} {
|
for _, key := range []string{"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY"} {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ func (engine *PdfCpu) listImages(ctx context.Context, inputPath string) ([]pdfcp
|
|||||||
// their second column is not a numeric object number.
|
// their second column is not a numeric object number.
|
||||||
func parseImagesList(output string) []pdfcpuImage {
|
func parseImagesList(output string) []pdfcpuImage {
|
||||||
var images []pdfcpuImage
|
var images []pdfcpuImage
|
||||||
for _, line := range strings.Split(output, "\n") {
|
for line := range strings.SplitSeq(output, "\n") {
|
||||||
cols := strings.Split(line, "│")
|
cols := strings.Split(line, "│")
|
||||||
if len(cols) < 9 {
|
if len(cols) < 9 {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1401,3 +1401,19 @@ Feature: /forms/chromium/convert/url
|
|||||||
Then the response header "Content-Type" should be "application/pdf"
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
Then the "foo.pdf" PDF should have 1 page(s)
|
Then the "foo.pdf" PDF should have 1 page(s)
|
||||||
|
|
||||||
|
# chrome://crash makes the renderer crash deterministically, the same
|
||||||
|
# failure class as a renderer crash triggered by the page content. The
|
||||||
|
# request must fail fast with a 503 instead of hanging until the API
|
||||||
|
# timeout.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/1640.
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Chromium crash fails fast with 503)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | chrome://crash | field |
|
||||||
|
Then the response status code should be 503
|
||||||
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
|
Then the response body should contain string:
|
||||||
|
"""
|
||||||
|
Chromium crashed while processing the request
|
||||||
|
"""
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# TODO:
|
# TODO:
|
||||||
# 1. Check if down for each module.
|
# 1. Check if down for each module.
|
||||||
# 2. Restarting modules do not make health check fail.
|
|
||||||
|
|
||||||
@health
|
@health
|
||||||
Feature: /health
|
Feature: /health
|
||||||
@@ -106,7 +105,19 @@ Feature: /health
|
|||||||
When I make a "HEAD" request to Gotenberg at the "/foo/health" endpoint
|
When I make a "HEAD" request to Gotenberg at the "/foo/health" endpoint
|
||||||
Then the response status code should be 200
|
Then the response status code should be 200
|
||||||
|
|
||||||
|
# A planned restart, the eager cycle after LIBREOFFICE_RESTART_AFTER
|
||||||
|
# conversions, must not fail the health check: requests arriving during it
|
||||||
|
# are requeued, not rejected. Setting the limit to 1 restarts LibreOffice
|
||||||
|
# after every conversion, so each probe lands right on a restart.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/1648.
|
||||||
|
Scenario: GET /health (Planned LibreOffice Restart)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| LIBREOFFICE_RESTART_AFTER | 1 |
|
||||||
|
When I make 5 sequential "POST" requests to Gotenberg at the "/forms/libreoffice/convert" endpoint, probing "/health" after each, with the following form data and header(s):
|
||||||
|
| files | testdata/page_1.docx | file |
|
||||||
|
Then all sequential response status codes should be 200
|
||||||
|
Then all probe response status codes should be 200
|
||||||
|
|
||||||
|
|
||||||
# TODO:
|
# TODO:
|
||||||
# 1. Check if down for each module.
|
# 1. Check if down for each module.
|
||||||
# 2. Restarting modules do not make health check fail.
|
|
||||||
@@ -88,6 +88,8 @@ func findScenarioLine(filePath, name string) int {
|
|||||||
type scenario struct {
|
type scenario struct {
|
||||||
resp *httptest.ResponseRecorder
|
resp *httptest.ResponseRecorder
|
||||||
concurrentResps []*httptest.ResponseRecorder
|
concurrentResps []*httptest.ResponseRecorder
|
||||||
|
probeResps []*httptest.ResponseRecorder
|
||||||
|
sequentialResps []*httptest.ResponseRecorder
|
||||||
workdir string
|
workdir string
|
||||||
teststoreDir string
|
teststoreDir string
|
||||||
gotenbergContainer testcontainers.Container
|
gotenbergContainer testcontainers.Container
|
||||||
@@ -99,6 +101,8 @@ type scenario struct {
|
|||||||
func (s *scenario) reset(ctx context.Context) error {
|
func (s *scenario) reset(ctx context.Context) error {
|
||||||
s.resp = httptest.NewRecorder()
|
s.resp = httptest.NewRecorder()
|
||||||
s.concurrentResps = nil
|
s.concurrentResps = nil
|
||||||
|
s.probeResps = nil
|
||||||
|
s.sequentialResps = nil
|
||||||
|
|
||||||
err := os.RemoveAll(s.workdir)
|
err := os.RemoveAll(s.workdir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -470,6 +474,130 @@ func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// iMakeSequentialRequestsToGotenbergProbing mirrors the client loop from
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/1648: a conversion, then a
|
||||||
|
// probe, repeated. It records both the conversion and the probe responses so a
|
||||||
|
// scenario can assert that a planned process restart neither fails the probe
|
||||||
|
// nor breaks the conversions. Requests are sequential on purpose, since the bug
|
||||||
|
// only surfaces between two conversions.
|
||||||
|
func (s *scenario) iMakeSequentialRequestsToGotenbergProbing(ctx context.Context, count int, method, endpoint, probeEndpoint string, dataTable *godog.Table) error {
|
||||||
|
if s.gotenbergContainer == nil {
|
||||||
|
return errors.New("no Gotenberg container")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make(map[string][]string)
|
||||||
|
files := make(map[string][]string)
|
||||||
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
for _, row := range dataTable.Rows {
|
||||||
|
name := row.Cells[0].Value
|
||||||
|
value := row.Cells[1].Value
|
||||||
|
kind := row.Cells[2].Value
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case "field":
|
||||||
|
fields[name] = append(fields[name], value)
|
||||||
|
case "file":
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get current directory: %w", err)
|
||||||
|
}
|
||||||
|
value = fmt.Sprintf("%s/%s", wd, value)
|
||||||
|
files[name] = append(files[name], value)
|
||||||
|
case "header":
|
||||||
|
headers[name] = value
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unexpected %q %q", kind, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get container HTTP endpoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record := func(resp *http.Response) (*httptest.ResponseRecorder, error) {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", readErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
rec.Code = resp.StatusCode
|
||||||
|
for key, values := range resp.Header {
|
||||||
|
for _, v := range values {
|
||||||
|
rec.Header().Add(key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, writeErr := rec.Body.Write(body)
|
||||||
|
if writeErr != nil {
|
||||||
|
return nil, fmt.Errorf("write response body: %w", writeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.probeResps = make([]*httptest.ResponseRecorder, 0, count)
|
||||||
|
s.sequentialResps = make([]*httptest.ResponseRecorder, 0, count)
|
||||||
|
|
||||||
|
for i := range count {
|
||||||
|
resp, reqErr := doFormDataRequest(method, fmt.Sprintf("%s%s", base, endpoint), fields, files, headers)
|
||||||
|
if reqErr != nil {
|
||||||
|
return fmt.Errorf("request %d: do request: %w", i+1, reqErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, recErr := record(resp)
|
||||||
|
if recErr != nil {
|
||||||
|
return fmt.Errorf("request %d: %w", i+1, recErr)
|
||||||
|
}
|
||||||
|
s.resp = rec
|
||||||
|
s.sequentialResps = append(s.sequentialResps, rec)
|
||||||
|
|
||||||
|
probeResp, probeErr := doRequest(http.MethodGet, fmt.Sprintf("%s%s", base, probeEndpoint), nil, nil)
|
||||||
|
if probeErr != nil {
|
||||||
|
return fmt.Errorf("probe %d: do request: %w", i+1, probeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
probeRec, probeRecErr := record(probeResp)
|
||||||
|
if probeRecErr != nil {
|
||||||
|
return fmt.Errorf("probe %d: %w", i+1, probeRecErr)
|
||||||
|
}
|
||||||
|
s.probeResps = append(s.probeResps, probeRec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scenario) allProbeResponseStatusCodesShouldBe(expected int) error {
|
||||||
|
if len(s.probeResps) == 0 {
|
||||||
|
return errors.New("no probe responses recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, resp := range s.probeResps {
|
||||||
|
if resp.Code != expected {
|
||||||
|
return fmt.Errorf("probe %d: expected status %d, got %d %q", i+1, expected, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scenario) allSequentialResponseStatusCodesShouldBe(expected int) error {
|
||||||
|
if len(s.sequentialResps) == 0 {
|
||||||
|
return errors.New("no sequential responses recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, resp := range s.sequentialResps {
|
||||||
|
if resp.Code != expected {
|
||||||
|
return fmt.Errorf("sequential response %d: expected status %d, got %d %q", i+1, expected, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *scenario) allConcurrentResponseStatusCodesShouldBe(expected int) error {
|
func (s *scenario) allConcurrentResponseStatusCodesShouldBe(expected int) error {
|
||||||
if len(s.concurrentResps) == 0 {
|
if len(s.concurrentResps) == 0 {
|
||||||
return errors.New("no concurrent responses recorded")
|
return errors.New("no concurrent responses recorded")
|
||||||
@@ -1644,10 +1772,13 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
|
|||||||
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint with the following header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingHeaders)
|
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint with the following header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingHeaders)
|
||||||
ctx.When(`^I make a "(POST)" request to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders)
|
ctx.When(`^I make a "(POST)" request to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders)
|
||||||
ctx.When(`^I make (\d+) concurrent "(POST)" requests to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeConcurrentRequestsToGotenberg)
|
ctx.When(`^I make (\d+) concurrent "(POST)" requests to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeConcurrentRequestsToGotenberg)
|
||||||
|
ctx.When(`^I make (\d+) sequential "(POST)" requests to Gotenberg at the "([^"]*)" endpoint, probing "([^"]*)" after each, with the following form data and header\(s\):$`, s.iMakeSequentialRequestsToGotenbergProbing)
|
||||||
ctx.When(`^I wait for the asynchronous request to the webhook$`, s.iWaitForTheAsynchronousRequestToWebhook)
|
ctx.When(`^I wait for the asynchronous request to the webhook$`, s.iWaitForTheAsynchronousRequestToWebhook)
|
||||||
ctx.Then(`^the Gotenberg container (should|should NOT) log the following entries:$`, s.theGotenbergContainerShouldLogTheFollowingEntries)
|
ctx.Then(`^the Gotenberg container (should|should NOT) log the following entries:$`, s.theGotenbergContainerShouldLogTheFollowingEntries)
|
||||||
ctx.Then(`^the response status code should be (\d+)$`, s.theResponseStatusCodeShouldBe)
|
ctx.Then(`^the response status code should be (\d+)$`, s.theResponseStatusCodeShouldBe)
|
||||||
ctx.Then(`^all concurrent response status codes should be (\d+)$`, s.allConcurrentResponseStatusCodesShouldBe)
|
ctx.Then(`^all concurrent response status codes should be (\d+)$`, s.allConcurrentResponseStatusCodesShouldBe)
|
||||||
|
ctx.Then(`^all probe response status codes should be (\d+)$`, s.allProbeResponseStatusCodesShouldBe)
|
||||||
|
ctx.Then(`^all sequential response status codes should be (\d+)$`, s.allSequentialResponseStatusCodesShouldBe)
|
||||||
ctx.Then(`^all concurrent responses should have (\d+) PDF\(s\)$`, s.allConcurrentResponsesShouldHavePdfs)
|
ctx.Then(`^all concurrent responses should have (\d+) PDF\(s\)$`, s.allConcurrentResponsesShouldHavePdfs)
|
||||||
ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe)
|
ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe)
|
||||||
ctx.Then(`^the webhook request header "([^"]*)" should carry trace id "([^"]*)"$`, s.theWebhookRequestHeaderShouldCarryTraceID)
|
ctx.Then(`^the webhook request header "([^"]*)" should carry trace id "([^"]*)"$`, s.theWebhookRequestHeaderShouldCarryTraceID)
|
||||||
|
|||||||
Reference in New Issue
Block a user