Compare commits

...

95 Commits

Author SHA1 Message Date
Julien Neuhart
fe1b0020b8 chore(deps): update golang to 1.26.2 2026-04-30 15:24:06 +02:00
Julien Neuhart
b47b9f45d8 docs(contributing): reorganize sections and slim PR checklist 2026-04-30 15:14:03 +02:00
Julien Neuhart
8c0ad887f2 chore(deps): update pdfcpu to v0.12.0 (#1537) 2026-04-30 14:51:05 +02:00
Julien Neuhart
45b9f32351 refactor(chromium): drop paint-callback polyfill now that chromedp is pinned 2026-04-30 14:16:59 +02:00
Julien Neuhart
ace379a92c fix(deps): pin chromedp to v0.14.2 to restore print-mode paint pipeline 2026-04-30 14:16:59 +02:00
Julien Neuhart
4998870723 feat(libreoffice): SSRF guard for embedded external content 2026-04-30 14:16:59 +02:00
Julien Neuhart
64c28dd45e fix(supervisor): retry first launch on failure (#1538) 2026-04-30 14:16:59 +02:00
Julien Neuhart
68e0f88d5b refactor(exiftool): switch from go-exiftool library to direct CLI 2026-04-24 14:40:39 +02:00
Julien Neuhart
cc97cb7e59 docs(chromium): tighten paint-polyfill always-on godoc per CONTRIBUTING style 2026-04-24 14:33:02 +02:00
Julien Neuhart
200334197f feat(chromium): always inject paint-callback polyfill to cover waitDelay users 2026-04-24 14:30:45 +02:00
Julien Neuhart
ce9c48b2b0 fix(testdata): prettify 2026-04-24 12:52:57 +02:00
Julien Neuhart
05465b3a74 docs(chromium): tighten paint-polyfill godoc per CONTRIBUTING style [skip ci] 2026-04-24 12:51:09 +02:00
Julien Neuhart
1c0ff24c4b feat(chromium): inject paint-callback polyfill when waitForExpression or waitForSelector is set 2026-04-24 12:14:52 +02:00
Julien Neuhart
8f711b0f99 Revert "feat(chromium): skip lifecycle waits when waitForExpression or waitForSelector is set"
This reverts commit 430f95f79f.
2026-04-24 11:19:14 +02:00
Julien Neuhart
430f95f79f feat(chromium): skip lifecycle waits when waitForExpression or waitForSelector is set 2026-04-24 09:28:27 +02:00
Julien Neuhart
259d80bb68 fix(test): deny-private-ips on scenario now uses http://127.0.0.1/ as the URL 2026-04-23 21:30:43 +02:00
Julien Neuhart
7a914fce65 fix(outbound)!: per-module deny-private-ips and deny-public-ips, permissive defaults 2026-04-23 20:01:27 +02:00
Julien Neuhart
a2a8c42457 fix(chromium): default-deny file:// sub-resources when no prefix is allowed 2026-04-22 07:47:19 +02:00
Julien Neuhart
4b192b1498 fix(webhook): detach async goroutine from pooled echo.Context 2026-04-22 07:47:19 +02:00
Julien Neuhart
c204cadfc5 fix(pdfengines): require uploaded stamp/watermark file for image or pdf source 2026-04-22 07:47:19 +02:00
Julien Neuhart
35f1a990a6 fix(chromium): harden outbound URL handling 2026-04-22 07:47:19 +02:00
Pieter Oliver
7729bd0590 chore(npm): restrict npm installs + package bumps for a week (try to prevent 0-days from upstream dependency changes) 2026-04-21 20:22:09 +02:00
dependabot[bot]
2980ca97a3 chore(deps): bump github.com/labstack/gommon from 0.4.2 to 0.5.0
Bumps [github.com/labstack/gommon](https://github.com/labstack/gommon) from 0.4.2 to 0.5.0.
- [Release notes](https://github.com/labstack/gommon/releases)
- [Commits](https://github.com/labstack/gommon/compare/v0.4.2...v0.5.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/gommon
  dependency-version: 0.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 12:47:58 +02:00
dependabot[bot]
bb95b9b7f3 chore(deps): bump github.com/dlclark/regexp2 from 1.11.5 to 1.12.0
Bumps [github.com/dlclark/regexp2](https://github.com/dlclark/regexp2) from 1.11.5 to 1.12.0.
- [Commits](https://github.com/dlclark/regexp2/compare/v1.11.5...v1.12.0)

---
updated-dependencies:
- dependency-name: github.com/dlclark/regexp2
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 12:47:34 +02:00
dependabot[bot]
62d98fef79 chore(deps-dev): bump prettier from 3.8.2 to 3.8.3
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.2 to 3.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.2...3.8.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 12:47:18 +02:00
Julien Neuhart
db51f9026d docs(CONTRIBUTING.md): remove do not push instruction [skip ci] 2026-04-16 17:44:45 +02:00
hubert.lenoir
3187980ead feat: add embeds metadata 2026-04-16 17:28:16 +02:00
dependabot[bot]
eff9444294 chore(deps-dev): bump prettier from 3.8.1 to 3.8.2
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.2)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-14 22:30:29 +02:00
Julien Neuhart
999dd2db40 ci: remove thecodingmachine registry 2026-04-14 20:56:47 +02:00
Julien Neuhart
f44d59d514 fix(Dockefile): pin Chromium to version 146 on ppc64le arch 2026-04-13 20:10:47 +02:00
Julien Neuhart
092b526ab5 docs: more succint [skip ci] 2026-04-13 17:59:14 +02:00
Julien Neuhart
0b5eaff8d9 chore(deps): update Go dependencies 2026-04-13 17:45:25 +02:00
Julien Neuhart
46e190970f fix(exiftool): remove System: prefixes 2026-04-13 17:44:58 +02:00
Julien Neuhart
55d19522a8 fix(exiftool): prevent line breaks 2026-04-11 13:11:47 +02:00
Julien Neuhart
924576d3d4 fix(outboundURLs): better detaults 2026-04-11 13:05:05 +02:00
Julien Neuhart
405d8d1c2b chore(deps-dev): update package-lock.json 2026-04-10 13:43:20 +02:00
Julien Neuhart
1b1e100107 chore(deps): update Go dependencies 2026-04-10 13:42:38 +02:00
Julien Neuhart
c3b4702424 Revert "ci(ppc64le): switch to ubuntu-24.04-ppc64le-p10"
This reverts commit 866ae53436.
2026-04-10 13:36:23 +02:00
Julien Neuhart
866ae53436 ci(ppc64le): switch to ubuntu-24.04-ppc64le-p10 2026-04-09 22:00:57 +02:00
Julien Neuhart
53e6a0ecf8 Revert "test(integration): increase timeout to 90min"
This reverts commit f1d1215c7c.
2026-04-07 21:11:52 +02:00
Julien Neuhart
eab3d2da12 chore(deps): update Go dependencies 2026-04-07 21:11:41 +02:00
Julien Neuhart
3f01ca18d3 fix: better denied list 2026-04-07 21:11:41 +02:00
Julien Neuhart
405f1069c0 fix(exitool): prevent control characters 2026-04-07 21:11:41 +02:00
Julien Neuhart
f1d1215c7c test(integration): increase timeout to 90min 2026-04-07 16:21:56 +02:00
Julien Neuhart
9bf12e7cb9 fix(chromium): set PDFENGINES_CONVERT_ENGINES to empty on chromium only variants 2026-04-06 18:52:29 +02:00
Julien Neuhart
b87a6d4dfb fix(Dockerfile): re-add cURL 2026-04-06 18:46:55 +02:00
Julien Neuhart
a80191a288 docs(README): add PDFBolt as sponsor [ci skip] 2026-04-05 21:40:48 +02:00
Julien Neuhart
e4a43434dc docs: improve godoc and documentation [skip ci] 2026-04-03 14:23:18 +02:00
Julien Neuhart
4811a00543 docs(test/integration/README.md): add missing scenarios 2026-04-02 22:07:40 +02:00
Julien Neuhart
cfb48d9af4 fix(regex): add timeout 2026-04-02 21:58:44 +02:00
Julien Neuhart
8b65315a09 fix(Dockerfile): reposition COPY intructions to prevent cache invalidation 2026-03-31 19:01:13 +02:00
Julien Neuhart
20522fd1ac fix(exiftool): exclude more system tags, but keep some derived tags (safe ones) 2026-03-31 19:00:49 +02:00
Julien Neuhart
e28e2ebf0f chore(supervisor): InfoContext to DebugContext 2026-03-31 15:12:26 +02:00
Julien Neuhart
15050a311b fix(exiftool): case-insensitive comparison and expanded blocklist 2026-03-31 14:18:44 +02:00
Julien Neuhart
c99eb471f9 docs(SECURITY.md): prefer GitHub advisories [skip ci] 2026-03-31 14:05:55 +02:00
Julien Neuhart
53c2f97008 docs(markdown): human-centric instead of agents first [skip ci] 2026-03-31 12:44:13 +02:00
Julien Neuhart
61cadd0e6a chore(Dockerfile): better distinction between intructions in a single RUN 2026-03-30 22:24:32 +02:00
Julien Neuhart
813926cd47 chore(Dockerfile): typo 2026-03-30 21:49:14 +02:00
Julien Neuhart
f447e31ae2 fix(Dockerfile): re-add fonts-noto-core to prevent tofu on most languages 2026-03-30 19:12:33 +02:00
Julien Neuhart
00c6ed0387 fix(Dockerfile): use new LOG_STD_ENABLE_GCP_FIELDS instead of deprecated flag 2026-03-30 19:10:06 +02:00
Julien Neuhart
cdf861bcfd fix(Makefile): remove prometheus deprecated flag 2026-03-30 19:08:37 +02:00
Julien Neuhart
5279d73928 chore(testdata): bigger image.png in html-with-asset 2026-03-30 19:07:39 +02:00
Julien Neuhart
e6d2a5640b ci: handle new variants 2026-03-30 18:14:15 +02:00
Julien Neuhart
4387a4f122 chore(Dockerfile): update container description 2026-03-30 18:14:15 +02:00
Julien Neuhart
bf0d0a4f40 feat(main): add dedicated Go entrypoints for libreoffice / chromium only variants 2026-03-30 18:14:15 +02:00
Julien Neuhart
f8809b3943 feat(Dockerfile): no dedicated Dockerfiles for AWS and Cloud Run + chromium/libreoffice only variants 2026-03-30 18:14:15 +02:00
Julien Neuhart
9e5b05ad3c feat(Dockerfile): libreoffice and chromium only variants 2026-03-30 18:14:15 +02:00
Julien Neuhart
452a500955 chore(webhook): deprecate Gotenberg-Webhook-Error-Url, make it non-mandatory if Gotenberg-Webhook-Events-Url is set 2026-03-30 18:14:15 +02:00
Julien Neuhart
1ab0117943 fix(chromium): assets not loading 2026-03-30 18:14:15 +02:00
Julien Neuhart
5be574081c ci: bump checkout actions 2026-03-28 21:45:11 +01:00
Julien Neuhart
6430bc6a5c test(integration): add retry mecanism 2026-03-28 21:09:22 +01:00
Julien Neuhart
e0eff40f74 tests(integreation): fix failing scenarios and add screenshots one 2026-03-28 20:39:53 +01:00
Julien Neuhart
385cbe6590 feat(webhook): add events 2026-03-28 19:00:07 +01:00
Julien Neuhart
043b1588de fix(exiftool): arbitrary file write 2026-03-28 14:19:04 +01:00
Julien Neuhart
e5d861def3 refactor(otel): disable telemetry by default for system routes 2026-03-28 14:08:50 +01:00
Julien Neuhart
72bc6f895a feat(otel): flag to disable telemetry for system routes 2026-03-28 14:06:16 +01:00
Julien Neuhart
7549a69f71 feat(otel): switch from software name to binary path 2026-03-28 13:31:28 +01:00
Julien Neuhart
ebf0548d19 refactor: switch from multierr to errors.join 2026-03-27 22:14:01 +01:00
Julien Neuhart
d3a65a587c feat(supervisor): optional idle shutdown 2026-03-27 21:16:49 +01:00
Julien Neuhart
8f7c1c98ad fix(api): handle correctly filename that takes more that 200 bytes 2026-03-27 20:55:50 +01:00
Julien Neuhart
40d75882a2 fix(chromium): better singlePage handling - accounting for margins now 2026-03-27 19:50:20 +01:00
Julien Neuhart
bb91259829 feat(libreoffice): add viewer preference form fields 2026-03-27 19:40:42 +01:00
Julien Neuhart
0b33be17a4 fix(chromium): waitForSelector and waitForExpression poll indefinitely when the main page returns a bad HTTP status (e.g., 500) 2026-03-27 17:32:27 +01:00
Julien Neuhart
3a78b89c97 docs(otel): add pointers 2026-03-27 16:56:29 +01:00
Julien Neuhart
c72be765b0 feat(otel): add more tracing when communicating with internal tools / external APIs 2026-03-27 16:50:24 +01:00
Julien Neuhart
4e9f63004d feat(otel): add OpenTelemetry support 2026-03-27 16:28:45 +01:00
Julien Neuhart
08088c15f4 feat(chromium): skip or waits for networkIdle2 event 2026-03-27 11:01:39 +01:00
Julien Neuhart
8625a4e899 fix(webhook/downloadFrom): better default security and DX for allow / deny lists 2026-03-27 10:52:04 +01:00
Julien Neuhart
06b2b2e10c fix(chromium): better default security and DX for allow / deny lists 2026-03-27 09:47:21 +01:00
Julien Neuhart
bd6d92be9b fix(supervisor): queue slot - second request hitting a busy node now gets a 429 immediately, pushing backpressure to a load balancer, if any 2026-03-26 21:24:51 +01:00
Julien Neuhart
ed22f1e5e6 refactor(supervisor): better orchestration of processes for conversion requests 2026-03-26 21:24:51 +01:00
Julien Neuhart
8e3acc8d0a docs(agents): better (?) organization of context files 2026-03-26 21:24:51 +01:00
dependabot[bot]
51afa5ba85 chore(deps): bump github.com/chromedp/chromedp from 0.14.2 to 0.15.0
Bumps [github.com/chromedp/chromedp](https://github.com/chromedp/chromedp) from 0.14.2 to 0.15.0.
- [Release notes](https://github.com/chromedp/chromedp/releases)
- [Commits](https://github.com/chromedp/chromedp/compare/v0.14.2...v0.15.0)

---
updated-dependencies:
- dependency-name: github.com/chromedp/chromedp
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 10:20:37 +01:00
Julien Neuhart
d67500ee05 chore(cmd): new start title 2026-03-21 21:17:35 +01:00
Julien Neuhart
0bec14c6ac docs: better README.md 2026-03-21 20:42:05 +01:00
183 changed files with 13623 additions and 2776 deletions

View File

@@ -11,7 +11,7 @@ post {
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-html/index.html)
files: @file(../test/integration/testdata/page-1-html/index.html)
~landscape: false
~printBackground: false
~scale: 1.0
@@ -50,6 +50,9 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
@@ -66,6 +69,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -51,6 +51,9 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
@@ -67,6 +70,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -50,6 +50,9 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
@@ -66,6 +69,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -40,6 +40,7 @@ headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -41,6 +41,7 @@ headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -40,6 +40,7 @@ headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -11,7 +11,7 @@ post {
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.docx)
files: @file(../test/integration/testdata/page_1.docx)
~password:
~landscape: false
~nativePageRanges:
@@ -31,6 +31,21 @@ body:multipart-form {
~skipEmptyPages: false
~addOriginalDocumentAsStream: false
~singlePageSheets: false
~initialView: 0
~initialPage: 1
~magnification: 0
~zoom: 100
~pageLayout: 0
~firstPageOnLeft: false
~resizeWindowToInitialPage: false
~centerWindow: false
~openInFullScreenMode: false
~displayPDFDocumentTitle: true
~hideViewerMenubar: false
~hideViewerToolbar: false
~hideViewerWindowControls: false
~useTransitionEffects: true
~openBookmarkLevels: -1
~losslessImageCompression: false
~quality: 90
~reduceImageResolution: false
@@ -52,6 +67,9 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
@@ -68,6 +86,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -17,6 +17,7 @@ body:multipart-form {
headers {
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -19,6 +19,7 @@ headers {
~Gotenberg-Output-Filename: with-bookmarks
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -20,6 +20,7 @@ headers {
~Gotenberg-Output-Filename: converted
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -11,8 +11,10 @@ post {
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
embeds: @file(../../test/integration/testdata/page_1.pdf)
files: @file(../test/integration/testdata/page_1.pdf)
embeds: @file(../test/integration/testdata/embed_1.xml)
embeds: @file(../test/integration/testdata/embed_2.xml)
embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
}
@@ -20,6 +22,7 @@ headers {
~Gotenberg-Output-Filename: with-embeds
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -20,6 +20,7 @@ headers {
~Gotenberg-Output-Filename: encrypted
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -18,6 +18,7 @@ headers {
~Gotenberg-Output-Filename: flattened
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -37,6 +37,7 @@ headers {
~Gotenberg-Output-Filename: merged
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -17,6 +17,7 @@ body:multipart-form {
headers {
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -19,6 +19,7 @@ headers {
~Gotenberg-Output-Filename: with-metadata
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -20,6 +20,7 @@ headers {
~Gotenberg-Output-Filename: rotated
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -37,6 +37,7 @@ headers {
~Gotenberg-Output-Filename: split
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -24,6 +24,7 @@ headers {
~Gotenberg-Output-Filename: stamped
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -24,6 +24,7 @@ headers {
~Gotenberg-Output-Filename: watermarked
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

65
.bruno/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Bruno API Collection
[Bruno](https://www.usebruno.com/) collection mirroring every Gotenberg route. Update the collection when adding or modifying a route.
## Structure
```
.bruno/
├── bruno.json # Collection config
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
├── environments/
│ ├── Local.bru # baseUrl: http://localhost:3000
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
├── Health & Info/ # GET routes
├── Chromium/Convert/ # POST routes grouped by module
├── Chromium/Screenshot/
├── LibreOffice/
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, ...)
```
## `.bru` file format
```bru
meta {
name: <Human-readable name>
type: http
seq: <order within folder>
}
post {
url: {{baseUrl}}/forms/<path>
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/<file>)
<mandatoryField>: <value>
~<optionalField>: <value>
}
headers {
~Gotenberg-Output-Filename: <name>
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}
```
## Conventions
- Mandatory fields have no prefix. Optional fields use `~` (disabled by default in Bruno).
- File references use relative paths to `test/integration/testdata/`.
- Webhook and output filename headers appear on every POST route as optional (`~`).
- One `.bru` file per request. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
## Checklist
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
2. Include all form fields from the route handler. Check `FormData*` calls in the route function.
3. For file upload fields (`files`, `watermark`, `stamp`, `embeds`), use `@file(...)` with a suitable test file.
4. Verify the URL path matches the route's `Path` field exactly.
5. For new module folders, keep the naming consistent (e.g., `PDF Engines/Rotate/`).

3
.env
View File

@@ -2,6 +2,5 @@ GOTENBERG_VERSION=snapshot
DOCKER_REGISTRY=gotenberg
DOCKER_REPOSITORY=gotenberg
DOCKERFILE=build/Dockerfile
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
DOCKERFILE_AWS_LAMBDA=build/Dockerfile.aws-lambda
DOCKER_BUILD_CONTEXT='.'
TARGET=gotenberg

View File

@@ -29,14 +29,32 @@ inputs:
outputs:
tags:
description: Comma separated list of tag
description: Comma separated list of full variant tags
value: ${{ steps.build.outputs.tags }}
tags_chromium:
description: Comma separated list of Chromium variant tags
value: ${{ steps.build.outputs.tags_chromium }}
tags_libreoffice:
description: Comma separated list of LibreOffice variant tags
value: ${{ steps.build.outputs.tags_libreoffice }}
tags_cloud_run:
description: Comma separated list of Cloud Run tags (linux/amd64 only)
description: Comma separated list of Cloud Run full variant tags (linux/amd64 only)
value: ${{ steps.build.outputs.tags_cloud_run }}
tags_cloud_run_chromium:
description: Comma separated list of Cloud Run Chromium variant tags (linux/amd64 only)
value: ${{ steps.build.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice:
description: Comma separated list of Cloud Run LibreOffice variant tags (linux/amd64 only)
value: ${{ steps.build.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda:
description: Comma separated list of AWS Lambda tags (linux/amd64 and linux/arm64 only)
description: Comma separated list of AWS Lambda full variant tags (linux/amd64 and linux/arm64 only)
value: ${{ steps.build.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium:
description: Comma separated list of AWS Lambda Chromium variant tags (linux/amd64 and linux/arm64 only)
value: ${{ steps.build.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice:
description: Comma separated list of AWS Lambda LibreOffice variant tags (linux/amd64 and linux/arm64 only)
value: ${{ steps.build.outputs.tags_aws_lambda_libreoffice }}
runs:
using: composite
@@ -48,7 +66,7 @@ runs:
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Log in to Docker Hub
if: inputs.docker_hub_username != ''
@@ -91,7 +109,7 @@ runs:
if: inputs.docker_hub_username != ''
shell: bash
env:
INPUT_TAGS: ${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}
INPUT_TAGS: ${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_chromium }},${{ steps.build.outputs.tags_libreoffice }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_cloud_run_chromium }},${{ steps.build.outputs.tags_cloud_run_libreoffice }},${{ steps.build.outputs.tags_aws_lambda }},${{ steps.build.outputs.tags_aws_lambda_chromium }},${{ steps.build.outputs.tags_aws_lambda_libreoffice }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
.github/actions/build-test-push/push.sh \
@@ -102,5 +120,11 @@ runs:
shell: bash
run: |
echo "tags=${{ steps.build.outputs.tags }}"
echo "tags_chromium=${{ steps.build.outputs.tags_chromium }}"
echo "tags_libreoffice=${{ steps.build.outputs.tags_libreoffice }}"
echo "tags_cloud_run=${{ steps.build.outputs.tags_cloud_run }}"
echo "tags_cloud_run_chromium=${{ steps.build.outputs.tags_cloud_run_chromium }}"
echo "tags_cloud_run_libreoffice=${{ steps.build.outputs.tags_cloud_run_libreoffice }}"
echo "tags_aws_lambda=${{ steps.build.outputs.tags_aws_lambda }}"
echo "tags_aws_lambda_chromium=${{ steps.build.outputs.tags_aws_lambda_chromium }}"
echo "tags_aws_lambda_libreoffice=${{ steps.build.outputs.tags_aws_lambda_libreoffice }}"

View File

@@ -55,8 +55,14 @@ fi
# Build tags arrays.
tags=()
tags_chromium=()
tags_libreoffice=()
tags_cloud_run=()
tags_cloud_run_chromium=()
tags_cloud_run_libreoffice=()
tags_aws_lambda=()
tags_aws_lambda_chromium=()
tags_aws_lambda_libreoffice=()
IFS='/' read -ra arch <<< "$platform"
IFS='.' read -ra semver <<< "$version"
@@ -69,55 +75,95 @@ if [ "${#semver[@]}" -eq 3 ]; then
minor="${semver[1]}"
patch="${semver[2]}"
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-${arch[1]}")
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-${arch[1]}")
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-${arch[1]}")
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-${arch[1]}")
done
if [ "$platform" = "linux/amd64" ]; then
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-cloudrun")
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-cloudrun")
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-cloudrun")
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-cloudrun")
done
fi
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-aws-lambda-${arch[1]}")
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-aws-lambda-${arch[1]}")
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-aws-lambda-${arch[1]}")
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-aws-lambda-${arch[1]}")
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-aws-lambda-${arch[1]}")
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-aws-lambda-${arch[1]}")
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-aws-lambda-${arch[1]}")
done
fi
else
echo
echo "Non-semver version detected, fallback to $version"
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}")
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-${arch[1]}")
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-${arch[1]}")
if [ "$platform" = "linux/amd64" ]; then
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-cloudrun")
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-cloudrun")
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-cloudrun")
fi
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-aws-lambda-${arch[1]}")
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-aws-lambda-${arch[1]}")
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-aws-lambda-${arch[1]}")
fi
fi
tags_flags=()
tags_chromium_flags=()
tags_libreoffice_flags=()
tags_cloud_run_flags=()
tags_cloud_run_chromium_flags=()
tags_cloud_run_libreoffice_flags=()
tags_aws_lambda_flags=()
tags_aws_lambda_chromium_flags=()
tags_aws_lambda_libreoffice_flags=()
echo "Will use the following tags:"
for tag in "${tags[@]}"; do
tags_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_chromium[@]}"; do
tags_chromium_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_libreoffice[@]}"; do
tags_libreoffice_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_cloud_run[@]}"; do
tags_cloud_run_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_cloud_run_chromium[@]}"; do
tags_cloud_run_chromium_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_cloud_run_libreoffice[@]}"; do
tags_cloud_run_libreoffice_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_aws_lambda[@]}"; do
tags_aws_lambda_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_aws_lambda_chromium[@]}"; do
tags_aws_lambda_chromium_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_aws_lambda_libreoffice[@]}"; do
tags_aws_lambda_libreoffice_flags+=("-t" "$tag")
echo "- $tag"
done
echo
# Build images.
@@ -145,7 +191,9 @@ join() {
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
# Full variant.
cmd="docker buildx build \
--target gotenberg \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
@@ -155,30 +203,102 @@ cmd="docker buildx build \
"
run_cmd "$cmd"
# Chromium only variant.
cmd="docker buildx build \
--target gotenberg-chromium \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_chromium_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
# LibreOffice only variant.
cmd="docker buildx build \
--target gotenberg-libreoffice \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_libreoffice_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
# Cloud Run variants (amd64 only).
if [ "$platform" = "linux/amd64" ]; then
cmd="docker build \
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
cmd="docker buildx build \
--target gotenberg-cloudrun \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_cloud_run_flags[*]} \
-f $DOCKERFILE_CLOUDRUN $DOCKER_BUILD_CONTEXT
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-cloudrun-chromium \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_cloud_run_chromium_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-cloudrun-libreoffice \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_cloud_run_libreoffice_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
fi
# AWS Lambda variants (amd64 + arm64 only).
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
cmd="docker build \
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
--build-arg GOTENBERG_VERSION=$version \
${tags_aws_lambda_flags[*]} \
-f $DOCKERFILE_AWS_LAMBDA $DOCKER_BUILD_CONTEXT
cmd="docker buildx build \
--target gotenberg-aws-lambda \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_aws_lambda_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-aws-lambda-chromium \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_aws_lambda_chromium_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-aws-lambda-libreoffice \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_aws_lambda_libreoffice_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
fi
echo "✅ Done!"
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_chromium=$(join "," "${tags_chromium[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_libreoffice=$(join "," "${tags_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run_chromium=$(join "," "${tags_cloud_run_chromium[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run_libreoffice=$(join "," "${tags_cloud_run_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_aws_lambda=$(join "," "${tags_aws_lambda[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_aws_lambda_chromium=$(join "," "${tags_aws_lambda_chromium[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_aws_lambda_libreoffice=$(join "," "${tags_aws_lambda_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
exit 0

View File

@@ -31,7 +31,7 @@ runs:
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Log in to Docker Hub
uses: docker/login-action@v3

View File

@@ -16,3 +16,5 @@ updates:
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7

View File

@@ -13,8 +13,14 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -34,8 +40,14 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -55,8 +67,14 @@ jobs:
runs-on: ubuntu-24.04-ppc64le
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -76,8 +94,14 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -97,8 +121,14 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -132,7 +162,20 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Merge Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }}"
- name: Merge LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }}"
- name: Merge AWS Lambda
uses: ./.github/actions/merge
@@ -140,11 +183,24 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }}"
- name: Merge AWS Lambda LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }},${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }},${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }},${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }},${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }},${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"

View File

@@ -46,7 +46,7 @@ jobs:
node-version-file: .node-version
- name: Install Dependencies
run: npm i
run: npm ci --ignore-scripts
- name: Run linters
run: make lint-prettier
@@ -77,8 +77,14 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -101,8 +107,14 @@ jobs:
runs-on: ubuntu-24.04-ppc64le
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -125,8 +137,14 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -149,8 +167,14 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -173,8 +197,14 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -232,6 +262,20 @@ jobs:
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
- name: Merge Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_chromium }},${{ needs.snapshot_386.outputs.tags_chromium }},${{ needs.snapshot_ppc64le.outputs.tags_chromium }},${{ needs.snapshot_arm64.outputs.tags_chromium }},${{ needs.snapshot_arm_v7.outputs.tags_chromium }}"
- name: Merge LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_libreoffice }},${{ needs.snapshot_386.outputs.tags_libreoffice }},${{ needs.snapshot_ppc64le.outputs.tags_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_libreoffice }},${{ needs.snapshot_arm_v7.outputs.tags_libreoffice }}"
- name: Merge AWS Lambda
uses: ./.github/actions/merge
with:
@@ -239,12 +283,26 @@ jobs:
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}"
- name: Merge AWS Lambda Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_chromium }}"
- name: Merge AWS Lambda LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_libreoffice }}"
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}"
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }},${{ needs.snapshot_amd64.outputs.tags_chromium }},${{ needs.snapshot_386.outputs.tags_chromium }},${{ needs.snapshot_ppc64le.outputs.tags_chromium }},${{ needs.snapshot_arm64.outputs.tags_chromium }},${{ needs.snapshot_arm_v7.outputs.tags_chromium }},${{ needs.snapshot_amd64.outputs.tags_libreoffice }},${{ needs.snapshot_386.outputs.tags_libreoffice }},${{ needs.snapshot_ppc64le.outputs.tags_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_libreoffice }},${{ needs.snapshot_arm_v7.outputs.tags_libreoffice }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_libreoffice }}"
edge_amd64:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
@@ -254,8 +312,14 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -277,8 +341,14 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -300,8 +370,14 @@ jobs:
runs-on: ubuntu-24.04-ppc64le
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -323,8 +399,14 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -346,8 +428,14 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -380,7 +468,20 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Merge Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }}"
- name: Merge LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }}"
- name: Merge AWS Lambda
uses: ./.github/actions/merge
@@ -388,11 +489,24 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }}"
- name: Merge AWS Lambda LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }},${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }},${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }},${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }},${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }},${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"

View File

@@ -24,13 +24,14 @@ linters:
- ineffassign
- misspell
- prealloc
- promlinter
- staticcheck
- testableexamples
- tparallel
- unconvert
- unused
- sloglint
- usetesting
- gocritic
- wastedassign
- whitespace
exclusions:
@@ -40,10 +41,6 @@ linters:
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gci
@@ -59,7 +56,3 @@ formatters:
custom-order: true
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -1 +1 @@
24.11.0
24.15.0

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
min-release-age=7

336
AGENTS.md
View File

@@ -1,336 +0,0 @@
# Operational Guidelines for Gotenberg
You are working on **Gotenberg**, a Docker-based API for converting documents to PDF. It is a widely used production dependency. Stability and backward compatibility are paramount. When in doubt about whether a change is breaking, flag it rather than assuming it's safe.
## Mandatory Workflow
Every task MUST follow these five steps in order. Do not skip any step.
### Step 1 — Plan
Before writing any code, produce a plan that covers:
- **Problem statement**: What needs to change and why.
- **Proposed solution**: The recommended approach with enough detail to implement (files to modify, interface changes, pipeline positioning, form fields, etc.).
- **Alternatives considered**: At least one alternative approach when pertinent, with a brief explanation of why the proposed solution is preferred.
- **Scope**: List every file that will be created or modified.
- **Testing strategy**: Which integration test tags will be affected, what new scenarios are needed, and whether unit tests are required.
Present the plan to the user and wait for approval before proceeding to Step 2. If the user provides a plan, validate it against the codebase and flag any issues before implementing.
### Step 2 — Implement
Implement the approved plan following the coding standards and patterns described in this document. After implementation, verify the build compiles (`go build ./...`).
### Step 3 — Test
Write or update tests based on the plan's testing strategy:
- **Integration tests** (primary): Gherkin scenarios in `test/integration/features/`. See the [Integration Tests](#integration-tests) section.
- **Unit tests** (when applicable): Table-driven tests in `*_test.go` files using mocks from `pkg/gotenberg/mocks.go`.
### Step 4 — Review
Self-review the implementation against the [Review Checklist](#review-checklist). Fix any issues found before presenting the result to the user.
### Step 5 — Commit
Present the review to the user and **wait for explicit approval**. Do NOT commit until the user confirms. Once approved, create a commit following the [Conventional Commits](https://www.conventionalcommits.org/) specification:
```
<type>(<scope>): <description>
```
Stage only the files related to the change. Do not use `git add -A` or `git add .`.
---
## Core Principles
- **Backward compatibility is law.** Never modify existing CLI flags, environment variables, or API form fields unless explicitly instructed to perform a breaking change. Flag any breaking change immediately.
- **Defensive programming.** Assume input is malformed. Handle errors explicitly. Never panic.
- **Atomic commits.** One feature or fix per PR. Isolate refactoring from feature work.
- **Idiomatic Go.** Follow "Effective Go" principles. All exported symbols must have GoDoc comments starting with their name.
## Project Layout
```
cmd/gotenberg/ → Entry point only (wiring/startup). No business logic.
pkg/gotenberg/ → Core module system, interfaces, utilities, mocks.
pkg/modules/ → Feature modules (api, chromium, libreoffice, pdfengines, etc.).
pkg/standard/ → Wires all standard modules together via imports.
test/integration/ → Gherkin feature files + Go test infrastructure.
build/ → Dockerfile, fonts, Chromium config.
.bruno/ → Bruno API collection (mirrors every route).
```
Key interfaces live in `pkg/gotenberg/``Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers. When adding features, determine if they belong in an existing module or require a new one.
## Codebase Navigation
- Start with `pkg/gotenberg/` for core interfaces and `pkg/modules/` for feature implementations.
- The integration test infrastructure in `test/integration/scenario/` is well-structured — read `scenario.go` and `containers.go` to understand the Gherkin step definitions before writing new tests.
- Mocks for all major interfaces are in `pkg/gotenberg/mocks.go` — use them for unit tests rather than creating new ones.
- Import ordering is enforced: standard library, third-party, then `github.com/gotenberg/gotenberg/v8` — separated by blank lines.
- When making changes, run only the relevant integration test tag rather than the full suite (40min timeout).
---
## Makefile — the Only Build Interface
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
| Command | Purpose | When to use |
| ----------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `make build` | Build the Docker image | Before integration tests, or to verify compilation |
| `make run` | Run a Gotenberg container locally | Manual testing. Flags are configured via `.env` and Makefile variables |
| `make fmt` | Format Go code (`go fix`, `golangci-lint fmt`, `go mod tidy`) | Before every commit |
| `make lint` | Lint Go code (strict `.golangci.yml` config) | Before every commit. Zero errors permitted |
| `make lint-prettier` | Lint non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
| `make prettify` | Format non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
| `make test-unit` | Run unit tests (`go test -race ./...`) | After code changes to `pkg/` |
| `make test-integration` | Run integration tests (Gherkin/Godog, 40min timeout) | After any feature or route change |
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
## Module System
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module:
- Lives in `pkg/modules/<name>/`
- Implements the `gotenberg.Module` interface (at minimum `Descriptor()`)
- May also implement `gotenberg.Provisioner`, `gotenberg.Validator`, or `gotenberg.Debuggable`
- Self-registers via `init()` and is wired through `pkg/standard/`
When adding a feature, first determine if it belongs in an existing module. Only create a new module if the feature represents a genuinely separate concern.
## Commit Convention
Commits must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
```
<type>(<scope>): <description>
```
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope should match the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
## Coding Patterns
- **Error handling:** Always wrap errors with context using `fmt.Errorf("description: %w", err)`. Never swallow errors silently.
- **Import ordering:** Enforced by `gci` — standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
- **Mocks:** Comprehensive mock implementations for all major interfaces live in `pkg/gotenberg/mocks.go`. Use these for unit tests.
- **No business logic in `cmd/`:** The `cmd/gotenberg/` package is strictly for wiring and startup.
## Adding PDF Engine Features
When adding a new PDF engine capability (e.g., bookmarks, watermark, stamp, embed), you must update the Makefile to include the corresponding engine list variable and flag. Every `--pdfengines-*-engines` flag registered in `pkg/modules/pdfengines/pdfengines.go` must have a matching entry in the Makefile:
1. **Add a variable** in the Makefile's variable block (around line 60-70):
```makefile
PDFENGINES_<FEATURE>_ENGINES=<default engines>
```
2. **Add the flag** in the Makefile's command args block (around line 140-155):
```makefile
--pdfengines-<feature>-engines=$(PDFENGINES_<FEATURE>_ENGINES) \
```
The default value should match what is defined in `pdfengines.go`'s `fs.StringSlice(...)` call for that flag.
---
## Integration Tests
- **Framework:** Gherkin (BDD) via [Godog](https://github.com/cucumber/godog), with `testcontainers-go` for Docker orchestration.
- **Feature files:** `test/integration/features/*.feature` — one file per endpoint or capability.
- **Test infrastructure:** `test/integration/scenario/` — Go step definitions, container management, HTTP helpers, PDF validation.
- **Entry point:** `test/integration/main_test.go` (build tag: `integration`).
- **Test data:** `test/integration/testdata/`
### How It Works
Each scenario spins up a fresh Gotenberg Docker container via testcontainers. The step definitions in `scenario/scenario.go` map Gherkin steps to Go functions. An additional `gotenberg/integration-tools` container provides PDF validation tools (`verapdf`, `pdfinfo`, `pdftotext`).
**Important:** Integration tests require a Docker image. Run `make build` before `make test-integration`.
### Selective Test Runs
Use the `TAGS` variable to run only relevant scenarios:
```bash
make test-integration TAGS=health
make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
```
Available tags: `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `debug`, `health`, `libreoffice`, `libreoffice-convert`, `output-filename`, `pdfengines`, `pdfengines-convert`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-flatten`, `flatten`, `pdfengines-merge`, `merge`, `pdfengines-metadata`, `metadata`, `pdfengines-split`, `split`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-bookmarks`, `bookmarks`, `pdfengines-rotate`, `rotate`, `prometheus-metrics`, `root`, `version`, `webhook`, `download-from`.
Other useful flags:
```bash
make test-integration NO_CONCURRENCY=true # Disable parallel scenarios
make test-integration PLATFORM=linux/arm64 # Force a specific platform
```
### Writing a New Integration Test
1. Create or update a `.feature` file in `test/integration/features/`.
2. Tag it appropriately (e.g., `@chromium @chromium-convert-html`).
3. If the feature requires new tag(s), add them to both the `TAGS` comment block in the `Makefile` and the "Available tags" list in this file.
4. If you create a new step definition, add it to `scenario/scenario.go`, register it in `InitializeScenario`, and update the "Available Gherkin Steps" list below.
5. Test data goes in `test/integration/testdata/`.
### Available Gherkin Steps
**Given (setup):**
- `I have a default Gotenberg container`
- `I have a Gotenberg container with the following environment variable(s):` (table: key | value)
- `I have a (webhook|static) server`
**When (action):**
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint`
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint with the following header(s):` (table: name | value)
- `I make a "(POST)" request to Gotenberg at the "<endpoint>" endpoint with the following form data and header(s):` (table: name | value | kind — where kind is `file`, `field`, or `header`)
- `I make <N> concurrent "(POST)" requests to Gotenberg at the "<endpoint>" endpoint with the following form data and header(s):` (same table format)
- `I wait for the asynchronous request to the webhook`
**Then (assertions):**
- `the response status code should be <code>`
- `the (response|webhook request) header "<name>" should be "<value>"`
- `the (response|webhook request) cookie "<name>" should be "<value>"`
- `the (response|webhook request) body should match string:` (docstring)
- `the (response|webhook request) body should contain string:` (docstring)
- `the (response|webhook request) body should match JSON:` (docstring — use `"ignore"` for dynamic values like timestamps)
- `there should be <N> PDF(s) in the (response|webhook request)`
- `there should be the following file(s) in the (response|webhook request):` (table of filenames)
- `the "<name>" PDF should have <N> page(s)`
- `the "<name>" PDF (should|should NOT) be set to landscape orientation`
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
- `the (response|webhook request) PDF(s) (should|should NOT) be flatten`
- `the (response|webhook request) PDF(s) (should|should NOT) be encrypted`
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
- `all concurrent response status codes should be <code>`
- `all concurrent responses should have <N> PDF(s)`
---
## Review Checklist
### Backward Compatibility
- [ ] No existing CLI flags renamed or removed
- [ ] No existing environment variables renamed or removed
- [ ] No existing API form fields renamed or removed
- [ ] No existing HTTP endpoints changed or removed
- [ ] No changes to default values that alter existing behavior
If any of these are violated, the change **must** be flagged as a breaking change.
### Linting Standards
The `.golangci.yml` enforces strict rules including: `gosec`, `govet`, `errcheck`, `staticcheck`, `dupl`, `bodyclose`, `exhaustive`, `errname`, and more. Zero linting errors are permitted.
Formatters enforce `gci`, `gofmt`, `gofumpt`, `goimports` with import ordering:
1. Standard library
2. Third-party packages
3. `github.com/gotenberg/gotenberg/v8`
Three groups separated by blank lines.
### Code Quality
- Errors are wrapped with context: `fmt.Errorf("description: %w", err)`. No swallowed errors.
- No business logic in `cmd/`.
- No panics in production code paths.
- Input is validated defensively.
- New features belong in the correct module (or justify a new one).
### Documentation
- Every exported function, type, constant, and variable has a GoDoc comment starting with its name.
- New packages include a `doc.go` file.
- `README.md` is not modified unless explicitly requested.
### Definition of Done
A change is ready to merge only when:
1. Code compiles: `go build ./...`
2. Code is formatted: `make fmt`
3. All linters pass: `make lint` and `make lint-prettier`
4. Integration tests pass: `make test-integration` (at minimum, the relevant `TAGS`)
5. Unit tests pass: `make test-unit`
6. All exported symbols and new packages have compliant GoDoc
7. Bruno collection is updated (if routes were added or modified)
---
## Bruno API Collection
A [Bruno](https://www.usebruno.com/) collection lives in `.bruno/` and mirrors every Gotenberg route. When adding or updating a route, update the collection to match.
### Structure
```
.bruno/
├── bruno.json # Collection config
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
├── environments/
│ ├── Local.bru # baseUrl: http://localhost:3000
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
├── Health & Info/ # GET routes
├── Chromium/Convert/ # POST routes grouped by module
├── Chromium/Screenshot/
├── LibreOffice/
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, …)
```
### `.bru` file format
```bru
meta {
name: <Human-readable name>
type: http
seq: <order within folder>
}
post {
url: {{baseUrl}}/forms/<path>
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/<file>)
<mandatoryField>: <value>
~<optionalField>: <value>
}
headers {
~Gotenberg-Output-Filename: <name>
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}
```
### Conventions
- **Mandatory fields** are listed without prefix; **optional fields** are prefixed with `~` (disabled by default in Bruno).
- **File references** use relative paths to `test/integration/testdata/`.
- **Webhook and output filename headers** are included on every POST route as optional (`~`).
- **One `.bru` file per request**. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
### Checklist when adding/updating a route
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
2. Include all form fields from the route handler — check `FormData*` calls in the route function.
3. For file upload fields (`files`, `watermark`, `stamp`, `embeds`), use `@file(...)` with a suitable test file.
4. Verify the URL path matches the route's `Path` field exactly.
5. If you add a new module folder, keep the naming consistent (e.g., `PDF Engines/Rotate/`).

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CONTRIBUTING.md

View File

@@ -1,3 +0,0 @@
# Claude Code — Gotenberg
Read [AGENTS.md](AGENTS.md) first. It contains everything: core principles, project layout, coding standards, the mandatory 4-step workflow (Plan → Implement → Test → Review), integration test reference, review checklist, and Bruno collection guidelines.

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
CONTRIBUTING.md

View File

@@ -1,41 +1,59 @@
# Contributing to Gotenberg
Thank you for your interest in contributing to Gotenberg! This guide will help you get started.
Gotenberg is a Docker-based API for converting documents to PDF. Two rules override everything else:
## Before You Start
- **Backward compatibility.** Never rename or remove CLI flags, environment variables, API form fields, or HTTP endpoints without discussion.
- **Defensive programming.** Assume input is malformed, handle errors explicitly, never panic.
Please read the [AGENTS.md](AGENTS.md) file — it describes the core principles, project layout, development standards, integration test reference, review checklist, and Bruno collection guidelines that all contributions must follow.
## Toolchain
## Getting Started
### Prerequisites
- Go (see version in `go.mod`)
- Module: `github.com/gotenberg/gotenberg/v8`
- Go: see version in `go.mod`
- Docker
- Node.js (see version in `.node-version`) for Prettier linting
- Node.js (see `.node-version`), for Prettier linting
- [golangci-lint](https://golangci-lint.run/) v2+
### Build and Run
## Before you start
```bash
make build # Build the Docker image
make run # Run a local Gotenberg container
For non-trivial changes, open an issue or a draft PR first. Describe what needs to change, the proposed solution (files to modify, interface changes, form fields), and which integration test tags are affected.
One thing per PR. Keep features, bug fixes, and refactoring in separate PRs.
When adding a feature or route, write the Gherkin scenario before the Go code, and plan to update the Bruno collection (`.bruno/`) if a route changes.
## Project layout
```
cmd/gotenberg/ -> Entry point only (wiring/startup). No business logic.
pkg/gotenberg/ -> Core module system, interfaces, utilities, mocks.
pkg/modules/ -> Feature modules (api, chromium, libreoffice, pdfengines, etc.).
pkg/standard/ -> Wires all standard modules together via imports.
test/integration/ -> Gherkin feature files + Go test infrastructure.
build/ -> Dockerfile, fonts, Chromium config.
.bruno/ -> Bruno API collection (mirrors every route).
```
### Development Loop
Key interfaces live in `pkg/gotenberg/`: `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers via `init()`.
```bash
# Write your code, then:
make fmt # Format Go code
make prettify # Format non-Go files (Markdown, YAML, etc.)
make lint # Lint Go code (zero errors permitted)
make lint-prettier # Lint non-Go files
make test-unit # Run unit tests
make build # Build the Docker image (required before integration tests)
make test-integration # Run all integration tests
```
## Setup and Makefile
To run only the integration tests relevant to your change:
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
| Command | Purpose | When to use |
| ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------------ |
| `make build` | Build the Gotenberg Docker image | Before integration tests or manual testing |
| `make run` | Run a Gotenberg container via `docker compose` | Manual testing. Flags configured via Makefile variables and compose.yaml |
| `make telemetry` | Start an OpenTelemetry collector and OpenObserve | When testing telemetry locally |
| `make down` | Stop all compose containers | After manual testing |
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
| `make fmt` | Format Go code | Before committing |
| `make lint` | Lint Go code (zero errors permitted) | Before committing |
| `make prettify` | Format non-Go files (Markdown, YAML, JSON) | Before committing |
| `make lint-prettier` | Lint non-Go files | Before committing |
| `make test-unit` | Run unit tests | Before committing |
| `make test-integration` | Run all integration tests (40 min timeout) | Before committing |
Run only the integration test tag(s) relevant to your change rather than the full suite:
```bash
make test-integration TAGS=health
@@ -43,21 +61,118 @@ make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
```
## Submitting a Pull Request
## Code conventions
Before opening a PR, verify:
### Module system
1. Code compiles: `make build`
2. Code is formatted: `make fmt` and `make prettify`
3. All linters pass: `make lint` and `make lint-prettier`
4. Integration tests pass: `make test-integration` (at minimum, the relevant tags)
5. Unit tests pass: `make test-unit`
6. All exported symbols and new packages have GoDoc comments
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module lives in `pkg/modules/<name>/`, implements at minimum `gotenberg.Module` (`Descriptor()`), and self-registers via `init()`. Wiring happens through `pkg/standard/`.
### Guidelines
Determine if a feature belongs in an existing module before creating a new one. Only create a new module for a genuinely separate concern.
- **Conventional Commits.** Commit messages must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification (e.g., `feat(chromium): add screenshot endpoint`, `fix(api): handle empty body`).
- **One thing per PR.** Keep features, bug fixes, and refactoring in separate PRs.
- **Backward compatibility matters.** Do not rename or remove existing CLI flags, environment variables, or API form fields without discussion.
- **Integration tests first.** When adding a feature or route, start by writing the Gherkin scenario in `test/integration/features/`.
- **No business logic in `cmd/`.** All logic belongs in `pkg/`.
The `cmd/gotenberg/` package is strictly for wiring and startup. No business logic.
### Backward compatibility
CLI flags, environment variables, API form fields, HTTP endpoints, and default values that alter existing behavior must not change without discussion. Deprecate old names with `fs.MarkDeprecated()` and register both the old and new names side by side.
If a change violates backward compatibility, flag it as a breaking change in the PR description.
### Error handling
- Wrap every error with context: `fmt.Errorf("description: %w", err)`.
- Never swallow errors silently.
- Match errors with `errors.Is`, never `strings.Contains`.
- No panics in production code paths.
- Validate input defensively.
### Logging
Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
### Telemetry
External tool calls (Chromium, LibreOffice, PDF engines, webhooks, downloads) must create OTEL spans with `trace.SpanKindClient` and `semconv.ServerAddress("toolname")`. Use `gotenberg.Tracer()` and `gotenberg.Meter()` for traces and metrics.
### Import ordering
Enforced by `gci`: standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
## Documentation conventions
### Tone
- Short, declarative sentences. Say what it does, then stop.
- Lead with the action. "Validates font embedding", not "This function validates font embedding".
- Active voice. "Gotenberg checks the profile", not "The profile is checked by Gotenberg".
- No em dashes. Use a period, colon, or comma.
- No "we" hedging. "Don't...", not "We do not recommend...".
### Godoc
Every exported type and function has a Godoc comment starting with its identifier name:
```go
// Violation records a single rule violation with context.
type Violation struct { ... }
// ValidatePDFA audits the document against a PDF/A profile.
func ValidatePDFA(ctx context.Context, ...) ([]error, error)
```
Each package should have a `doc.go` with a `// Package foo ...` comment.
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
```go
// ValidatePDFA returns violations as []error where each element
// is a [Violation] value. See [Rule] for the structured fields.
```
### Code comments
- Explain _why_, not _what_.
- No numbered step comments (`// 1. Do X`, `// 2. Do Y`).
- No section dividers with numbers (`// --- 8. Foo ---`). Plain dividers are fine for major boundaries.
- No noise comments that restate the code (`// Check if err is nil`, `// Return results`).
- Reference spec clauses where relevant (`// Per ISO 32000-2, Table 116...`).
- Mark debt with `// TODO: [context]`.
## Testing
### Unit tests
Table-driven tests in `*_test.go` files. Use the comprehensive mock implementations in `pkg/gotenberg/mocks.go` rather than rolling new ones.
### Integration tests
Gherkin (BDD) via Godog with `testcontainers-go` for Docker orchestration. Feature files live in `test/integration/features/`; step definitions live in `test/integration/scenario/`. Read `scenario.go` and `containers.go` before writing new tests.
`make build` is required before running integration tests. The full suite has a 40-minute timeout, so run only the tag(s) relevant to your change.
## Pull requests
### Commits
[Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <description>`.
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope matches the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
Stage specific files. Never `git add -A` or `git add .`.
### Checklist
Before opening the PR, confirm:
- [ ] No backward-compatibility regression. See [Backward compatibility](#backward-compatibility).
- [ ] Code conventions met (error wrapping, logging, telemetry, import ordering, no panics, no business logic in `cmd/`). See [Code conventions](#code-conventions).
- [ ] Documentation conventions met (Godoc on every exported identifier, `doc.go` for new packages, tone). See [Documentation conventions](#documentation-conventions).
- [ ] `make fmt && make lint && make prettify && make lint-prettier` pass with zero warnings.
- [ ] `make test-unit` passes.
- [ ] Relevant `make test-integration TAGS=...` passes.
- [ ] Bruno collection updated if routes were added or modified.
## Further reading
- [`test/integration/README.md`](test/integration/README.md) — Gherkin step reference, available tags, writing new tests.
- [`.bruno/README.md`](.bruno/README.md) — `.bru` file format, conventions, route update checklist.
- [`pkg/modules/pdfengines/README.md`](pkg/modules/pdfengines/README.md) — adding new engine features (Makefile variable and flag).

View File

@@ -1,3 +0,0 @@
# Gemini — Gotenberg
Read [AGENTS.md](AGENTS.md) first. It contains everything: core principles, project layout, coding standards, the mandatory 4-step workflow (Plan → Implement → Test → Review), integration test reference, review checklist, and Bruno collection guidelines.

1
GEMINI.md Symbolic link
View File

@@ -0,0 +1 @@
CONTRIBUTING.md

143
Makefile
View File

@@ -5,8 +5,9 @@ help: ## Show the help
@grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: build
build: ## Build the Gotenberg's Docker image
build: ## Build the Gotenberg's Docker image (use TARGET=gotenberg-chromium or TARGET=gotenberg-libreoffice for variants)
docker build \
--target $(TARGET) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
@@ -20,19 +21,23 @@ API_BIND_IP=
API_START_TIMEOUT=30s
API_TIMEOUT=30s
API_BODY_LIMIT=
API_ROOT_PATH="/"
API_TRACE_HEADER=Gotenberg-Trace
API_ROOT_PATH=/
API_CORRELATION_ID_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD=
API-DOWNLOAD-FROM-ALLOW-LIST=
API-DOWNLOAD-FROM-DENY-LIST=
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false
API_DOWNLOAD_FROM_ALLOW_LIST=
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
API_DOWNLOAD_FROM_MAX_RETRY=4
API_DISABLE_DOWNLOAD_FROM=false
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
API_DISABLE_ROOT_ROUTE_TELEMETRY=true
API_DISABLE_DEBUG_ROUTE_TELEMETRY=true
API_DISABLE_VERSION_ROUTE_TELEMETRY=true
API_ENABLE_DEBUG_ROUTE=false
CHROMIUM_RESTART_AFTER=100
CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_IDLE_SHUTDOWN_TIMEOUT=0
CHROMIUM_MAX_CONCURRENCY=6
CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s
@@ -50,13 +55,16 @@ CHROMIUM_DISABLE_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_RESTART_AFTER=10
LIBREOFFICE_MAX_QUEUE_SIZE=0
LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT=0
LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_ALLOW_LIST=
LIBREOFFICE_DENY_LIST=
LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info
LOG_FORMAT=auto
LOG_FIELDS_PREFIX=
LOG_ENABLE_GCP_FIELDS=false
LOG_STD_FORMAT=auto
LOG_STD_ENABLE_GCP_FIELDS=false
PDFENGINES_DISABLE_ROUTES=false
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
@@ -70,105 +78,43 @@ PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
PDFENGINES_EMBED_ENGINES=pdfcpu
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
PROMETHEUS_DISABLE_COLLECT=false
PROMETHEUS_METRICS_PATH=/prometheus/metrics
OTEL_SERVICE_NAME=gotenberg
OTEL_TRACES_EXPORTER=none
OTEL_METRICS_EXPORTER=none
OTEL_LOGS_EXPORTER=none
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_INSECURE=true
WEBHOOK_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST=
WEBHOOK_ERROR_ALLOW_LIST=
WEBHOOK_ERROR_DENY_LIST=
WEBHOOK_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
WEBHOOK_MAX_RETRY=4
WEBHOOK_RETRY_MIN_WAIT=1s
WEBHOOK_RETRY_MAX_WAIT=30s
WEBHOOK_CLIENT_TIMEOUT=30s
WEBHOOK_DISABLE=false
# Export all variables so they are available to Compose
export
.PHONY: run
run: ## Start a Gotenberg container
docker run --rm -it \
-p $(API_PORT):$(API_PORT) \
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
-e TZ=$(TZ) \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
gotenberg \
--gotenberg-hide-banner=$(GOTENBERG_HIDE_BANNER) \
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--gotenberg-build-debug-data="$(GOTENBERG_BUILD_DEBUG_DATA)" \
--api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \
--api-bind-ip=$(API_BIND_IP) \
--api-start-timeout=$(API_START_TIMEOUT) \
--api-timeout=$(API_TIMEOUT) \
--api-body-limit="$(API_BODY_LIMIT)" \
--api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
--api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \
--api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--api-enable-debug-route=$(API_ENABLE_DEBUG_ROUTE) \
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
--chromium-max-concurrency=$(CHROMIUM_MAX_CONCURRENCY) \
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
--chromium-disable-web-security=$(CHROMIUM_DISABLE_WEB_SECURITY) \
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
--chromium-allow-list="$(CHROMIUM_ALLOW_LIST)" \
--chromium-deny-list="$(CHROMIUM_DENY_LIST)" \
--chromium-clear-cache=$(CHROMIUM_CLEAR_CACHE) \
--chromium-clear-cookies=$(CHROMIUM_CLEAR_COOKIES) \
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
--libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \
--libreoffice-max-queue-size=$(LIBREOFFICE_MAX_QUEUE_SIZE) \
--libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \
--libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \
--log-level=$(LOG_LEVEL) \
--log-format=$(LOG_FORMAT) \
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--log-enable-gcp-fields=$(LOG_ENABLE_GCP_FIELDS) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
--pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \
--pdfengines-flatten-engines=$(PDFENGINES_FLATTEN_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
--pdfengines-read-bookmarks-engines=$(PDFENGINES_READ_BOOKMARKS_ENGINES) \
--pdfengines-write-bookmarks-engines=$(PDFENGINES_WRITE_BOOKMARKS_ENGINES) \
--pdfengines-watermark-engines=$(PDFENGINES_WATERMARK_ENGINES) \
--pdfengines-stamp-engines=$(PDFENGINES_STAMP_ENGINES) \
--pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \
--pdfengines-rotate-engines=$(PDFENGINES_ROTATE_ENGINES) \
--pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
--prometheus-metrics-path=$(PROMETHEUS_METRICS_PATH) \
--webhook-enable-sync-mode="$(WEBHOOK_ENABLE_SYNC_MODE)" \
--webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
--webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
--webhook-retry-min-wait=$(WEBHOOK_RETRY_MIN_WAIT) \
--webhook-retry-max-wait=$(WEBHOOK_RETRY_MAX_WAIT) \
--webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \
--webhook-disable=$(WEBHOOK_DISABLE)
run: ## Start a Gotenberg container via Compose
docker compose up gotenberg
.PHONY: telemetry
telemetry: ## Start an OpenTelemetry collector and OpenObserve containers via Compose
docker compose up otel-collector openobserve
.PHONY: down
down: ## Stop all containers
docker compose down -v
.PHONY: test-unit
test-unit: ## Run unit tests
@@ -182,6 +128,9 @@ NO_CONCURRENCY=false
# chromium-convert-html
# chromium-convert-markdown
# chromium-convert-url
# chromium-screenshot-html
# chromium-screenshot-markdown
# chromium-screenshot-url
# debug
# health
# libreoffice
@@ -217,7 +166,7 @@ NO_CONCURRENCY=false
TAGS=
.PHONY: test-integration
test-integration: ## Run integration tests
test-integration: ## Run integration tests (automatically retries failed scenarios up to 3 times)
go test -timeout 40m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \
--gotenberg-docker-repository=$(DOCKER_REPOSITORY) \
--gotenberg-version=$(GOTENBERG_VERSION) \

View File

@@ -1,7 +1,7 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3>
<p align="center">A containerized API for seamless PDF conversion</p>
<p align="center">A Docker-based API for converting documents to PDF</p>
<p align="center">
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml/badge.svg"></a>
@@ -10,38 +10,60 @@
<p align="center">
<a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Read the Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Try the Live Demo</a> 🔥</p>
<p align="center">
<a href="https://gotenberg.dev/docs/getting-started/introduction"><strong>Documentation</strong></a> &#183;
<a href="https://gotenberg.dev/docs/getting-started/installation#live-demo"><strong>Live Demo</strong></a> &#183;
<a href="https://github.com/gotenberg/gotenberg/discussions"><strong>Community</strong></a>
</p>
</p>
---
**Gotenberg** is a containerized API that abstracts the complexity of PDF conversion.
**Gotenberg** is a Docker-based API for converting documents to PDF. Trusted in production by thousands of companies. Also adopted by notable open-source projects.
It provides a `multipart/form-data` interface for interacting with powerful engines like Chromium and LibreOffice.
Instead of managing heavy dependencies, browser versions, or fonts in your own backend, simply send your files to
Gotenberg and get a PDF in return.
Send your files via `multipart/form-data`, get a PDF back. No need to manage Chromium, LibreOffice, or fonts yourself.
## Quick Start
Open a terminal and run the following command:
```bash
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
```
With the API running at `http://localhost:3000`, you are now ready to head
to the **[Full Documentation](https://gotenberg.dev/docs/getting-started/introduction)** to discover how to convert URLs,
local files, inject custom CSS, merge PDFs, and more.
Convert a URL to PDF:
```bash
curl \
--request POST http://localhost:3000/forms/chromium/convert/url \
--form url=https://sparksuite.github.io/simple-html-invoice-template/ \
-o invoice.pdf
```
## Features
- **HTML, URL, Markdown to PDF** via Headless Chromium
- **Office documents to PDF** via LibreOffice (100+ formats)
- **Merge, split, rotate, flatten** PDFs
- **Watermark, stamp, encrypt** PDFs
- **PDF/A and PDF/UA** compliance
- **Screenshots** of URLs and HTML
- **Read/write metadata and bookmarks**
See the [full documentation](https://gotenberg.dev/docs/getting-started/introduction).
## Contributing
Questions and feedback: [GitHub Discussions](https://github.com/gotenberg/gotenberg/discussions).
Bug reports: [GitHub Issues](https://github.com/gotenberg/gotenberg/issues).
## Sponsors
Open-source development takes a significant amount of time, energy, and dedication. If Gotenberg helps streamline your
workflow or powers your business, please consider supporting its continuous improvement by [**becoming a sponsor**](https://github.com/sponsors/gulien)! ❤️
If Gotenberg powers your workflow or your business, consider [**becoming a sponsor**](https://github.com/sponsors/gulien).
**Historic & GitHub Sponsors**
- [TheCodingMachine](https://thecodingmachine.com/)
- [pdfme](https://pdfme.com/)
- [PDFBolt](https://pdfbolt.com)
**Powered By**

View File

@@ -2,40 +2,28 @@
## Supported Versions
Please ensure to keep your environment up to date and use only the latest version of Gotenberg.
Security updates and patches will be applied only to the most recent version.
Only the latest version receives security updates and patches. Keep your environment up to date.
## Reporting a Vulnerability
Your help in identifying vulnerabilities in our project is much appreciated.
We take all reports regarding security seriously.
Do not publish vulnerabilities publicly. Report them through [GitHub's private vulnerability reporting](https://github.com/gotenberg/gotenberg/security/advisories/new).
If you discover a security vulnerability, please refrain from publishing it publicly.
Instead, kindly send us the details via email to _neuhart [dot] julien [at] gmail [dot] com_.
In the subject of your email, please indicate that it's a security vulnerability report for Gotenberg.
In your message, please include:
Include:
- A detailed description of the vulnerability.
- The steps to reproduce the issue.
- Any potential impact of the vulnerability on the users or system.
- Steps to reproduce the issue.
- Potential impact on users or the system.
Please remember that this process is done in a _'best-effort'_ manner.
This means we strive to respond and act as quickly as possible, but the speed may vary depending on the severity of
the issue and our resources.
Thank you in advance for helping to keep our project safe!
This process is handled on a best-effort basis. Response speed may vary depending on severity and available resources.
## Disclosure Policy
Once we have received your vulnerability report, we will work to validate and reproduce the issue.
If we can confirm the vulnerability, we will proceed to:
Once a report is received and confirmed:
- Work on a fix and a release timeline.
- Notify you when the fix has been implemented and released.
- Credit you for discovering the vulnerability (unless you request anonymity).
- Please note that we will do our best to keep you informed about the progress towards resolving the issue.
- A fix and release timeline will be prepared.
- The reporter will be notified when the fix is released.
- The reporter will be credited for the discovery (unless anonymity is requested).
## Comments on this Policy
If you have suggestions on how this process could be improved, please submit a pull request.
Submit a pull request with suggestions for improving this process.

View File

@@ -1,7 +1,7 @@
# ARG instructions do not create additional layers. Instead, next layers will
# concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them.
ARG GOLANG_VERSION=1.26.0
ARG GOLANG_VERSION=1.26.2
# ----------------------------------------------
# pdfcpu binary build stage
@@ -11,22 +11,22 @@ ARG GOLANG_VERSION=1.26.0
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
# See https://github.com/pdfcpu/pdfcpu/releases.
ARG PDFCPU_VERSION=v0.11.1
ARG PDFCPU_VERSION=v0.12.0
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
tar --strip-components=1 -xvzf pdfcpu.tar.gz
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz \
&& tar --strip-components=1 -xvzf pdfcpu.tar.gz
# Install module dependencies.
RUN go mod download &&\
go mod verify
RUN go mod download \
&& go mod verify
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu &&\
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu \
# Verify installation.
./pdfcpu version
&& ./pdfcpu version
# ----------------------------------------------
# Gotenberg binary build stage
@@ -42,14 +42,16 @@ WORKDIR /home
# Install module dependencies.
COPY go.mod go.sum ./
RUN go mod download &&\
go mod verify
RUN go mod download \
&& go mod verify
# Copy the source code.
COPY cmd ./cmd
COPY pkg ./pkg
RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go \
&& go build -o gotenberg-chromium -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg-chromium/main.go \
&& go build -o gotenberg-libreoffice -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg-libreoffice/main.go
# ----------------------------------------------
# Custom JRE stage
@@ -57,10 +59,9 @@ RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/
# ----------------------------------------------
FROM debian:13-slim AS custom-jre-stage
RUN \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
RUN apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
# Note: jdeps helps finding which modules a JAR requires.
# Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK.
@@ -74,6 +75,25 @@ RUN jlink \
--compress=2 \
--output /custom-jre
# ----------------------------------------------
# Downloader stage
# ----------------------------------------------
FROM debian:13-slim AS downloader-stage
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
ARG PDFTK_VERSION=v3.3.3
RUN apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
WORKDIR /downloads
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o 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" \
&& chmod a+x pdftk-all.jar
# ----------------------------------------------
# Base image stage
# ----------------------------------------------
@@ -82,125 +102,79 @@ FROM debian:13-slim AS base-image-stage
ARG TIMEZONE=UTC
ENV TZ=$TIMEZONE
COPY --from=custom-jre-stage /custom-jre /opt/java
COPY --link --from=custom-jre-stage /custom-jre /opt/java
ENV PATH="/opt/java/bin:${PATH}"
# ----------------------------------------------
# Final stage
# Common stage
# ----------------------------------------------
FROM base-image-stage
FROM base-image-stage AS common-stage
ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001
# See https://github.com/googlefonts/noto-emoji/releases.
ARG NOTO_COLOR_EMOJI_VERSION=v2.051
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
ARG PDFTK_VERSION=v3.3.3
LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A containerized API for seamless PDF conversion." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Create a non-root user.
# All processes in the Docker container will run with this dedicated user.
RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
&& useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg \
&& mkdir /home/gotenberg \
&& chown gotenberg: /home/gotenberg
RUN \
# Create a non-root user.
# All processes in the Docker container will run with this dedicated user.
groupadd --gid "$GOTENBERG_USER_GID" gotenberg &&\
useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg &&\
mkdir /home/gotenberg &&\
chown gotenberg: /home/gotenberg
RUN \
# Install system dependencies required for the next instructions or debugging.
# Note: tini is a helper for reaping zombie processes.
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 python3-distutils-extra &&\
# Note: tini is a helper for reaping zombie processes.
RUN apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
tini \
# Many users rely on curl for Docker health checks.
curl \
# Install fonts.
# Essential metric-compatible fonts for LibreOffice layout fidelity (replaces MS Fonts).
fonts-crosextra-carlito \
fonts-crosextra-caladea \
fonts-liberation \
fonts-liberation2 \
# Reliable general-purpose fallback for Chromium.
fonts-dejavu \
# Unified CJK (Chinese, Japanese, Korean) support.
fonts-noto-cjk \
# Standard Emoji support.
fonts-noto-color-emoji \
# Tofu prevention.
fonts-noto-core \
# Install QPDF & ExifTool (PDF engines).
qpdf exiftool \
# See https://github.com/nextcloud/docker/issues/380.
&& mkdir -p /usr/share/man/man1 \
# Cleanup.
# Note: the Debian image does automatically a clean after each install thanks to a hook.
# Therefore, there is no need for apt-get clean.
# See https://stackoverflow.com/a/24417119/3248473.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install fonts.
# Credits:
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile.
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html.
curl -o ./ttf-mscorefonts-installer_3.8.1_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8.1_all.deb &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
./ttf-mscorefonts-installer_3.8.1_all.deb \
culmus \
fonts-beng \
fonts-hosny-amiri \
fonts-lklug-sinhala \
fonts-lohit-guru \
fonts-lohit-knda \
fonts-samyak-gujr \
fonts-samyak-mlym \
fonts-samyak-taml \
fonts-sarai \
fonts-sil-abyssinica \
fonts-sil-padauk \
fonts-telu \
fonts-thai-tlwg \
ttf-wqy-zenhei \
fonts-arphic-ukai \
fonts-arphic-uming \
fonts-ipafont-mincho \
fonts-ipafont-gothic \
fonts-unfonts-core \
# LibreOffice recommends.
fonts-crosextra-caladea \
fonts-crosextra-carlito \
fonts-dejavu \
fonts-liberation \
fonts-liberation2 \
fonts-linuxlibertine \
fonts-noto-cjk \
fonts-noto-core \
fonts-noto-mono \
fonts-noto-ui-core \
fonts-sil-gentium \
fonts-sil-gentium-basic &&\
rm -f ./ttf-mscorefonts-installer_3.8.1_all.deb &&\
# Add Color and Black-and-White Noto emoji font.
# Credits:
# https://github.com/gotenberg/gotenberg/pull/325.
# https://github.com/googlefonts/noto-emoji.
curl -Ls "https://github.com/googlefonts/noto-emoji/raw/$NOTO_COLOR_EMOJI_VERSION/fonts/NotoColorEmoji.ttf" -o /usr/local/share/fonts/NotoColorEmoji.ttf &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# PDFtk bash wrapper.
# See https://github.com/gotenberg/gotenberg/pull/273.
RUN printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk \
&& chmod +x /usr/bin/pdftk
RUN \
# Install Hyphenation for LibreOffice.
# Credits: https://wiki.archlinux.org/title/LibreOffice.
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
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 &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Support for arbitrary user IDs (OpenShift).
# See:
# https://github.com/gotenberg/gotenberg/issues/1049.
# https://docs.redhat.com/en/documentation/openshift_container_platform/4.15/html/images/creating-images#use-uid_create-images.
RUN usermod -aG root gotenberg \
&& chgrp -R 0 /home/gotenberg \
&& chmod -R g=u /home/gotenberg
RUN \
# Install Chromium.
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium &&\
# Verify installation.
chromium --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# COPY instructions last to maximize cache reuse when only source code changes.
# Improve fonts subpixel hinting and smoothing.
# Credits:
# https://github.com/arachnys/athenapdf/issues/69.
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY --link build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Copy PDFtk jar.
COPY --link --from=downloader-stage /downloads/pdftk-all.jar /usr/bin/pdftk-all.jar
# Copy the pdfcpu binary (shared across all variants).
COPY --link --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
# Set default characterset encoding to UTF-8.
# See:
@@ -209,74 +183,91 @@ RUN \
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
RUN \
# Install LibreOffice & unoconverter. \
echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports libreoffice &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o /usr/bin/unoconverter &&\
chmod +x /usr/bin/unoconverter &&\
# unoconverter will look for the Python binary, which has to be at version 3.
ln -s /usr/bin/python3 /usr/bin/python &&\
# Verify installations.
libreoffice --version &&\
unoconverter --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install PDFtk, QPDF & ExifTool (PDF engines).
# See https://github.com/gotenberg/gotenberg/pull/273.
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
chmod a+x /usr/bin/pdftk-all.jar &&\
printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
chmod +x /usr/bin/pdftk &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
# See https://github.com/nextcloud/docker/issues/380.
mkdir -p /usr/share/man/man1 &&\
# Verify installations.
pdftk --version &&\
qpdf --version &&\
exiftool --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Support for arbitrary user IDs (OpenShift).
# See:
# https://github.com/gotenberg/gotenberg/issues/1049.
# https://docs.redhat.com/en/documentation/openshift_container_platform/4.15/html/images/creating-images#use-uid_create-images.
RUN \
usermod -aG root gotenberg &&\
chgrp -R 0 /home/gotenberg &&\
chmod -R g=u /home/gotenberg
# Improve fonts subpixel hinting and smoothing.
# Credits:
# https://github.com/arachnys/athenapdf/issues/69.
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Copy dictionnaries so that hypens work on Chromium.
# See https://github.com/gotenberg/gotenberg/issues/1293.
COPY --chown=gotenberg:gotenberg build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
# Copy the Golang binaries.
COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
# Environment variables required by modules or else.
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
# Environment variables for PDF engines.
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
ENV QPDF_BIN_PATH=/usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
ENV OTEL_TRACES_EXPORTER=none
ENV OTEL_METRICS_EXPORTER=none
ENV OTEL_LOGS_EXPORTER=none
# ----------------------------------------------
# Final stage: full (Chromium + LibreOffice)
# ----------------------------------------------
FROM common-stage AS gotenberg
ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A Docker-based API for converting documents to PDF." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Install Chromium.
# On ppc64el, the latest Chromium is broken, so we pin a known working
# version from snapshot.debian.org via debsnap.
RUN /bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install LibreOffice & unoconverter.
RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list \
&& apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
python3 python3-distutils-extra \
# Install Hyphenation for LibreOffice.
# Credits: https://wiki.archlinux.org/title/LibreOffice.
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
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 \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
# unoconverter will look for the Python binary, which has to be at version 3.
&& ln -s /usr/bin/python3 /usr/bin/python \
# Cleanup.
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# COPY instructions last to maximize cache reuse.
# Copy the Gotenberg binary (full variant with all modules).
COPY --link --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
# Copy unoconverter.
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
# Copy dictionnaries so that hyphens work on Chromium.
# See https://github.com/gotenberg/gotenberg/issues/1293.
COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
USER gotenberg
WORKDIR /home/gotenberg
@@ -285,3 +276,234 @@ EXPOSE 3000
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "gotenberg" ]
# ----------------------------------------------
# Final stage: Chromium only
# ----------------------------------------------
FROM common-stage AS gotenberg-chromium
ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
LABEL org.opencontainers.image.title="Gotenberg (Chromium)" \
org.opencontainers.image.description="A Docker-based API for converting documents to PDF — Chromium variant." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Install Chromium.
# On ppc64el, the latest Chromium is broken, so we pin a known working
# version from snapshot.debian.org via debsnap.
RUN /bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# COPY instructions last to maximize cache reuse.
# Copy the Gotenberg binary (Chromium variant, no LibreOffice modules).
COPY --link --from=gotenberg-binary-stage /home/gotenberg-chromium /usr/bin/gotenberg
# Copy dictionnaries so that hyphens work on Chromium.
# See https://github.com/gotenberg/gotenberg/issues/1293.
COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
# No LibreOffice in this variant; override the default to use all available engines.
ENV PDFENGINES_CONVERT_ENGINES=
USER gotenberg
WORKDIR /home/gotenberg
# Default API port.
EXPOSE 3000
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "gotenberg" ]
# ----------------------------------------------
# Final stage: LibreOffice only
# ----------------------------------------------
FROM common-stage AS gotenberg-libreoffice
ARG GOTENBERG_VERSION=snapshot
LABEL org.opencontainers.image.title="Gotenberg (LibreOffice)" \
org.opencontainers.image.description="A Docker-based API for converting documents to PDF — LibreOffice variant." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Install LibreOffice & unoconverter.
RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list \
&& apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
python3 python3-distutils-extra \
# Install Hyphenation for LibreOffice.
# Credits: https://wiki.archlinux.org/title/LibreOffice.
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
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 \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
# unoconverter will look for the Python binary, which has to be at version 3.
&& ln -s /usr/bin/python3 /usr/bin/python \
# Cleanup.
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# COPY instructions last to maximize cache reuse.
# Copy the Gotenberg binary (LibreOffice variant, no Chromium modules).
COPY --link --from=gotenberg-binary-stage /home/gotenberg-libreoffice /usr/bin/gotenberg
# Copy unoconverter.
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
USER gotenberg
WORKDIR /home/gotenberg
# Default API port.
EXPOSE 3000
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "gotenberg" ]
# ==============================================================
# Cloud Run variants
# ==============================================================
# ----------------------------------------------
# Cloud Run: full (Chromium + LibreOffice)
# ----------------------------------------------
FROM gotenberg AS gotenberg-cloudrun
USER root
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
RUN chown gotenberg: /usr/bin/tini
ENV API_PORT_FROM_ENV=PORT
ENV CHROMIUM_AUTO_START=true
ENV LIBREOFFICE_AUTO_START=true
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
ENV LOG_STD_ENABLE_GCP_FIELDS=true
USER gotenberg
# ----------------------------------------------
# Cloud Run: Chromium only
# ----------------------------------------------
FROM gotenberg-chromium AS gotenberg-cloudrun-chromium
USER root
RUN chown gotenberg: /usr/bin/tini
ENV API_PORT_FROM_ENV=PORT
ENV CHROMIUM_AUTO_START=true
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
ENV LOG_STD_ENABLE_GCP_FIELDS=true
USER gotenberg
# ----------------------------------------------
# Cloud Run: LibreOffice only
# ----------------------------------------------
FROM gotenberg-libreoffice AS gotenberg-cloudrun-libreoffice
USER root
RUN chown gotenberg: /usr/bin/tini
ENV API_PORT_FROM_ENV=PORT
ENV LIBREOFFICE_AUTO_START=true
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
ENV LOG_STD_ENABLE_GCP_FIELDS=true
USER gotenberg
# ==============================================================
# AWS Lambda variants
# ==============================================================
# ----------------------------------------------
# AWS Lambda: full (Chromium + LibreOffice)
# ----------------------------------------------
FROM gotenberg AS gotenberg-aws-lambda
USER root
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=3000
ENV AWS_LWA_READINESS_CHECK_PATH=/health
ENV AWS_LWA_INVOKE_MODE=buffered
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
USER gotenberg
# ----------------------------------------------
# AWS Lambda: Chromium only
# ----------------------------------------------
FROM gotenberg-chromium AS gotenberg-aws-lambda-chromium
USER root
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=3000
ENV AWS_LWA_READINESS_CHECK_PATH=/health
ENV AWS_LWA_INVOKE_MODE=buffered
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
USER gotenberg
# ----------------------------------------------
# AWS Lambda: LibreOffice only
# ----------------------------------------------
FROM gotenberg-libreoffice AS gotenberg-aws-lambda-libreoffice
USER root
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=3000
ENV AWS_LWA_READINESS_CHECK_PATH=/health
ENV AWS_LWA_INVOKE_MODE=buffered
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
USER gotenberg

View File

@@ -1,21 +0,0 @@
ARG DOCKER_REGISTRY
ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
USER root
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
# AWS.
ENV AWS_LWA_PORT=3000
ENV AWS_LWA_READINESS_CHECK_PATH=/health
ENV AWS_LWA_INVOKE_MODE=buffered
# Gotenberg.
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
USER gotenberg

View File

@@ -1,22 +0,0 @@
ARG DOCKER_REGISTRY
ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
USER root
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
RUN chown gotenberg: /usr/bin/tini
# Gotenberg.
ENV API_PORT_FROM_ENV=PORT
ENV CHROMIUM_AUTO_START=true
ENV LIBREOFFICE_AUTO_START=true
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
ENV LOG_ENABLE_GCP_FIELDS=true
USER gotenberg

View File

@@ -0,0 +1,11 @@
package main
import (
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
// Gotenberg modules (Chromium variant — no LibreOffice).
_ "github.com/gotenberg/gotenberg/v8/pkg/standard/chromium"
)
func main() {
gotenbergcmd.Run()
}

View File

@@ -0,0 +1,11 @@
package main
import (
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
// Gotenberg modules (LibreOffice variant — no Chromium).
_ "github.com/gotenberg/gotenberg/v8/pkg/standard/libreoffice"
)
func main() {
gotenbergcmd.Run()
}

View File

@@ -25,7 +25,7 @@ const banner = `
\___/\___/\__/\__/_//_/_.__/\__/_/ \_, /
/___/
A containerized API for seamless PDF conversion.
A Docker-based API for converting documents to PDF.
Version: %s
-------------------------------------------------------
`
@@ -44,6 +44,24 @@ func Run() {
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed")
// Logging & telemetry flags.
fs.String("log-level", gotenberg.InfoLoggingLevel, "Set the log level")
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each log field key")
fs.String("log-std-format", gotenberg.AutoLoggingFormat, "Set the log format for standard output")
fs.Bool("log-std-enable-gcp-fields", false, "Use GCP-compatible field names in log output")
// Deprecated logging flags.
fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format")
fs.Bool("log-enable-gcp-fields", false, "Use GCP-compatible field names")
if err := errors.Join(
fs.MarkDeprecated("log-format", "use --log-std-format instead"),
fs.MarkDeprecated("log-enable-gcp-fields", "use --log-std-enable-gcp-fields instead"),
); err != nil {
fmt.Printf("[FATAL] mark deprecated flags: %s\n", err)
os.Exit(1)
}
descriptors := gotenberg.GetModuleDescriptors()
var modsInfo strings.Builder
for _, desc := range descriptors {
@@ -76,10 +94,11 @@ func Run() {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1)
}
f.Changed = true
return
}
err = f.Value.Set(val)
err = fs.Set(f.Name, val)
if err != nil {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1)
@@ -91,6 +110,35 @@ func Run() {
hideBanner := parsedFlags.MustBool("gotenberg-hide-banner")
gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration")
// Initialize telemetry (logging + OTEL).
serviceName := os.Getenv("OTEL_SERVICE_NAME")
if serviceName == "" {
serviceName = "gotenberg"
}
telemetryCfg := gotenberg.TelemetryConfig{
ServiceName: serviceName,
ServiceVersion: Version,
LogLevel: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
}
// LogLevel uses its own flag, not the format flag.
telemetryCfg.LogLevel = parsedFlags.MustString("log-level")
err = telemetryCfg.Validate()
if err != nil {
fmt.Printf("[FATAL] invalid telemetry config: %s\n", err)
os.Exit(1)
}
shutdownTelemetry, err := gotenberg.StartTelemetry(telemetryCfg)
if err != nil {
fmt.Printf("[FATAL] start telemetry: %s\n", err)
os.Exit(1)
}
if !hideBanner {
fmt.Printf(banner, Version)
}
@@ -190,8 +238,17 @@ func Run() {
err = eg.Wait()
if err != nil {
cancel()
fmt.Printf("[FATAL] %v\n", err)
os.Exit(1)
os.Exit(1) //nolint:gocritic // defers are already called explicitly above
}
// Shutdown telemetry (flush spans, metrics, logs).
err = shutdownTelemetry(gracefulShutdownCtx)
if err != nil {
cancel()
fmt.Printf("[FATAL] %v\n", err)
os.Exit(1) //nolint:gocritic // defers are already called explicitly above
}
os.Exit(0)

122
compose.yaml Normal file
View File

@@ -0,0 +1,122 @@
services:
gotenberg:
image: ${DOCKER_REGISTRY}/${DOCKER_REPOSITORY}:${GOTENBERG_VERSION}
ports:
- "${API_PORT}:${API_PORT}"
environment:
GOTENBERG_API_BASIC_AUTH_USERNAME: ${GOTENBERG_API_BASIC_AUTH_USERNAME}
GOTENBERG_API_BASIC_AUTH_PASSWORD: ${GOTENBERG_API_BASIC_AUTH_PASSWORD}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME}
OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER}
OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER}
OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
OTEL_EXPORTER_OTLP_INSECURE: ${OTEL_EXPORTER_OTLP_INSECURE}
command:
- "gotenberg"
- "--gotenberg-hide-banner=${GOTENBERG_HIDE_BANNER}"
- "--gotenberg-graceful-shutdown-duration=${GOTENBERG_GRACEFUL_SHUTDOWN_DURATION}"
- "--gotenberg-build-debug-data=${GOTENBERG_BUILD_DEBUG_DATA}"
- "--api-port=${API_PORT}"
- "--api-port-from-env=${API_PORT_FROM_ENV}"
- "--api-bind-ip=${API_BIND_IP}"
- "--api-start-timeout=${API_START_TIMEOUT}"
- "--api-timeout=${API_TIMEOUT}"
- "--api-body-limit=${API_BODY_LIMIT}"
- "--api-root-path=${API_ROOT_PATH}"
- "--api-correlation-id-header=${API_CORRELATION_ID_HEADER}"
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}"
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
- "--api-disable-debug-route-telemetry=${API_DISABLE_DEBUG_ROUTE_TELEMETRY}"
- "--api-disable-version-route-telemetry=${API_DISABLE_VERSION_ROUTE_TELEMETRY}"
- "--api-enable-debug-route=${API_ENABLE_DEBUG_ROUTE}"
- "--chromium-restart-after=${CHROMIUM_RESTART_AFTER}"
- "--chromium-auto-start=${CHROMIUM_AUTO_START}"
- "--chromium-max-queue-size=${CHROMIUM_MAX_QUEUE_SIZE}"
- "--chromium-idle-shutdown-timeout=${CHROMIUM_IDLE_SHUTDOWN_TIMEOUT}"
- "--chromium-max-concurrency=${CHROMIUM_MAX_CONCURRENCY}"
- "--chromium-start-timeout=${CHROMIUM_START_TIMEOUT}"
- "--chromium-allow-insecure-localhost=${CHROMIUM_ALLOW_INSECURE_LOCALHOST}"
- "--chromium-ignore-certificate-errors=${CHROMIUM_IGNORE_CERTIFICATE_ERRORS}"
- "--chromium-disable-web-security=${CHROMIUM_DISABLE_WEB_SECURITY}"
- "--chromium-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}"
- "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}"
- "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}"
- "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}"
- "--chromium-deny-list=${CHROMIUM_DENY_LIST}"
- "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}"
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
- "--chromium-disable-routes=${CHROMIUM_DISABLE_ROUTES}"
- "--libreoffice-restart-after=${LIBREOFFICE_RESTART_AFTER}"
- "--libreoffice-max-queue-size=${LIBREOFFICE_MAX_QUEUE_SIZE}"
- "--libreoffice-idle-shutdown-timeout=${LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT}"
- "--libreoffice-auto-start=${LIBREOFFICE_AUTO_START}"
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}"
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
- "--log-level=${LOG_LEVEL}"
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
- "--log-std-format=${LOG_STD_FORMAT}"
- "--log-std-enable-gcp-fields=${LOG_STD_ENABLE_GCP_FIELDS}"
- "--pdfengines-merge-engines=${PDFENGINES_MERGE_ENGINES}"
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
- "--pdfengines-write-bookmarks-engines=${PDFENGINES_WRITE_BOOKMARKS_ENGINES}"
- "--pdfengines-watermark-engines=${PDFENGINES_WATERMARK_ENGINES}"
- "--pdfengines-stamp-engines=${PDFENGINES_STAMP_ENGINES}"
- "--pdfengines-encrypt-engines=${PDFENGINES_ENCRYPT_ENGINES}"
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
- "--prometheus-disable-route-telemetry=${PROMETHEUS_DISABLE_ROUTE_TELEMETRY}"
- "--prometheus-disable-collect=${PROMETHEUS_DISABLE_COLLECT}"
- "--prometheus-metrics-path=${PROMETHEUS_METRICS_PATH}"
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
- "--webhook-client-timeout=${WEBHOOK_CLIENT_TIMEOUT}"
- "--webhook-disable=${WEBHOOK_DISABLE}"
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "4317:4317" # OTLP gRPC receiver
depends_on:
openobserve:
condition: service_started
restart: on-failure
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
restart: always
ports:
- "5080:5080"
- "5081:5081"
environment:
ZO_ROOT_USER_EMAIL: telemetry@gotenberg.dev
ZO_ROOT_USER_PASSWORD: telemetry
networks:
default:
enable_ipv6: false

78
go.mod
View File

@@ -1,33 +1,39 @@
module github.com/gotenberg/gotenberg/v8
go 1.26.0
go 1.26.2
require (
github.com/alexliesenfeld/health v0.8.1
github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.2
github.com/cucumber/godog v0.15.1
github.com/dlclark/regexp2 v1.11.5
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.6.0
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/dlclark/regexp2 v1.12.0
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f
github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/labstack/echo/v4 v4.15.1
github.com/labstack/gommon v0.4.2
github.com/labstack/gommon v0.5.0
github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27
github.com/moby/moby/api v1.54.1
github.com/prometheus/client_golang v1.23.2
github.com/shirou/gopsutil/v4 v4.26.2
github.com/shirou/gopsutil/v4 v4.26.3
github.com/spf13/pflag v1.0.10
github.com/testcontainers/testcontainers-go v0.41.0
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1
golang.org/x/net v0.52.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/log v0.19.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.19.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/net v0.53.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
golang.org/x/term v0.42.0
golang.org/x/text v0.36.0
)
require (
@@ -35,7 +41,7 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
@@ -54,6 +60,7 @@ require (
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
@@ -67,42 +74,42 @@ require (
github.com/gobwas/ws v1.4.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
@@ -110,17 +117,28 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

178
go.sum
View File

@@ -10,12 +10,10 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
@@ -47,8 +45,8 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=
@@ -61,10 +59,8 @@ 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/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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -98,8 +94,10 @@ github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f h1:C5vKBogs/Qf5ID8F8XuRO8SFL+5SH7JMJrAfdLAZ2iA=
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -107,8 +105,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
@@ -132,8 +130,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
@@ -148,18 +146,18 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -172,10 +170,12 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
@@ -184,8 +184,6 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
@@ -198,8 +196,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/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
@@ -210,13 +206,15 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
@@ -231,17 +229,16 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais=
github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
@@ -259,38 +256,64 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM=
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg=
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w=
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o=
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk=
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
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/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -298,22 +321,23 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -324,3 +348,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

View File

@@ -0,0 +1,32 @@
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
processors:
batch:
exporters:
otlp/openobserve:
endpoint: "openobserve:5081"
tls:
insecure: true
headers:
Authorization: "Basic dGVsZW1ldHJ5QGdvdGVuYmVyZy5kZXY6dGVsZW1ldHJ5"
organization: "default"
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/openobserve]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [otlp/openobserve]
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlp/openobserve]

22
package-lock.json generated
View File

@@ -5,7 +5,7 @@
"packages": {
"": {
"devDependencies": {
"prettier": "3.8.1",
"prettier": "3.8.3",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
}
@@ -34,9 +34,9 @@
}
},
"node_modules/@reteps/dockerfmt": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.3.6.tgz",
"integrity": "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==",
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.2.tgz",
"integrity": "sha512-Hbr7yen4fP5TxGM54ucXa4o5NwWXatJ6Bd9I8gp0PValYbI4Rug2Gu+rVv7K7o/efQc3F5ctqWJz47rYaa8zBw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -58,9 +58,9 @@
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
@@ -86,13 +86,13 @@
}
},
"node_modules/prettier-plugin-sh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.0.tgz",
"integrity": "sha512-cW1XL27FOJQ/qGHOW6IHwdCiNWQsAgK+feA8V6+xUTaH0cD3Mh+tFAtBvEEWvuY6hTDzRV943Fzeii+qMOh7nQ==",
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.1.tgz",
"integrity": "sha512-uZmU22wBMevjh3rmCatNQqiEer2+5KLa0xYCBX6zQQUQkcNzVL+s6FbPKK6ZSUNUbQk6jMAcQHrYPvuL2W6ihQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@reteps/dockerfmt": "^0.3.6",
"@reteps/dockerfmt": "^0.5.1",
"sh-syntax": "^0.5.8"
},
"engines": {

View File

@@ -1,6 +1,6 @@
{
"devDependencies": {
"prettier": "3.8.1",
"prettier": "3.8.3",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
}

View File

@@ -6,17 +6,16 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"os/exec"
"strings"
"syscall"
"go.uber.org/zap"
)
// Cmd wraps an [exec.Cmd].
type Cmd struct {
ctx context.Context
logger *zap.Logger
logger *slog.Logger
process *exec.Cmd
}
@@ -25,13 +24,13 @@ type Cmd struct {
// children without creating orphans.
//
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
func Command(logger *zap.Logger, binPath string, args ...string) *Cmd {
func Command(logger *slog.Logger, binPath string, args ...string) *Cmd {
cmd := exec.Command(binPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return &Cmd{
ctx: nil,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
logger: logger.With(slog.String("logger", strings.ReplaceAll(binPath, "/", ""))),
process: cmd,
}
}
@@ -41,7 +40,7 @@ func Command(logger *zap.Logger, binPath string, args ...string) *Cmd {
// children without creating orphans.
//
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, args ...string) (*Cmd, error) {
func CommandContext(ctx context.Context, logger *slog.Logger, binPath string, args ...string) (*Cmd, error) {
if ctx == nil {
return nil, errors.New("nil context")
}
@@ -51,11 +50,18 @@ func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, arg
return &Cmd{
ctx: ctx,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
logger: logger.With(slog.String("logger", strings.ReplaceAll(binPath, "/", ""))),
process: cmd,
}, nil
}
// SetEnv replaces the environment variables passed to the underlying
// process. When SetEnv is not called, the process inherits the parent's
// environment.
func (cmd *Cmd) SetEnv(env []string) {
cmd.process.Env = env
}
// Start starts the command but does not wait for its completion.
func (cmd *Cmd) Start() error {
err := cmd.pipeOutput()
@@ -63,7 +69,7 @@ func (cmd *Cmd) Start() error {
return fmt.Errorf("pipe unix process output: %w", err)
}
cmd.logger.Debug(fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " ")))
cmd.logger.DebugContext(context.Background(), fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " ")))
err = cmd.process.Start()
if err != nil {
@@ -110,7 +116,7 @@ func (cmd *Cmd) Exec() (int, error) {
case err = <-errChan:
errProc := cmd.Kill()
if errProc != nil {
cmd.logger.Error(errProc.Error())
cmd.logger.ErrorContext(context.Background(), errProc.Error())
}
if err == nil {
@@ -125,7 +131,7 @@ func (cmd *Cmd) Exec() (int, error) {
case <-cmd.ctx.Done():
errProc := cmd.Kill()
if errProc != nil {
cmd.logger.Error(errProc.Error())
cmd.logger.ErrorContext(context.Background(), errProc.Error())
}
return 62, fmt.Errorf("context done: %w", cmd.ctx.Err())
@@ -135,8 +141,7 @@ func (cmd *Cmd) Exec() (int, error) {
// pipeOutput creates logs entries according to the process stdout and stderr.
// It does nothing if the logging level is not debug.
func (cmd *Cmd) pipeOutput() error {
checkedEntry := cmd.logger.Check(zap.DebugLevel, "check for debug level before piping unix process output")
if checkedEntry == nil {
if !cmd.logger.Enabled(context.Background(), slog.LevelDebug) {
return nil
}
@@ -152,12 +157,12 @@ func (cmd *Cmd) pipeOutput() error {
// logCommandOutput creates logs entries according to a reader
// (either stdout or stderr).
logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) {
logCommandOutput := func(logger *slog.Logger, reader io.ReadCloser) {
r := bufio.NewReader(reader)
defer func(reader io.ReadCloser) {
err := reader.Close()
if err != nil && !strings.Contains(err.Error(), "file already closed") {
logger.Error(fmt.Sprintf("close reader: %s", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("close reader: %s", err))
}
}(reader)
@@ -165,20 +170,20 @@ func (cmd *Cmd) pipeOutput() error {
line, _, err := r.ReadLine()
if err != nil {
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
logger.Error(fmt.Sprintf("pipe unix process output error: %s", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("pipe unix process output error: %s", err))
}
break
}
if len(line) != 0 {
logger.Debug(string(line))
logger.DebugContext(context.Background(), string(line))
}
}
}
go logCommandOutput(cmd.logger.Named("stdout"), stdout)
go logCommandOutput(cmd.logger.Named("stderr"), stderr)
go logCommandOutput(cmd.logger.With(slog.String("logger", "stdout")), stdout)
go logCommandOutput(cmd.logger.With(slog.String("logger", "stderr")), stderr)
return nil
}
@@ -196,13 +201,13 @@ func (cmd *Cmd) Kill() error {
err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL)
if err == nil {
cmd.logger.Debug("unix process killed")
cmd.logger.DebugContext(context.Background(), "unix process killed")
return nil
}
// If the process does not exist anymore, the error is irrelevant.
if strings.Contains(err.Error(), "no such process") {
cmd.logger.Debug("unix process already killed")
cmd.logger.DebugContext(context.Background(), "unix process already killed")
return nil
}

View File

@@ -1,7 +1,7 @@
// Package gotenberg provides most of the logic of the module system.
// Package gotenberg implements the core module system.
//
// caddyserver/caddy, licensed under the Apache License 2.0, has significantly
// inspired this module system.
// Significantly inspired by caddyserver/caddy, licensed under the Apache
// License 2.0.
//
// More details are available on https://caddyserver.com/.
// See https://caddyserver.com/.
package gotenberg

View File

@@ -12,40 +12,57 @@ import (
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
var ErrFiltered = errors.New("value filtered")
// FilterDeadline checks if given value is allowed and not denied according to
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long
// to process.
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error {
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
if allowed.String() != "" {
allow := regexp2.MustCompile(allowed.String(), 0)
allow.MatchTimeout = time.Until(deadline)
// FilterDeadline checks if the given value is allowed and not denied according
// to regex patterns. The allowed list uses OR semantics (value must match at
// least one pattern). The denied list uses OR semantics (value is denied if it
// matches any pattern). It returns a [context.DeadlineExceeded] if it takes
// too long to process.
func FilterDeadline(allowed, denied []*regexp2.Regexp, s string, deadline time.Time) error {
if len(allowed) > 0 {
matched := false
ok, err := allow.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
for _, pattern := range allowed {
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := clone.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err)
}
if ok {
matched = true
break
}
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
}
if !ok {
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered)
if !matched {
return fmt.Errorf("'%s' does not match any expression from the allowed list: %w", s, ErrFiltered)
}
}
if denied.String() != "" {
deny := regexp2.MustCompile(denied.String(), 0)
deny.MatchTimeout = time.Until(deadline)
if len(denied) > 0 {
for _, pattern := range denied {
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := deny.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
ok, err := clone.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err)
}
if ok {
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
}
return fmt.Errorf("'%s' cannot handle '%s': %w", deny.String(), s, err)
}
if ok {
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
}
}

View File

@@ -12,8 +12,8 @@ import (
func TestFilterDeadline(t *testing.T) {
for _, tc := range []struct {
scenario string
allowed *regexp2.Regexp
denied *regexp2.Regexp
allowed []*regexp2.Regexp
denied []*regexp2.Regexp
s string
deadline time.Time
expectError bool
@@ -21,17 +21,17 @@ func TestFilterDeadline(t *testing.T) {
}{
{
scenario: "DeadlineExceeded (allowed)",
allowed: regexp2.MustCompile("foo", 0),
denied: regexp2.MustCompile("", 0),
allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
denied: nil,
s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true,
expectedError: context.DeadlineExceeded,
},
{
scenario: "ErrFiltered (allowed)",
allowed: regexp2.MustCompile("foo", 0),
denied: regexp2.MustCompile("", 0),
scenario: "ErrFiltered (allowed, no match)",
allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
denied: nil,
s: "bar",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
@@ -39,8 +39,8 @@ func TestFilterDeadline(t *testing.T) {
},
{
scenario: "DeadlineExceeded (denied)",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("foo", 0),
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true,
@@ -48,21 +48,55 @@ func TestFilterDeadline(t *testing.T) {
},
{
scenario: "ErrFiltered (denied)",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("foo", 0),
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "success",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("", 0),
scenario: "success (empty lists)",
allowed: nil,
denied: nil,
s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
{
scenario: "multi-pattern allow list, second matches",
allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^file:///tmp/", 0)},
denied: nil,
s: "file:///tmp/abc/index.html",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
{
scenario: "multi-pattern allow list, none matches",
allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^ftp://", 0)},
denied: nil,
s: "file:///tmp/abc/index.html",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "multi-pattern deny list, second matches",
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("^ftp://", 0), regexp2.MustCompile("^file:.*", 0)},
s: "file:///etc/passwd",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "https URL passes deny list targeting file://",
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("^file:.*", 0)},
s: "https://example.com",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)

View File

@@ -222,3 +222,32 @@ func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *r
return f.MustRegexp(newName)
}
// MustRegexpSlice returns a slice of compiled regular expressions from a
// string-slice flag given by name. Empty strings are skipped.
// It panics if an error occurs.
func (f *ParsedFlags) MustRegexpSlice(name string) []*regexp2.Regexp {
vals := f.MustStringSlice(name)
var regexps []*regexp2.Regexp
for _, val := range vals {
if val == "" {
continue
}
regexps = append(regexps, regexp2.MustCompile(val, 0))
}
return regexps
}
// MustDeprecatedRegexpSlice returns the slice of compiled regular expressions
// of a deprecated flag if it was explicitly set or the slice of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedRegexpSlice(deprecated string, newName string) []*regexp2.Regexp {
if f.Changed(deprecated) {
return f.MustRegexpSlice(deprecated)
}
return f.MustRegexpSlice(newName)
}

View File

@@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/dlclark/regexp2"
flag "github.com/spf13/pflag"
)
@@ -833,3 +834,120 @@ func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) {
})
}
}
func TestParsedFlags_MustRegexpSlice(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", []string{}, "")
err := fs.Parse([]string{"--foo=^file:.*", "--foo=^ftp://.*"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags := ParsedFlags{FlagSet: fs}
for _, tc := range []struct {
scenario string
name string
expectPanic bool
expectLen int
}{
{
scenario: "success with multiple patterns",
name: "foo",
expectPanic: false,
expectLen: 2,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic but got none")
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
result := parsedFlags.MustRegexpSlice(tc.name)
if !tc.expectPanic && len(result) != tc.expectLen {
t.Errorf("expected %d regexps but got %d", tc.expectLen, len(result))
}
})
}
// Test empty strings are skipped.
fs2 := flag.NewFlagSet("tests2", flag.ContinueOnError)
fs2.StringSlice("baz", []string{""}, "")
err = fs2.Parse([]string{})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags2 := ParsedFlags{FlagSet: fs2}
result := parsedFlags2.MustRegexpSlice("baz")
if len(result) != 0 {
t.Errorf("expected 0 regexps for empty strings but got %d", len(result))
}
}
func TestParsedFlags_MustDeprecatedRegexpSlice(t *testing.T) {
for _, tc := range []struct {
scenario string
rawFlags []string
expectPattern string
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=^file:.*"},
expectPattern: "^file:.*",
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=^ftp://.*"},
expectPattern: "^ftp://.*",
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=^file:.*", "--bar=^ftp://.*"},
expectPattern: "^file:.*",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", []string{}, "")
fs.StringSlice("bar", []string{}, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedRegexpSlice("foo", "bar")
if len(actual) != 1 {
t.Fatalf("expected 1 regexp but got %d", len(actual))
}
if actual[0].String() != tc.expectPattern {
t.Errorf("expected pattern '%s' but got '%s'", tc.expectPattern, actual[0].String())
}
})
}
_ = regexp2.None // Keep import alive.
}

View File

@@ -1,19 +1,19 @@
package gotenberg
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
)
// GarbageCollect scans the root path and deletes files or directories with
// names containing specific substrings and before a given expiration time.
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
logger = logger.Named("gc")
func GarbageCollect(ctx context.Context, logger *slog.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
logger = logger.With(slog.String("logger", "gc"))
// To make sure that the next Walk method stays on
// the root level of the considered path, we have to
@@ -38,12 +38,12 @@ func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string,
for _, substr := range includeSubstr {
if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
err := os.RemoveAll(path)
err := os.RemoveAll(path) //nolint:gosec // G122: rootPath is a trusted internal working directory
if err != nil {
return fmt.Errorf("garbage collect '%s': %w", path, err)
}
logger.Debug(fmt.Sprintf("'%s' removed", path))
logger.DebugContext(ctx, fmt.Sprintf("'%s' removed", path))
return skipDirOrNil(info)
}

View File

@@ -1,14 +1,15 @@
package gotenberg
import (
"context"
"fmt"
"log/slog"
"os"
"path"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
func TestGarbageCollect(t *testing.T) {
@@ -66,7 +67,7 @@ func TestGarbageCollect(t *testing.T) {
}
}()
err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr, time.Now())
err := GarbageCollect(context.Background(), slog.New(slog.DiscardHandler), tc.rootPath, tc.includeSubstr, time.Now())
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)

View File

@@ -1,9 +1,8 @@
package logging
package log
import (
"fmt"
"go.uber.org/zap/zapcore"
"log/slog"
)
// Foreground colors.
@@ -25,23 +24,15 @@ func (c color) Add(s string) string {
return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s)
}
func levelToColor(l zapcore.Level) color {
func levelToColor(l slog.Level) color {
switch l {
case zapcore.DebugLevel:
case slog.LevelDebug:
return cyan
case zapcore.InfoLevel:
case slog.LevelInfo:
return blue
case zapcore.WarnLevel:
case slog.LevelWarn:
return yellow
case zapcore.ErrorLevel:
return red
case zapcore.DPanicLevel:
return red
case zapcore.PanicLevel:
return red
case zapcore.FatalLevel:
return red
case zapcore.InvalidLevel:
case slog.LevelError:
return red
default:
return red

View File

@@ -0,0 +1,2 @@
// Package log gathers internal logging utilities.
package log

View File

@@ -0,0 +1,18 @@
package log
import "log/slog"
func gcpSeverity(l slog.Level) string {
switch {
case l < slog.LevelInfo:
return "DEBUG"
case l < slog.LevelWarn:
return "INFO"
case l < slog.LevelError:
return "WARNING"
case l >= slog.LevelError:
return "ERROR"
default:
return "DEFAULT"
}
}

View File

@@ -0,0 +1,171 @@
package log
import (
"context"
"errors"
"log/slog"
)
type gotenbergHandler struct {
slog.Handler
fieldsPrefix string
loggerName string
}
func NewGotenbergHandler(next slog.Handler, prefix string) slog.Handler {
return &gotenbergHandler{Handler: next, fieldsPrefix: prefix}
}
func (h *gotenbergHandler) Handle(ctx context.Context, r slog.Record) error {
var newAttrs []slog.Attr
if h.loggerName != "" {
newAttrs = append(newAttrs, slog.String("logger", h.loggerName))
}
var needsNewRecord bool
if len(newAttrs) > 0 {
needsNewRecord = true
}
if h.fieldsPrefix != "" {
r.Attrs(func(a slog.Attr) bool {
if a.Key == "logger" || a.Key == "correlation_id" || a.Key == "trace_id" || a.Key == "span_id" {
newAttrs = append(newAttrs, a)
needsNewRecord = true
return true
}
a.Key = h.fieldsPrefix + "_" + a.Key
newAttrs = append(newAttrs, a)
needsNewRecord = true
return true
})
} else if needsNewRecord {
r.Attrs(func(a slog.Attr) bool {
newAttrs = append(newAttrs, a)
return true
})
}
if needsNewRecord {
newR := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
newR.AddAttrs(newAttrs...)
r = newR
}
return h.Handler.Handle(ctx, r)
}
func (h *gotenbergHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
var prefixed []slog.Attr
newLoggerName := h.loggerName
for _, a := range attrs {
if a.Key == "logger" {
if newLoggerName == "" {
newLoggerName = a.Value.String()
} else {
newLoggerName = newLoggerName + "." + a.Value.String()
}
continue
}
if h.fieldsPrefix != "" {
if a.Key == "correlation_id" || a.Key == "trace_id" || a.Key == "span_id" {
// Don't prefix these keys
} else {
a.Key = h.fieldsPrefix + "_" + a.Key
}
}
prefixed = append(prefixed, a)
}
newHandler := h.Handler
if len(prefixed) > 0 {
newHandler = h.Handler.WithAttrs(prefixed)
}
return &gotenbergHandler{
Handler: newHandler,
fieldsPrefix: h.fieldsPrefix,
loggerName: newLoggerName,
}
}
func (h *gotenbergHandler) WithGroup(name string) slog.Handler {
return &gotenbergHandler{
Handler: h.Handler.WithGroup(name),
fieldsPrefix: h.fieldsPrefix,
loggerName: h.loggerName,
}
}
type multiHandler struct {
handlers []slog.Handler
}
func FanOut(handlers ...slog.Handler) slog.Handler {
return &multiHandler{handlers: handlers}
}
func (m *multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
for _, h := range m.handlers {
if h.Enabled(ctx, level) {
return true
}
}
return false
}
func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error {
var errs []error
for _, h := range m.handlers {
if h.Enabled(ctx, r.Level) {
if err := h.Handle(ctx, r.Clone()); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
cloned := make([]slog.Handler, len(m.handlers))
for i, h := range m.handlers {
cloned[i] = h.WithAttrs(attrs)
}
return &multiHandler{handlers: cloned}
}
func (m *multiHandler) WithGroup(name string) slog.Handler {
cloned := make([]slog.Handler, len(m.handlers))
for i, h := range m.handlers {
cloned[i] = h.WithGroup(name)
}
return &multiHandler{handlers: cloned}
}
type levelHandler struct {
slog.Handler
level slog.Level
}
func LevelFilter(next slog.Handler, level slog.Level) slog.Handler {
return &levelHandler{Handler: next, level: level}
}
func (h *levelHandler) Enabled(ctx context.Context, level slog.Level) bool {
return level >= h.level && h.Handler.Enabled(ctx, level)
}
func (h *levelHandler) Handle(ctx context.Context, r slog.Record) error {
return h.Handler.Handle(ctx, r)
}
func (h *levelHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &levelHandler{Handler: h.Handler.WithAttrs(attrs), level: h.level}
}
func (h *levelHandler) WithGroup(name string) slog.Handler {
return &levelHandler{Handler: h.Handler.WithGroup(name), level: h.level}
}

View File

@@ -0,0 +1,37 @@
package log
import (
"log/slog"
"sync"
)
// InitLogger initializes the global logger.
func InitLogger(handler slog.Handler) {
if logger != nil {
return
}
mu.Lock()
defer mu.Unlock()
// Double check: ensure it wasn't initialized while we waited for the lock.
if logger != nil {
return
}
logger = slog.New(handler)
}
// Logger returns the global logger.
func Logger() *slog.Logger {
mu.Lock()
defer mu.Unlock()
return logger
}
// logger is Singleton so that we instantiate our [slog.Logger] only once.
var (
logger *slog.Logger
mu sync.Mutex
)

View File

@@ -0,0 +1,128 @@
package log
import (
"context"
"log/slog"
"os"
"strings"
"go.opentelemetry.io/otel/trace"
"golang.org/x/term"
)
const (
autoLoggingFormat = "auto"
jsonLoggingFormat = "json"
textLoggingFormat = "text"
)
// traceContextHandler is an internal wrapper that specifically injects
// trace_id and span_id into the log record before they are written to the standard output.
type traceContextHandler struct {
slog.Handler
}
func (h traceContextHandler) Handle(ctx context.Context, r slog.Record) error {
if spanCtx := trace.SpanContextFromContext(ctx); spanCtx.IsValid() {
// Since slog.Record is immutable with regards to adding attributes in-place,
// we must clone and add them.
newR := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
r.Attrs(func(a slog.Attr) bool {
newR.AddAttrs(a)
return true
})
newR.AddAttrs(
slog.String("trace_id", spanCtx.TraceID().String()),
slog.String("span_id", spanCtx.SpanID().String()),
)
r = newR
}
return h.Handler.Handle(ctx, r)
}
func (h traceContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return traceContextHandler{Handler: h.Handler.WithAttrs(attrs)}
}
func (h traceContextHandler) WithGroup(name string) slog.Handler {
return traceContextHandler{Handler: h.Handler.WithGroup(name)}
}
// NewStdHandler returns a [slog.Handler] instance for the standard output.
func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool) (slog.Handler, error) {
// #nosec: G115
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
// Normalize the log format based on the output device.
if format == autoLoggingFormat {
if isTerminal {
format = textLoggingFormat
} else {
format = jsonLoggingFormat
}
}
opts := &slog.HandlerOptions{
Level: level,
}
opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
// Configure level encoding based on format and GCP settings.
if a.Key == slog.LevelKey {
l := a.Value.Any().(slog.Level)
switch {
case format == textLoggingFormat && isTerminal:
if enableGcpFields {
a.Value = slog.StringValue(gcpSeverityColorEncoder(l))
} else {
a.Value = slog.StringValue(levelToColor(l).Add(l.String()))
}
case enableGcpFields && format != textLoggingFormat:
a.Key = "severity"
a.Value = slog.StringValue(gcpSeverity(l))
default:
a.Value = slog.StringValue(strings.ToLower(l.String()))
}
}
if a.Key == slog.TimeKey {
if enableGcpFields && format != textLoggingFormat {
a.Key = "time"
} else {
a.Key = "ts"
}
if isTerminal {
a.Value = slog.StringValue(a.Value.Time().Local().Format("2006/01/02 15:04:05.000"))
} else if !enableGcpFields {
a.Value = slog.Float64Value(float64(a.Value.Time().UnixNano()) / 1e9)
}
}
if a.Key == slog.MessageKey {
if enableGcpFields && format != textLoggingFormat {
a.Key = "message"
} else {
a.Key = "msg"
}
}
return a
}
var handler slog.Handler
if format == textLoggingFormat {
handler = slog.NewTextHandler(os.Stderr, opts)
} else {
handler = slog.NewJSONHandler(os.Stderr, opts)
}
return traceContextHandler{Handler: handler}, nil
}
func gcpSeverityColorEncoder(l slog.Level) string {
severity := gcpSeverity(l)
c := levelToColor(l)
return c.Add(severity)
}

View File

@@ -0,0 +1,7 @@
// Package otel gathers initialization utilities for OpenTelemetry
// instrumentation.
//
// Significantly inspired by https://github.com/lucavallin/gotel.
//
// See https://opentelemetry.io/.
package otel

View File

@@ -0,0 +1,166 @@
package otel
import (
"context"
"fmt"
"log/slog"
"os"
"sync/atomic"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/exporters/autoexport"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
)
// InitTracerProvider initializes the OpenTelemetry tracer provider.
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
initOtelLogger(logger)
ctx := context.Background()
hostname, err := os.Hostname()
if err != nil {
return nil, fmt.Errorf("get hostname: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
semconv.HostName(hostname),
),
)
if err != nil {
return nil, fmt.Errorf("merge resource: %w", err)
}
traceOpts := []trace.TracerProviderOption{
trace.WithResource(res),
}
traceExporter, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, err
}
if !autoexport.IsNoneSpanExporter(traceExporter) {
traceOpts = append(traceOpts, trace.WithBatcher(traceExporter))
}
traceProvider := trace.NewTracerProvider(traceOpts...)
otel.SetTracerProvider(traceProvider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return traceProvider.Shutdown, nil
}
// InitMeterProvider initializes the OpenTelemetry meter provider.
func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
initOtelLogger(logger)
ctx := context.Background()
hostname, err := os.Hostname()
if err != nil {
return nil, fmt.Errorf("get hostname: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
semconv.HostName(hostname),
),
)
if err != nil {
return nil, fmt.Errorf("merge resource: %w", err)
}
metricOpts := []metric.Option{
metric.WithResource(res),
}
metricReader, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, err
}
if !autoexport.IsNoneMetricReader(metricReader) {
metricOpts = append(metricOpts, metric.WithReader(metricReader))
}
meterProvider := metric.NewMeterProvider(metricOpts...)
otel.SetMeterProvider(meterProvider)
return meterProvider.Shutdown, nil
}
// InitLoggerProvider initializes the OpenTelemetry logger provider.
func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, handler slog.Handler, err error) {
initOtelLogger(logger)
ctx := context.Background()
hostname, err := os.Hostname()
if err != nil {
return nil, nil, fmt.Errorf("get hostname: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
semconv.HostName(hostname),
),
)
if err != nil {
return nil, nil, fmt.Errorf("merge resource: %w", err)
}
logOpts := []log.LoggerProviderOption{
log.WithResource(res),
}
logExporter, err := autoexport.NewLogExporter(ctx)
if err != nil {
return nil, nil, err
}
if !autoexport.IsNoneLogExporter(logExporter) {
logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter)))
}
loggerProvider := log.NewLoggerProvider(logOpts...)
otelHandler := otelslog.NewHandler(serviceName, otelslog.WithLoggerProvider(loggerProvider))
global.SetLoggerProvider(loggerProvider)
return loggerProvider.Shutdown, otelHandler, nil
}
func initOtelLogger(logger *slog.Logger) {
if otlpLoggerInitialized.Load() {
return
}
otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) {
logger.Error(err.Error())
}))
otlpLoggerInitialized.Store(true)
}
var otlpLoggerInitialized atomic.Bool

View File

@@ -1,57 +1 @@
package gotenberg
import (
"fmt"
"github.com/hashicorp/go-retryablehttp"
"go.uber.org/zap"
)
// LoggerProvider is an interface for a module that supplies a method for
// creating a [zap.Logger] instance for use by other modules.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.LoggerProvider))
// logger, _ := provider.(gotenberg.LoggerProvider).Logger(m)
// }
type LoggerProvider interface {
Logger(mod Module) (*zap.Logger, error)
}
// LeveledLogger is a wrapper around a [zap.Logger] so that it may be used by a
// [retryablehttp.Client].
type LeveledLogger struct {
logger *zap.Logger
}
// NewLeveledLogger instantiates a [LeveledLogger].
func NewLeveledLogger(logger *zap.Logger) *LeveledLogger {
return &LeveledLogger{
logger: logger,
}
}
// Error logs a message at the error level using the wrapped zap.Logger.
func (leveled LeveledLogger) Error(msg string, keysAndValues ...any) {
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Warn logs a message at the warning level using the wrapped zap.Logger.
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...any) {
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Info logs a message at the info level using the wrapped zap.Logger.
func (leveled LeveledLogger) Info(msg string, keysAndValues ...any) {
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Debug logs a message at the debug level using the wrapped zap.Logger.
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...any) {
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Interface guards.
var (
_ retryablehttp.LeveledLogger = (*LeveledLogger)(nil)
)

View File

@@ -2,9 +2,8 @@ package gotenberg
import (
"context"
"log/slog"
"os"
"go.uber.org/zap"
)
// ModuleMock is a mock for the [Module] interface.
@@ -46,75 +45,80 @@ func (mod *DebuggableMock) Debug() map[string]any {
//
//nolint:dupl
type PdfEngineMock struct {
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error)
PageCountMock func(ctx context.Context, logger *zap.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error
ReadBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error)
EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
WriteBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error
WatermarkMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
StampMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
RotateMock func(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
}
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return engine.MergeMock(ctx, logger, inputPaths, outputPath)
}
func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
func (engine *PdfEngineMock) Split(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath)
}
func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
return engine.FlattenMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error {
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
}
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return engine.ReadMetadataMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
func (engine *PdfEngineMock) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
return engine.PageCountMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
}
func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error) {
func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error) {
return engine.ReadBookmarksMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
}
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
}
func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error {
func (engine *PdfEngineMock) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return engine.EmbedFilesMetadataMock(ctx, logger, metadata, inputPath)
}
func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error {
return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks)
}
func (engine *PdfEngineMock) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error {
func (engine *PdfEngineMock) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error {
return engine.WatermarkMock(ctx, logger, inputPath, stamp)
}
func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error {
func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error {
return engine.StampMock(ctx, logger, inputPath, stamp)
}
func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
}
@@ -129,31 +133,32 @@ func (provider *PdfEngineProviderMock) PdfEngine() (PdfEngine, error) {
// ProcessMock is a mock for the [Process] interface.
type ProcessMock struct {
StartMock func(logger *zap.Logger) error
StopMock func(logger *zap.Logger) error
HealthyMock func(logger *zap.Logger) bool
StartMock func(logger *slog.Logger) error
StopMock func(logger *slog.Logger) error
HealthyMock func(logger *slog.Logger) bool
}
func (p *ProcessMock) Start(logger *zap.Logger) error {
func (p *ProcessMock) Start(logger *slog.Logger) error {
return p.StartMock(logger)
}
func (p *ProcessMock) Stop(logger *zap.Logger) error {
func (p *ProcessMock) Stop(logger *slog.Logger) error {
return p.StopMock(logger)
}
func (p *ProcessMock) Healthy(logger *zap.Logger) bool {
func (p *ProcessMock) Healthy(logger *slog.Logger) bool {
return p.HealthyMock(logger)
}
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
type ProcessSupervisorMock struct {
LaunchMock func() error
ShutdownMock func() error
HealthyMock func() bool
RunMock func(ctx context.Context, logger *zap.Logger, task func() error) error
ReqQueueSizeMock func() int64
RestartsCountMock func() int64
LaunchMock func() error
ShutdownMock func() error
HealthyMock func() bool
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
ReqQueueSizeMock func() int64
RestartsCountMock func() int64
ActiveTasksCountMock func() int64
}
func (s *ProcessSupervisorMock) Launch() error {
@@ -168,7 +173,7 @@ func (s *ProcessSupervisorMock) Healthy() bool {
return s.HealthyMock()
}
func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
return s.RunMock(ctx, logger, task)
}
@@ -180,13 +185,8 @@ func (s *ProcessSupervisorMock) RestartsCount() int64 {
return s.RestartsCountMock()
}
// LoggerProviderMock is a mock for the [LoggerProvider] interface.
type LoggerProviderMock struct {
LoggerMock func(mod Module) (*zap.Logger, error)
}
func (provider *LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
return provider.LoggerMock(mod)
func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
return s.ActiveTasksCountMock()
}
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
@@ -224,7 +224,6 @@ var (
_ PdfEngineProvider = (*PdfEngineProviderMock)(nil)
_ Process = (*ProcessMock)(nil)
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
_ LoggerProvider = (*LoggerProviderMock)(nil)
_ MetricsProvider = (*MetricsProviderMock)(nil)
_ MkdirAll = (*MkdirAllMock)(nil)
_ PathRename = (*PathRenameMock)(nil)

413
pkg/gotenberg/outbound.go Normal file
View File

@@ -0,0 +1,413 @@
package gotenberg
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
"github.com/dlclark/regexp2"
)
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
// is not reachable on the public internet. This covers loopback, RFC1918
// private, link-local, unspecified, multicast, and IPv6 unique-local
// (fc00::/7) addresses, as well as their IPv4-mapped IPv6 wrappers (for
// example [::ffff:127.0.0.1]).
var ErrNonPublicIP = errors.New("non-public IP")
// ErrPublicIP indicates that an outbound URL targets an IP address that is
// reachable on the public internet. It is returned when a caller opts
// into denying public destinations via [WithDenyPublicIPs]; typical use
// cases are air-gapped or data-governed deployments where Gotenberg must
// only talk to hosts on a private network.
var ErrPublicIP = errors.New("public IP")
// netipResolver is the subset of [net.Resolver] used by [resolveHost].
// Defining it as an interface allows tests to substitute a stub resolver.
type netipResolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
// outboundResolver is the resolver used by [resolveHost]. It is a
// package-level variable so that tests can substitute a stub resolver.
var outboundResolver netipResolver = net.DefaultResolver
// outboundDialer is the underlying dialer used by [secureDialContext]. It is
// a package-level variable so that tests can replace it.
var outboundDialer = &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
// IsPublicIP reports whether addr is reachable on the public internet. It
// returns false for loopback, private (RFC1918), link-local, unspecified,
// multicast, and unique-local addresses. IPv4-mapped IPv6 addresses are
// unmapped before evaluation so that [::ffff:127.0.0.1] is correctly
// identified as loopback.
func IsPublicIP(addr netip.Addr) bool {
if !addr.IsValid() {
return false
}
addr = addr.Unmap()
switch {
case addr.IsLoopback(),
addr.IsPrivate(),
addr.IsLinkLocalUnicast(),
addr.IsLinkLocalMulticast(),
addr.IsMulticast(),
addr.IsUnspecified(),
addr.IsInterfaceLocalMulticast():
return false
}
return true
}
// ResolveAndCheckPublic resolves host and rejects any resolved address
// that fails [IsPublicIP] with [ErrNonPublicIP]. It is the strict
// equivalent of [DecideOutbound] with [WithDenyPrivateIPs] true for a
// bare host. Callers that need a different policy should use
// [DecideOutbound] directly.
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
return resolveHost(ctx, host, true, false)
}
// resolveHost resolves host and returns the addresses. When denyPrivate
// is true, a non-public address is rejected with [ErrNonPublicIP]. When
// denyPublic is true, a public address is rejected with [ErrPublicIP].
// Both checks may be active at the same time, in which case any
// resolved address fails and the caller must rely on an allow-list
// bypass.
func resolveHost(ctx context.Context, host string, denyPrivate, denyPublic bool) ([]netip.Addr, error) {
if host == "" {
return nil, errors.New("empty host")
}
check := func(a netip.Addr) error {
public := IsPublicIP(a)
if denyPublic && public {
return fmt.Errorf("%q: %w", a, ErrPublicIP)
}
if denyPrivate && !public {
return fmt.Errorf("%q: %w", a, ErrNonPublicIP)
}
return nil
}
if addr, err := netip.ParseAddr(host); err == nil {
if err := check(addr); err != nil {
return nil, err
}
return []netip.Addr{addr}, nil
}
addrs, err := outboundResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, fmt.Errorf("resolve %q: %w", host, err)
}
if len(addrs) == 0 {
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
}
for _, a := range addrs {
if err := check(a); err != nil {
return nil, fmt.Errorf("%q resolves to rejected address %w", host, err)
}
}
return addrs, nil
}
// OutboundDecision is the result of validating an outbound URL via
// [DecideOutbound]. Callers use it to dial the destination either directly
// (operator-approved allow-list match, Bypass true) or via [DialPinned] so
// that the connect targets the IPs resolved at validation time. Passing
// the decision to the dialer closes the window between validation and
// connect that DNS rebinding exploits.
type OutboundDecision struct {
// Bypass is true when an allow-list pattern matched the URL. The
// operator has explicitly opted into the destination; the caller
// should dial directly without an additional IP check.
Bypass bool
// Pinned holds the IPs resolved for the URL host. The caller should
// dial one of these via [DialPinned] to prevent DNS rebinding between
// validation and connect.
Pinned []netip.Addr
}
// outboundDecisionKey is the context key under which an [OutboundDecision]
// is stored.
type outboundDecisionKey struct{}
// decideConfig carries optional settings for [DecideOutbound] and
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
type decideConfig struct {
denyPrivateIPs bool
denyPublicIPs bool
}
// DecideOption customizes how [DecideOutbound] and [FilterOutboundURL]
// validate a URL. Options are applied in order on top of the permissive
// defaults (no IP-class rejection).
type DecideOption func(*decideConfig)
// WithDenyPrivateIPs rejects URLs whose host resolves to a non-public IP
// address (loopback, RFC1918, link-local, unique-local, multicast,
// unspecified). DNS still runs and the returned [OutboundDecision] still
// carries the resolved IPs for dial pinning, so enabling or disabling
// this option does not affect DNS-rebinding protection. Use it on
// internet-exposed deployments to mitigate SSRF against internal
// services.
func WithDenyPrivateIPs(deny bool) DecideOption {
return func(c *decideConfig) { c.denyPrivateIPs = deny }
}
// WithDenyPublicIPs rejects URLs whose host resolves to a public IP
// address. Use it on air-gapped or data-governed deployments where
// Gotenberg must only reach hosts on a private network; the option
// prevents data exfiltration to attacker-controlled public servers via
// webhook callbacks, downloadFrom URLs, or user-supplied stamp sources.
// May be combined with [WithDenyPrivateIPs]; in that case every resolved
// address fails and only an allow-list bypass permits a destination.
func WithDenyPublicIPs(deny bool) DecideOption {
return func(c *decideConfig) { c.denyPublicIPs = deny }
}
// httpLikeScheme reports whether scheme is one of http, https, ws, or wss.
// Only these schemes go through the IP-based address check; data, blob,
// file, and other schemes are filtered by the regex layer alone.
func httpLikeScheme(scheme string) bool {
switch scheme {
case "http", "https", "ws", "wss":
return true
}
return false
}
// DecideOutbound parses rawURL, runs the regex allow/deny lists against
// the normalized form, and (when no allow-list match) resolves the host
// and applies the IP-class checks selected by opts. It returns the
// resulting [OutboundDecision] so the caller can pin the dial to the IPs
// that were resolved here and skip a second DNS lookup later, which
// closes the DNS rebinding window that affects callers that only receive
// an error from [FilterOutboundURL].
//
// The semantics:
//
// 1. The URL is parsed and its scheme and host lowercased.
// 2. allowList and denyList apply against the normalized form with OR
// semantics. The deny-list always applies.
// 3. For http, https, ws, and wss, the host is resolved and every
// resolved address must satisfy the enabled IP-class checks
// ([WithDenyPrivateIPs], [WithDenyPublicIPs]). An allow-list match
// bypasses the IP-class checks and the returned decision carries
// Bypass true. Otherwise the decision carries Pinned with the
// resolved addresses.
//
// Callers that dial the destination themselves must honor Bypass and
// Pinned: bypassed URLs dial the hostname directly (operator opt-in);
// pinned URLs must dial one of Pinned via [DialPinned].
func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) (OutboundDecision, error) {
cfg := decideConfig{}
for _, opt := range opts {
opt(&cfg)
}
parsed, err := url.Parse(rawURL)
if err != nil {
return OutboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
parsed.Host = strings.ToLower(parsed.Host)
normalized := parsed.String()
allowMatched := false
if len(allowList) > 0 {
for _, pattern := range allowList {
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := clone.MatchString(normalized)
if err != nil {
if time.Now().After(deadline) {
return OutboundDecision{}, context.DeadlineExceeded
}
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
}
if ok {
allowMatched = true
break
}
}
if !allowMatched {
return OutboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
}
}
for _, pattern := range denyList {
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := clone.MatchString(normalized)
if err != nil {
if time.Now().After(deadline) {
return OutboundDecision{}, context.DeadlineExceeded
}
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
}
if ok {
return OutboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
}
}
if allowMatched {
return OutboundDecision{Bypass: true}, nil
}
if !httpLikeScheme(parsed.Scheme) {
return OutboundDecision{}, nil
}
host := parsed.Hostname()
if host == "" {
return OutboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
}
addrs, err := resolveHost(ctx, host, cfg.denyPrivateIPs, cfg.denyPublicIPs)
if err != nil {
switch {
case errors.Is(err, ErrNonPublicIP):
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
case errors.Is(err, ErrPublicIP):
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
default:
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
}
}
return OutboundDecision{Pinned: addrs}, nil
}
// FilterOutboundURL validates that rawURL is acceptable for an outbound
// request from Gotenberg. It is the URL-aware replacement for
// [FilterDeadline] and should be preferred for any new code that filters
// a URL before issuing or instructing an outbound request.
//
// The default behavior is permissive: the URL passes as long as it clears
// the regex allow-list and deny-list. Callers that need IP-class checks
// opt in via [WithDenyPrivateIPs] or [WithDenyPublicIPs]. The deny-list
// always applies and cannot be bypassed by an allow-list match.
func FilterOutboundURL(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) error {
_, err := DecideOutbound(ctx, rawURL, allowList, denyList, deadline, opts...)
return err
}
// outboundRoundTripper is an [http.RoundTripper] that validates each
// request URL via [DecideOutbound] and stashes the resulting
// [OutboundDecision] in the request context so that [secureDialContext]
// can pin the dial or bypass the IP check as appropriate. Because the
// http.Client invokes RoundTrip again for each redirect hop, this also
// re-validates redirect targets without a separate CheckRedirect.
type outboundRoundTripper struct {
base http.RoundTripper
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
opts []DecideOption
}
// RoundTrip validates req.URL and delegates to the base transport.
func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
deadline, ok := req.Context().Deadline()
if !ok {
deadline = time.Now().Add(30 * time.Second)
}
decision, err := DecideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline, rt.opts...)
if err != nil {
return nil, err
}
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
return rt.base.RoundTrip(req.WithContext(ctx))
}
// NewOutboundHttpClient returns an [http.Client] that validates every
// outbound request URL via the same logic as [FilterOutboundURL] and
// pins the resulting dial to the resolved IPs.
//
// The client re-validates redirect targets automatically because the
// underlying [http.Client] invokes the wrapping [http.RoundTripper] once
// per hop. This closes the redirect-based SSRF bypass that affects raw
// [http.Client] usage when no CheckRedirect is set.
//
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
// or [WithDenyPublicIPs] to opt into IP-class rejection.
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, opts ...DecideOption) *http.Client {
base := http.DefaultTransport.(*http.Transport).Clone()
base.DialContext = secureDialContext
return &http.Client{
Timeout: timeout,
Transport: &outboundRoundTripper{
base: base,
allowList: allowList,
denyList: denyList,
opts: opts,
},
}
}
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
// [outboundRoundTripper]. When the decision is to bypass (allow-list
// match), it dials directly. When the decision contains pinned IPs, it
// dials each in turn until one connects. When no decision is present
// (the dialer was used outside of [outboundRoundTripper]), it falls back
// to resolving the destination without IP-class checks so that the
// fallback matches the permissive default and operators who need
// restrictions configure them at the caller.
func secureDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("split host:port %q: %w", addr, err)
}
if decision, ok := ctx.Value(outboundDecisionKey{}).(OutboundDecision); ok {
if decision.Bypass {
return outboundDialer.DialContext(ctx, network, addr)
}
if len(decision.Pinned) > 0 {
return DialPinned(ctx, network, decision.Pinned, port)
}
}
addrs, err := resolveHost(ctx, host, false, false)
if err != nil {
return nil, err
}
return DialPinned(ctx, network, addrs, port)
}
// DialPinned dials each addr in turn until one connects, returning the
// first successful connection or the last error. Callers pass the Pinned
// slice from [OutboundDecision] so that the dial targets exactly the IPs
// that [DecideOutbound] resolved, preventing DNS rebinding between
// validation and connect.
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) {
var lastErr error
for _, a := range addrs {
conn, err := outboundDialer.DialContext(ctx, network, net.JoinHostPort(a.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr == nil {
return nil, errors.New("no addresses to dial")
}
return nil, lastErr
}

View File

@@ -0,0 +1,457 @@
package gotenberg
import (
"context"
"errors"
"net/netip"
"testing"
"time"
"github.com/dlclark/regexp2"
)
func TestIsPublicIP(t *testing.T) {
for _, tc := range []struct {
addr string
public bool
}{
// Public.
{"1.1.1.1", true},
{"8.8.8.8", true},
{"2606:4700:4700::1111", true},
// Loopback.
{"127.0.0.1", false},
{"127.255.255.254", false},
{"::1", false},
// IPv4-mapped IPv6 (Issue 2).
{"::ffff:127.0.0.1", false},
{"::ffff:10.0.0.1", false},
{"::ffff:169.254.169.254", false},
// RFC1918.
{"10.0.0.1", false},
{"172.16.0.1", false},
{"172.31.255.254", false},
{"192.168.1.1", false},
// Link-local.
{"169.254.169.254", false},
{"fe80::1", false},
// Unique-local.
{"fc00::1", false},
{"fd12:3456:789a::1", false},
// Unspecified.
{"0.0.0.0", false},
{"::", false},
// Multicast.
{"224.0.0.1", false},
{"ff02::1", false},
} {
t.Run(tc.addr, func(t *testing.T) {
addr, err := netip.ParseAddr(tc.addr)
if err != nil {
t.Fatalf("parse %q: %v", tc.addr, err)
}
if got := IsPublicIP(addr); got != tc.public {
t.Fatalf("IsPublicIP(%q) = %v, want %v", tc.addr, got, tc.public)
}
})
}
}
// stubResolver lets tests fake DNS lookups in [ResolveAndCheckPublic].
type stubResolver struct {
lookup func(host string) ([]netip.Addr, error)
}
func (s stubResolver) LookupNetIP(_ context.Context, _, host string) ([]netip.Addr, error) {
return s.lookup(host)
}
func withStubResolver(t *testing.T, fn func(host string) ([]netip.Addr, error)) {
t.Helper()
prev := outboundResolver
outboundResolver = stubResolver{lookup: fn}
t.Cleanup(func() { outboundResolver = prev })
}
func mustAddrs(t *testing.T, ss ...string) []netip.Addr {
t.Helper()
out := make([]netip.Addr, 0, len(ss))
for _, s := range ss {
a, err := netip.ParseAddr(s)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
out = append(out, a)
}
return out
}
func TestFilterOutboundURL(t *testing.T) {
defaultDeny := []*regexp2.Regexp{
regexp2.MustCompile(`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`, 0),
}
chromiumDeny := []*regexp2.Regexp{
regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0),
}
for _, tc := range []struct {
scenario string
rawURL string
allow []*regexp2.Regexp
deny []*regexp2.Regexp
opts []DecideOption
stub func(host string) ([]netip.Addr, error)
expectErr bool
expectIs error
expectErrMsg string
}{
{
scenario: "public IP literal passes",
rawURL: "https://1.1.1.1/",
deny: defaultDeny,
expectErr: false,
},
{
scenario: "loopback IP literal blocked by default deny-list",
rawURL: "http://127.0.0.1:8080/",
deny: defaultDeny,
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Issue 4: uppercase scheme normalized then blocked by deny-list",
rawURL: "HTTP://127.0.0.1:8080/",
deny: defaultDeny,
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Issue 2: IPv4-mapped IPv6 evades deny-list but blocked by IP check",
rawURL: "http://[::ffff:127.0.0.1]:8080/page.pdf",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Issue 2: IPv4-mapped IPv6 to RFC1918 blocked by IP check",
rawURL: "http://[::ffff:10.0.0.1]/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "hostname resolving to public IP passes with deny-private-ips",
rawURL: "https://example.com/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
expectErr: false,
},
{
scenario: "hostname resolving to loopback blocked with deny-private-ips",
rawURL: "https://rebind.example/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "127.0.0.1"), nil },
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "hostname resolving to mixed public+private blocked with deny-private-ips",
rawURL: "https://mixed.example/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "1.1.1.1", "10.0.0.1"), nil },
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "allow-list match bypasses IP check",
rawURL: "http://internal.service/api",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, 0)},
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: false,
},
{
scenario: "deny-list still wins over allow-list match",
rawURL: "http://internal.service/api",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal`, 0)},
deny: []*regexp2.Regexp{regexp2.MustCompile(`/api$`, 0)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "allow-list non-empty and no match rejects",
rawURL: "https://other.example/",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^https://allowed\.example`, 0)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "file:// allowed under tmp passes Chromium default",
rawURL: "file:///tmp/index.html",
deny: chromiumDeny,
expectErr: false,
},
{
scenario: "file:// outside tmp blocked by Chromium default",
rawURL: "file:///etc/passwd",
deny: chromiumDeny,
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Chromium default permissive passes http to public host",
rawURL: "https://example.com/",
deny: chromiumDeny,
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
expectErr: false,
},
{
scenario: "Chromium with deny-private-ips blocks http to loopback",
rawURL: "http://127.0.0.1:3000/health",
deny: chromiumDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Chromium with deny-private-ips blocks cloud metadata",
rawURL: "http://169.254.169.254/latest/meta-data/",
deny: chromiumDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "data: URL passes (non-network scheme)",
rawURL: "data:text/html;base64,PGgxPmhpPC9oMT4=",
expectErr: false,
},
{
scenario: "URL with no host rejected",
rawURL: "http:///path",
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "userinfo cannot mask host when deny-private-ips enabled",
rawURL: "http://example.com@127.0.0.1/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.stub != nil {
withStubResolver(t, tc.stub)
} else {
// Default: any DNS lookup in a non-stubbed test is a bug.
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
}
err := FilterOutboundURL(context.Background(), tc.rawURL, tc.allow, tc.deny, time.Now().Add(5*time.Second), tc.opts...)
if tc.expectErr && err == nil {
t.Fatalf("expected error, got nil")
}
if !tc.expectErr && err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if tc.expectIs != nil && !errors.Is(err, tc.expectIs) {
t.Fatalf("expected error to wrap %v, got: %v", tc.expectIs, err)
}
})
}
}
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
_, err := ResolveAndCheckPublic(context.Background(), "127.0.0.1")
if !errors.Is(err, ErrNonPublicIP) {
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
}
}
func TestResolveAndCheckPublic_HostResolvesToLoopback(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "127.0.0.1"), nil
})
_, err := ResolveAndCheckPublic(context.Background(), "rebind.example")
if !errors.Is(err, ErrNonPublicIP) {
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
}
}
func TestResolveAndCheckPublic_HostResolvesToPublic(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "1.1.1.1"), nil
})
addrs, err := ResolveAndCheckPublic(context.Background(), "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(addrs) != 1 || addrs[0].String() != "1.1.1.1" {
t.Fatalf("expected [1.1.1.1], got: %v", addrs)
}
}
func TestDecideOutbound_DenyPrivateIPs_RejectsLoopbackLiteral(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
_, err := DecideOutbound(
context.Background(),
"http://127.0.0.1:8080/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("WithDenyPrivateIPs(true) must reject loopback literal, got: %v", err)
}
}
func TestDecideOutbound_DenyPrivateIPs_AllowsPublic(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "93.184.216.34"), nil
})
decision, err := DecideOutbound(
context.Background(),
"http://example.com/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
)
if err != nil {
t.Fatalf("expected no error for public host, got: %v", err)
}
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "93.184.216.34" {
t.Fatalf("decision.Pinned = %v, want [93.184.216.34]", decision.Pinned)
}
}
func TestDecideOutbound_DenyPublicIPs_RejectsPublic(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "1.1.1.1"), nil
})
_, err := DecideOutbound(
context.Background(),
"http://example.com/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPublicIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("WithDenyPublicIPs(true) must reject public host, got: %v", err)
}
}
func TestDecideOutbound_DenyPublicIPs_AllowsPrivate(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "10.0.0.5"), nil
})
decision, err := DecideOutbound(
context.Background(),
"http://internal.svc/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPublicIPs(true),
)
if err != nil {
t.Fatalf("expected no error for private host, got: %v", err)
}
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
}
}
func TestDecideOutbound_DenyBoth_WhitelistOnly(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "1.1.1.1"), nil
})
// Both denies active and no allow-list match: every resolved address
// fails. Only an allow-list match can permit a destination under
// this posture.
_, err := DecideOutbound(
context.Background(),
"http://example.com/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
WithDenyPublicIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("expected ErrFiltered with both denies enabled, got: %v", err)
}
}
func TestDecideOutbound_DenyLists_WinOverDenyPrivateIPs(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
// The regex deny-list fires before any resolution; verifies that
// operator-supplied deny patterns remain effective regardless of
// IP-class options.
deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, 0)}
_, err := DecideOutbound(
context.Background(),
"http://evil.local/",
nil, deny,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("deny-list must still reject, got: %v", err)
}
}
func TestDecideOutbound_Permissive_AllowsPrivate(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "10.0.0.5"), nil
})
// No options passed: default posture is permissive across both
// IP classes. The caller still gets pinned IPs for dial safety.
decision, err := DecideOutbound(
context.Background(),
"http://internal.svc/",
nil, nil,
time.Now().Add(5*time.Second),
)
if err != nil {
t.Fatalf("permissive default must allow private host, got: %v", err)
}
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
}
}

View File

@@ -4,8 +4,7 @@ import (
"context"
"errors"
"fmt"
"go.uber.org/zap"
"log/slog"
)
var (
@@ -160,57 +159,63 @@ type Bookmark struct {
type PdfEngine interface {
// Merge combines multiple PDFs into a single PDF. The resulting page order
// is determined by the order of files provided in inputPaths.
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
// Split splits a given PDF file.
Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
Split(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
// Flatten merges existing annotation appearances with page content,
// effectively deleting the original annotations. This process can flatten
// forms as well as forms share a relationship with annotations. Note that
// this operation is irreversible.
Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error
Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error
// Convert transforms a given PDF to the specified formats defined in
// PdfFormats. If no format, it does nothing.
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
// ReadMetadata extracts the metadata of a given PDF file.
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error)
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
// PageCount returns the number of pages in a PDF file.
PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error)
PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
// WriteMetadata writes the metadata into a given PDF file.
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error
WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
// ReadBookmarks reads the document outline (bookmarks) of a PDF file.
ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error)
ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
// WriteBookmarks adds a document outline (bookmarks) to a PDF file.
// The bookmarks parameter represents the hierarchical tree of the outline.
WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
// Encrypt adds password protection to a PDF file.
// The userPassword is required to open the document.
// The ownerPassword provides full access to the document.
// If the ownerPassword is empty, it defaults to the userPassword.
Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
// TODO: attachments instead? Rename the route?
EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
// EmbedFilesMetadata sets metadata (such as MIME type and AFRelationship)
// on already-embedded files in a PDF. The metadata map is keyed by
// filename, with each value being a map of property names to values
// (e.g., "mimeType" and "relationship").
EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
// Watermark applies a watermark (behind page content) to a PDF file.
Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
// Stamp applies a stamp (on top of page content) to a PDF file.
Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
// If pages is empty, all pages are rotated.
Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
}
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].

View File

@@ -0,0 +1,48 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/bench_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv
import (
"net/http"
"net/url"
"testing"
"go.opentelemetry.io/otel/attribute"
)
var benchHTTPServerRequestResults []attribute.KeyValue
// BenchmarkHTTPServerRequest allows comparison between different version of the HTTP server.
// To use an alternative start this test with OTEL_SEMCONV_STABILITY_OPT_IN set to the
// version under test.
func BenchmarkHTTPServerRequest(b *testing.B) {
// Request was generated from TestHTTPServerRequest request.
req := &http.Request{
Method: http.MethodGet,
URL: &url.URL{
Path: "/",
},
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{
"User-Agent": []string{"Go-http-client/1.1"},
"Accept-Encoding": []string{"gzip"},
},
Body: http.NoBody,
Host: "127.0.0.1:39093",
RemoteAddr: "127.0.0.1:38738",
RequestURI: "/",
}
serv := NewHTTPServer(nil)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
benchHTTPServerRequestResults = serv.RequestTraceAttrs("", req, RequestTraceAttrsOpts{})
}
}

View File

@@ -0,0 +1,308 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/client.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
// Package semconv provides OpenTelemetry semantic convention types and
// functionality.
package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv"
import (
"context"
"fmt"
"net/http"
"reflect"
"slices"
"strconv"
"strings"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/semconv/v1.39.0"
"go.opentelemetry.io/otel/semconv/v1.39.0/httpconv"
)
type HTTPClient struct {
requestBodySize httpconv.ClientRequestBodySize
requestDuration httpconv.ClientRequestDuration
}
func NewHTTPClient(meter metric.Meter) HTTPClient {
client := HTTPClient{}
var err error
client.requestBodySize, err = httpconv.NewClientRequestBodySize(meter)
handleErr(err)
client.requestDuration, err = httpconv.NewClientRequestDuration(
meter,
metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10),
)
handleErr(err)
return client
}
func (n HTTPClient) Status(code int) (codes.Code, string) {
if code == 0 {
return codes.Error, "No HTTP status code"
}
if code < 100 || code >= 600 {
return codes.Error, fmt.Sprintf("Invalid HTTP status code %d", code)
}
if code >= 400 {
return codes.Error, http.StatusText(code)
}
return codes.Ok, ""
}
// RequestTraceAttrs returns trace attributes for an HTTP request made by a client.
func (n HTTPClient) RequestTraceAttrs(req *http.Request) []attribute.KeyValue {
/*
below attributes are returned:
- http.request.method
- http.request.method.original
- url.full
- server.address
- server.port
- network.protocol.name
- network.protocol.version
*/
numOfAttributes := 3 // URL, server address, proto, and method.
var urlHost string
if req.URL != nil {
urlHost = req.URL.Host
}
var requestHost string
var requestPort int
for _, hostport := range []string{urlHost, req.Header.Get("Host")} {
requestHost, requestPort = SplitHostPort(hostport)
if requestHost != "" || requestPort > 0 {
break
}
}
eligiblePort := requiredHTTPPort(req.URL != nil && req.URL.Scheme == "https", requestPort)
if eligiblePort > 0 {
numOfAttributes++
}
useragent := req.UserAgent()
if useragent != "" {
numOfAttributes++
}
protoName, protoVersion := netProtocol(req.Proto)
if protoName != "" && protoName != "http" {
numOfAttributes++
}
if protoVersion != "" {
numOfAttributes++
}
method, originalMethod := n.method(req.Method)
if originalMethod != (attribute.KeyValue{}) {
numOfAttributes++
}
attrs := make([]attribute.KeyValue, 0, numOfAttributes)
attrs = append(attrs, method)
if originalMethod != (attribute.KeyValue{}) {
attrs = append(attrs, originalMethod)
}
var u string
if req.URL != nil {
// Remove any username/password info that may be in the URL.
userinfo := req.URL.User
req.URL.User = nil
u = req.URL.String()
// Restore any username/password info that was removed.
req.URL.User = userinfo
}
attrs = append(attrs, semconv.URLFull(u))
attrs = append(attrs, semconv.ServerAddress(requestHost))
if eligiblePort > 0 {
attrs = append(attrs, semconv.ServerPort(eligiblePort))
}
if protoName != "" && protoName != "http" {
attrs = append(attrs, semconv.NetworkProtocolName(protoName))
}
if protoVersion != "" {
attrs = append(attrs, semconv.NetworkProtocolVersion(protoVersion))
}
return attrs
}
// ResponseTraceAttrs returns trace attributes for an HTTP response made by a client.
func (n HTTPClient) ResponseTraceAttrs(resp *http.Response) []attribute.KeyValue {
/*
below attributes are returned:
- http.response.status_code
- error.type
*/
var count int
if resp.StatusCode > 0 {
count++
}
if isErrorStatusCode(resp.StatusCode) {
count++
}
attrs := make([]attribute.KeyValue, 0, count)
if resp.StatusCode > 0 {
attrs = append(attrs, semconv.HTTPResponseStatusCode(resp.StatusCode))
}
if isErrorStatusCode(resp.StatusCode) {
errorType := strconv.Itoa(resp.StatusCode)
attrs = append(attrs, semconv.ErrorTypeKey.String(errorType))
}
return attrs
}
func (n HTTPClient) ErrorType(err error) attribute.KeyValue {
t := reflect.TypeOf(err)
var value string
if t.PkgPath() == "" && t.Name() == "" {
// Likely a builtin type.
value = t.String()
} else {
value = fmt.Sprintf("%s.%s", t.PkgPath(), t.Name())
}
if value == "" {
return semconv.ErrorTypeOther
}
return semconv.ErrorTypeKey.String(value)
}
func (n HTTPClient) method(method string) (attribute.KeyValue, attribute.KeyValue) {
if method == "" {
return semconv.HTTPRequestMethodGet, attribute.KeyValue{}
}
if attr, ok := methodLookup[method]; ok {
return attr, attribute.KeyValue{}
}
orig := semconv.HTTPRequestMethodOriginal(method)
if attr, ok := methodLookup[strings.ToUpper(method)]; ok {
return attr, orig
}
return semconv.HTTPRequestMethodGet, orig
}
func (n HTTPClient) MetricAttributes(req *http.Request, statusCode int, additionalAttributes []attribute.KeyValue) []attribute.KeyValue {
num := len(additionalAttributes) + 2
var h string
if req.URL != nil {
h = req.URL.Host
}
var requestHost string
var requestPort int
for _, hostport := range []string{h, req.Header.Get("Host")} {
requestHost, requestPort = SplitHostPort(hostport)
if requestHost != "" || requestPort > 0 {
break
}
}
port := requiredHTTPPort(req.URL != nil && req.URL.Scheme == "https", requestPort)
if port > 0 {
num++
}
protoName, protoVersion := netProtocol(req.Proto)
if protoName != "" {
num++
}
if protoVersion != "" {
num++
}
if statusCode > 0 {
num++
}
attributes := slices.Grow(additionalAttributes, num)
attributes = append(attributes,
semconv.HTTPRequestMethodKey.String(standardizeHTTPMethod(req.Method)),
semconv.ServerAddress(requestHost),
n.scheme(req),
)
if port > 0 {
attributes = append(attributes, semconv.ServerPort(port))
}
if protoName != "" {
attributes = append(attributes, semconv.NetworkProtocolName(protoName))
}
if protoVersion != "" {
attributes = append(attributes, semconv.NetworkProtocolVersion(protoVersion))
}
if statusCode > 0 {
attributes = append(attributes, semconv.HTTPResponseStatusCode(statusCode))
}
return attributes
}
type MetricOpts struct {
measurement metric.MeasurementOption
addOptions metric.AddOption
}
func (o MetricOpts) MeasurementOption() metric.MeasurementOption {
return o.measurement
}
func (o MetricOpts) AddOptions() metric.AddOption {
return o.addOptions
}
func (n HTTPClient) MetricOptions(ma MetricAttributes) map[string]MetricOpts {
opts := map[string]MetricOpts{}
attributes := n.MetricAttributes(ma.Req, ma.StatusCode, ma.AdditionalAttributes)
set := metric.WithAttributeSet(attribute.NewSet(attributes...))
opts["new"] = MetricOpts{
measurement: set,
addOptions: set,
}
return opts
}
func (n HTTPClient) RecordMetrics(ctx context.Context, md MetricData, opts map[string]MetricOpts) {
n.requestBodySize.Inst().Record(ctx, md.RequestSize, opts["new"].MeasurementOption())
n.requestDuration.Inst().Record(ctx, md.ElapsedTime/1000, opts["new"].MeasurementOption())
}
// TraceAttributes returns attributes for httptrace.
func (n HTTPClient) TraceAttributes(host string) []attribute.KeyValue {
return []attribute.KeyValue{
semconv.ServerAddress(host),
}
}
func (n HTTPClient) scheme(req *http.Request) attribute.KeyValue {
if req.URL != nil && req.URL.Scheme != "" {
return semconv.URLScheme(req.URL.Scheme)
}
if req.TLS != nil {
return semconv.URLScheme("https")
}
return semconv.URLScheme("http")
}
func isErrorStatusCode(code int) bool {
return code >= 400 || code < 100
}

View File

@@ -0,0 +1,182 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/client_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv
import (
"net/http"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
func TestHTTPClientStatus(t *testing.T) {
tests := []struct {
code int
stat codes.Code
msg bool
}{
{0, codes.Error, true},
{http.StatusContinue, codes.Ok, false},
{http.StatusSwitchingProtocols, codes.Ok, false},
{http.StatusProcessing, codes.Ok, false},
{http.StatusEarlyHints, codes.Ok, false},
{http.StatusOK, codes.Ok, false},
{http.StatusCreated, codes.Ok, false},
{http.StatusAccepted, codes.Ok, false},
{http.StatusNonAuthoritativeInfo, codes.Ok, false},
{http.StatusNoContent, codes.Ok, false},
{http.StatusResetContent, codes.Ok, false},
{http.StatusPartialContent, codes.Ok, false},
{http.StatusMultiStatus, codes.Ok, false},
{http.StatusAlreadyReported, codes.Ok, false},
{http.StatusIMUsed, codes.Ok, false},
{http.StatusMultipleChoices, codes.Ok, false},
{http.StatusMovedPermanently, codes.Ok, false},
{http.StatusFound, codes.Ok, false},
{http.StatusSeeOther, codes.Ok, false},
{http.StatusNotModified, codes.Ok, false},
{http.StatusUseProxy, codes.Ok, false},
{306, codes.Ok, false},
{http.StatusTemporaryRedirect, codes.Ok, false},
{http.StatusPermanentRedirect, codes.Ok, false},
{http.StatusBadRequest, codes.Error, true},
{http.StatusUnauthorized, codes.Error, true},
{http.StatusPaymentRequired, codes.Error, true},
{http.StatusForbidden, codes.Error, true},
{http.StatusNotFound, codes.Error, true},
{http.StatusMethodNotAllowed, codes.Error, true},
{http.StatusNotAcceptable, codes.Error, true},
{http.StatusProxyAuthRequired, codes.Error, true},
{http.StatusRequestTimeout, codes.Error, true},
{http.StatusConflict, codes.Error, true},
{http.StatusGone, codes.Error, true},
{http.StatusLengthRequired, codes.Error, true},
{http.StatusPreconditionFailed, codes.Error, true},
{http.StatusRequestEntityTooLarge, codes.Error, true},
{http.StatusRequestURITooLong, codes.Error, true},
{http.StatusUnsupportedMediaType, codes.Error, true},
{http.StatusRequestedRangeNotSatisfiable, codes.Error, true},
{http.StatusExpectationFailed, codes.Error, true},
{http.StatusTeapot, codes.Error, true},
{http.StatusMisdirectedRequest, codes.Error, true},
{http.StatusUnprocessableEntity, codes.Error, true},
{http.StatusLocked, codes.Error, true},
{http.StatusFailedDependency, codes.Error, true},
{http.StatusTooEarly, codes.Error, true},
{http.StatusUpgradeRequired, codes.Error, true},
{http.StatusPreconditionRequired, codes.Error, true},
{http.StatusTooManyRequests, codes.Error, true},
{http.StatusRequestHeaderFieldsTooLarge, codes.Error, true},
{http.StatusUnavailableForLegalReasons, codes.Error, true},
{499, codes.Error, false},
{http.StatusInternalServerError, codes.Error, true},
{http.StatusNotImplemented, codes.Error, true},
{http.StatusBadGateway, codes.Error, true},
{http.StatusServiceUnavailable, codes.Error, true},
{http.StatusGatewayTimeout, codes.Error, true},
{http.StatusHTTPVersionNotSupported, codes.Error, true},
{http.StatusVariantAlsoNegotiates, codes.Error, true},
{http.StatusInsufficientStorage, codes.Error, true},
{http.StatusLoopDetected, codes.Error, true},
{http.StatusNotExtended, codes.Error, true},
{http.StatusNetworkAuthenticationRequired, codes.Error, true},
{600, codes.Error, true},
}
for _, test := range tests {
t.Run(strconv.Itoa(test.code), func(t *testing.T) {
c, msg := HTTPClient{}.Status(test.code)
assert.Equal(t, test.stat, c)
if test.msg && msg == "" {
t.Errorf("expected non-empty message for %d", test.code)
} else if !test.msg && msg != "" {
t.Errorf("expected empty message for %d, got: %s", test.code, msg)
}
})
}
}
func TestHTTPClient_MetricAttributes(t *testing.T) {
defaultRequest, err := http.NewRequest("GET", "http://example.com/path?query=test", http.NoBody)
require.NoError(t, err)
httpsRequest, err := http.NewRequest("GET", "https://example.com/path?query=test", http.NoBody)
require.NoError(t, err)
tests := []struct {
name string
server string
req *http.Request
statusCode int
additionalAttributes []attribute.KeyValue
wantFunc func(t *testing.T, attrs []attribute.KeyValue)
}{
{
name: "routine testing",
req: defaultRequest,
statusCode: 200,
additionalAttributes: []attribute.KeyValue{attribute.String("test", "test")},
wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
require.Len(t, attrs, 7)
assert.ElementsMatch(t, []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("server.address", "example.com"),
attribute.String("url.scheme", "http"),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.Int64("http.response.status_code", 200),
attribute.String("test", "test"),
}, attrs)
},
},
{
name: "use server address",
req: defaultRequest,
statusCode: 200,
additionalAttributes: nil,
wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
require.Len(t, attrs, 6)
assert.ElementsMatch(t, []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("server.address", "example.com"),
attribute.String("url.scheme", "http"),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.Int64("http.response.status_code", 200),
}, attrs)
},
},
{
name: "https scheme",
req: httpsRequest,
statusCode: 200,
additionalAttributes: nil,
wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
require.Len(t, attrs, 6)
assert.ElementsMatch(t, []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("server.address", "example.com"),
attribute.String("url.scheme", "https"),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.Int64("http.response.status_code", 200),
}, attrs)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := HTTPClient{}.MetricAttributes(tt.req, tt.statusCode, tt.additionalAttributes)
tt.wantFunc(t, got)
})
}
}

View File

@@ -0,0 +1,71 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/common_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
"go.opentelemetry.io/otel/attribute"
)
type testServerReq struct {
hostname string
serverPort int
peerAddr string
peerPort int
clientIP string
}
func testTraceRequest(t *testing.T, serv semconv.HTTPServer, want func(testServerReq) []attribute.KeyValue) {
t.Helper()
got := make(chan *http.Request, 1)
handler := func(w http.ResponseWriter, r *http.Request) {
got <- r
close(got)
w.WriteHeader(http.StatusOK)
}
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
srvURL, err := url.Parse(srv.URL)
require.NoError(t, err)
srvPort, err := strconv.ParseInt(srvURL.Port(), 10, 32)
require.NoError(t, err)
resp, err := srv.Client().Get(srv.URL)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
req := <-got
peer, peerPort := semconv.SplitHostPort(req.RemoteAddr)
const user = "alice"
req.SetBasicAuth(user, "pswrd")
const clientIP = "127.0.0.5"
req.Header.Add("X-Forwarded-For", clientIP)
srvReq := testServerReq{
hostname: srvURL.Hostname(),
serverPort: int(srvPort),
peerAddr: peer,
peerPort: peerPort,
clientIP: clientIP,
}
assert.ElementsMatch(t, want(srvReq), serv.RequestTraceAttrs("", req, semconv.RequestTraceAttrsOpts{}))
}

View File

@@ -0,0 +1,5 @@
// Package semconv contains utilities not yet exposed in the OpenTelemetry Go
// SDK. Remove this package once an official API exists.
//
// See https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4580.
package semconv

View File

@@ -0,0 +1,15 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv"
// Generate semconv package:
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/bench_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=bench_test.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/common_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=common_test.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/server.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=server.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/server_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=server_test.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/client.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=client.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/client_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=client_test.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/httpconvtest_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=httpconvtest_test.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/util.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=util.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/util_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=util_test.go

View File

@@ -0,0 +1,455 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/httpconv_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv_test
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/sdk/instrumentation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest"
)
func TestNewTraceRequest(t *testing.T) {
serv := semconv.NewHTTPServer(nil)
want := func(req testServerReq) []attribute.KeyValue {
return []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("url.scheme", "http"),
attribute.String("server.address", req.hostname),
attribute.Int("server.port", req.serverPort),
attribute.String("network.peer.address", req.peerAddr),
attribute.Int("network.peer.port", req.peerPort),
attribute.String("user_agent.original", "Go-http-client/1.1"),
attribute.String("client.address", req.clientIP),
attribute.String("network.protocol.version", "1.1"),
attribute.String("url.path", "/"),
}
}
testTraceRequest(t, serv, want)
}
func TestNewServerRecordMetrics(t *testing.T) {
oldAttrs := attribute.NewSet(
attribute.String("http.scheme", "http"),
attribute.String("http.method", "POST"),
attribute.Int64("http.status_code", 301),
attribute.String("key", "value"),
attribute.String("net.host.name", "stuff"),
attribute.String("net.protocol.name", "http"),
attribute.String("net.protocol.version", "1.1"),
)
currAttrs := attribute.NewSet(
attribute.String("http.request.method", "POST"),
attribute.Int64("http.response.status_code", 301),
attribute.String("key", "value"),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.String("server.address", "stuff"),
attribute.String("url.scheme", "http"),
)
// the HTTPServer version
expectedCurrentScopeMetric := metricdata.ScopeMetrics{
Scope: instrumentation.Scope{
Name: "test",
},
Metrics: []metricdata.Metrics{
{
Name: "http.server.request.body.size",
Description: "Size of HTTP server request bodies.",
Unit: "By",
Data: metricdata.Histogram[int64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[int64]{
{
Attributes: currAttrs,
},
},
},
},
{
Name: "http.server.response.body.size",
Description: "Size of HTTP server response bodies.",
Unit: "By",
Data: metricdata.Histogram[int64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[int64]{
{
Attributes: currAttrs,
},
},
},
},
{
Name: "http.server.request.duration",
Description: "Duration of HTTP server requests.",
Unit: "s",
Data: metricdata.Histogram[float64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[float64]{
{
Attributes: currAttrs,
},
},
},
},
},
}
// The OldHTTPServer version
expectedOldScopeMetric := expectedCurrentScopeMetric
expectedOldScopeMetric.Metrics = append(expectedOldScopeMetric.Metrics, []metricdata.Metrics{
{
Name: "http.server.request.size",
Description: "Measures the size of HTTP request messages.",
Unit: "By",
Data: metricdata.Sum[int64]{
Temporality: metricdata.CumulativeTemporality,
IsMonotonic: true,
DataPoints: []metricdata.DataPoint[int64]{
{
Attributes: oldAttrs,
},
},
},
},
{
Name: "http.server.response.size",
Description: "Measures the size of HTTP response messages.",
Unit: "By",
Data: metricdata.Sum[int64]{
Temporality: metricdata.CumulativeTemporality,
IsMonotonic: true,
DataPoints: []metricdata.DataPoint[int64]{
{
Attributes: oldAttrs,
},
},
},
},
{
Name: "http.server.duration",
Description: "Measures the duration of inbound HTTP requests.",
Unit: "ms",
Data: metricdata.Histogram[float64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[float64]{
{
Attributes: oldAttrs,
},
},
},
},
}...)
tests := []struct {
name string
serverFunc func(metric.MeterProvider) semconv.HTTPServer
wantFunc func(t *testing.T, rm metricdata.ResourceMetrics)
}{
{
name: "No Meter",
serverFunc: func(metric.MeterProvider) semconv.HTTPServer {
return semconv.NewHTTPServer(nil)
},
wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
assert.Empty(t, rm.ScopeMetrics)
},
},
{
name: "With Meter",
serverFunc: func(mp metric.MeterProvider) semconv.HTTPServer {
return semconv.NewHTTPServer(mp.Meter("test"))
},
wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
require.Len(t, rm.ScopeMetrics, 1)
// because of OldHTTPServer
require.Len(t, rm.ScopeMetrics[0].Metrics, 3)
metricdatatest.AssertEqual(t, expectedCurrentScopeMetric, rm.ScopeMetrics[0], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := sdkmetric.NewManualReader()
mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
server := tt.serverFunc(mp)
req, err := http.NewRequest("POST", "http://example.com", http.NoBody)
assert.NoError(t, err)
server.RecordMetrics(t.Context(), semconv.ServerMetricData{
ServerName: "stuff",
ResponseSize: 200,
MetricAttributes: semconv.MetricAttributes{
Req: req,
StatusCode: 301,
AdditionalAttributes: []attribute.KeyValue{
attribute.String("key", "value"),
},
},
MetricData: semconv.MetricData{
RequestSize: 100,
ElapsedTime: 300,
},
})
rm := metricdata.ResourceMetrics{}
require.NoError(t, reader.Collect(t.Context(), &rm))
tt.wantFunc(t, rm)
})
}
}
func TestNewTraceResponse(t *testing.T) {
testCases := []struct {
name string
resp semconv.ResponseTelemetry
want []attribute.KeyValue
}{
{
name: "empty",
resp: semconv.ResponseTelemetry{},
want: nil,
},
{
name: "no errors",
resp: semconv.ResponseTelemetry{
StatusCode: 200,
ReadBytes: 701,
WriteBytes: 802,
},
want: []attribute.KeyValue{
attribute.Int("http.request.body.size", 701),
attribute.Int("http.response.body.size", 802),
attribute.Int("http.response.status_code", 200),
},
},
{
name: "with errors",
resp: semconv.ResponseTelemetry{
StatusCode: 200,
ReadBytes: 701,
ReadError: fmt.Errorf("read error"),
WriteBytes: 802,
WriteError: fmt.Errorf("write error"),
},
want: []attribute.KeyValue{
attribute.Int("http.request.body.size", 701),
attribute.Int("http.response.body.size", 802),
attribute.Int("http.response.status_code", 200),
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := semconv.HTTPServer{}.ResponseTraceAttrs(tt.resp)
assert.ElementsMatch(t, tt.want, got)
})
}
}
func TestNewTraceRequest_Client(t *testing.T) {
body := strings.NewReader("Hello, world!")
url := "https://example.com:8888/foo/bar?stuff=morestuff"
req := httptest.NewRequest("pOST", url, body)
req.Header.Set("User-Agent", "go-test-agent")
want := []attribute.KeyValue{
attribute.String("http.request.method", "POST"),
attribute.String("http.request.method_original", "pOST"),
attribute.String("url.full", url),
attribute.String("server.address", "example.com"),
attribute.Int("server.port", 8888),
attribute.String("network.protocol.version", "1.1"),
}
client := semconv.NewHTTPClient(nil)
assert.ElementsMatch(t, want, client.RequestTraceAttrs(req))
}
func TestNewTraceResponse_Client(t *testing.T) {
testcases := []struct {
resp http.Response
want []attribute.KeyValue
}{
{resp: http.Response{StatusCode: 200, ContentLength: 123}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 200)}},
{resp: http.Response{StatusCode: 404, ContentLength: 0}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 404), attribute.String("error.type", "404")}},
}
for _, tt := range testcases {
client := semconv.NewHTTPClient(nil)
assert.ElementsMatch(t, tt.want, client.ResponseTraceAttrs(&tt.resp))
}
}
func TestClientRequest(t *testing.T) {
body := strings.NewReader("Hello, world!")
url := "https://example.com:8888/foo/bar?stuff=morestuff"
req := httptest.NewRequest("pOST", url, body)
req.Header.Set("User-Agent", "go-test-agent")
want := []attribute.KeyValue{
attribute.String("http.request.method", "POST"),
attribute.String("http.request.method_original", "pOST"),
attribute.String("url.full", url),
attribute.String("server.address", "example.com"),
attribute.Int("server.port", 8888),
attribute.String("network.protocol.version", "1.1"),
}
got := semconv.HTTPClient{}.RequestTraceAttrs(req)
assert.ElementsMatch(t, want, got)
}
func TestClientResponse(t *testing.T) {
testcases := []struct {
resp http.Response
want []attribute.KeyValue
}{
{resp: http.Response{StatusCode: 200, ContentLength: 123}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 200)}},
{resp: http.Response{StatusCode: 404, ContentLength: 0}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 404), attribute.String("error.type", "404")}},
}
for _, tt := range testcases {
got := semconv.HTTPClient{}.ResponseTraceAttrs(&tt.resp)
assert.ElementsMatch(t, tt.want, got)
}
}
func TestRequestErrorType(t *testing.T) {
testcases := []struct {
err error
want attribute.KeyValue
}{
{err: errors.New("http: nil Request.URL"), want: attribute.String("error.type", "*errors.errorString")},
{err: customError{}, want: attribute.String("error.type", "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv_test.customError")},
}
for _, tt := range testcases {
got := semconv.HTTPClient{}.ErrorType(tt.err)
assert.Equal(t, tt.want, got)
}
}
func TestNewClientRecordMetrics(t *testing.T) {
currAttrs := attribute.NewSet(
attribute.String("http.request.method", "POST"),
attribute.Int64("http.response.status_code", 301),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.String("server.address", "example.com"),
attribute.String("url.scheme", "http"),
)
// the HTTPClient version
expectedCurrentScopeMetric := metricdata.ScopeMetrics{
Scope: instrumentation.Scope{
Name: "test",
},
Metrics: []metricdata.Metrics{
{
Name: "http.client.request.body.size",
Description: "Size of HTTP client request bodies.",
Unit: "By",
Data: metricdata.Histogram[int64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[int64]{
{
Attributes: currAttrs,
},
},
},
},
{
Name: "http.client.request.duration",
Description: "Duration of HTTP client requests.",
Unit: "s",
Data: metricdata.Histogram[float64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[float64]{
{
Attributes: currAttrs,
},
},
},
},
},
}
tests := []struct {
name string
clientFunc func(metric.MeterProvider) semconv.HTTPClient
wantFunc func(t *testing.T, rm metricdata.ResourceMetrics)
}{
{
name: "No environment variable set, and no Meter",
clientFunc: func(metric.MeterProvider) semconv.HTTPClient {
return semconv.NewHTTPClient(nil)
},
wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
assert.Empty(t, rm.ScopeMetrics)
},
},
{
name: "With Meter",
clientFunc: func(mp metric.MeterProvider) semconv.HTTPClient {
return semconv.NewHTTPClient(mp.Meter("test"))
},
wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
require.Len(t, rm.ScopeMetrics, 1)
require.Len(t, rm.ScopeMetrics[0].Metrics, 2)
metricdatatest.AssertEqual(t, expectedCurrentScopeMetric, rm.ScopeMetrics[0], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := sdkmetric.NewManualReader()
mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
client := tt.clientFunc(mp)
req, err := http.NewRequest("POST", "http://example.com", http.NoBody)
assert.NoError(t, err)
client.RecordMetrics(t.Context(), semconv.MetricData{
RequestSize: 100,
ElapsedTime: 300,
}, client.MetricOptions(semconv.MetricAttributes{
Req: req,
StatusCode: 301,
}))
rm := metricdata.ResourceMetrics{}
require.NoError(t, reader.Collect(t.Context(), &rm))
tt.wantFunc(t, rm)
})
}
}
type customError struct{}
func (customError) Error() string {
return "custom error"
}

View File

@@ -0,0 +1,405 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/server.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
// Package semconv provides OpenTelemetry semantic convention types and
// functionality.
package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv"
import (
"context"
"fmt"
"net/http"
"slices"
"strings"
"sync"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/semconv/v1.39.0"
"go.opentelemetry.io/otel/semconv/v1.39.0/httpconv"
)
type RequestTraceAttrsOpts struct {
// If set, this is used as value for the "http.client_ip" attribute.
HTTPClientIP string
}
type ResponseTelemetry struct {
StatusCode int
ReadBytes int64
ReadError error
WriteBytes int64
WriteError error
}
type HTTPServer struct {
requestBodySizeHistogram httpconv.ServerRequestBodySize
responseBodySizeHistogram httpconv.ServerResponseBodySize
requestDurationHistogram httpconv.ServerRequestDuration
}
func NewHTTPServer(meter metric.Meter) HTTPServer {
server := HTTPServer{}
var err error
server.requestBodySizeHistogram, err = httpconv.NewServerRequestBodySize(meter)
handleErr(err)
server.responseBodySizeHistogram, err = httpconv.NewServerResponseBodySize(meter)
handleErr(err)
server.requestDurationHistogram, err = httpconv.NewServerRequestDuration(
meter,
metric.WithExplicitBucketBoundaries(
0.005, 0.01, 0.025, 0.05, 0.075, 0.1,
0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
),
)
handleErr(err)
return server
}
// Status returns a span status code and message for an HTTP status code
// value returned by a server.
func (n HTTPServer) Status(code int) (codes.Code, string) {
if code == 0 {
return codes.Error, "No HTTP status code"
}
if code < 100 || code >= 600 {
return codes.Error, fmt.Sprintf("Invalid HTTP status code %d", code)
}
if code >= 400 {
return codes.Error, http.StatusText(code)
}
return codes.Ok, ""
}
// RequestTraceAttrs returns trace attributes for an HTTP request received by a
// server.
//
// The server must be the primary server name if it is known. For example this
// would be the ServerName directive
// (https://httpd.apache.org/docs/2.4/mod/core.html#servername) for an Apache
// server, and the server_name directive
// (http://nginx.org/en/docs/http/ngx_http_core_module.html#server_name) for an
// nginx server. More generically, the primary server name would be the host
// header value that matches the default virtual host of an HTTP server. It
// should include the host identifier and if a port is used to route to the
// server that port identifier should be included as an appropriate port
// suffix.
//
// If the primary server name is not known, server should be an empty string.
// The req Host will be used to determine the server instead.
func (n HTTPServer) RequestTraceAttrs(server string, req *http.Request, opts RequestTraceAttrsOpts) []attribute.KeyValue {
count := 3 // ServerAddress, Method, Scheme
var host string
var p int
if server == "" {
host, p = SplitHostPort(req.Host)
} else {
// Prioritize the primary server name.
host, p = SplitHostPort(server)
if p < 0 {
_, p = SplitHostPort(req.Host)
}
}
hostPort := requiredHTTPPort(req.TLS != nil, p)
if hostPort > 0 {
count++
}
method, methodOriginal := n.method(req.Method)
if methodOriginal != (attribute.KeyValue{}) {
count++
}
scheme := n.scheme(req.TLS != nil)
peer, peerPort := SplitHostPort(req.RemoteAddr)
if peer != "" {
// The Go HTTP server sets RemoteAddr to "IP:port", this will not be a
// file-path that would be interpreted with a sock family.
count++
if peerPort > 0 {
count++
}
}
useragent := req.UserAgent()
if useragent != "" {
count++
}
// For client IP, use, in order:
// 1. The value passed in the options
// 2. The value in the X-Forwarded-For header
// 3. The peer address
clientIP := opts.HTTPClientIP
if clientIP == "" {
clientIP = serverClientIP(req.Header.Get("X-Forwarded-For"))
if clientIP == "" {
clientIP = peer
}
}
if clientIP != "" {
count++
}
if req.URL != nil && req.URL.Path != "" {
count++
}
protoName, protoVersion := netProtocol(req.Proto)
if protoName != "" && protoName != "http" {
count++
}
if protoVersion != "" {
count++
}
route := httpRoute(req.Pattern)
if route != "" {
count++
}
attrs := make([]attribute.KeyValue, 0, count)
attrs = append(attrs,
semconv.ServerAddress(host),
method,
scheme,
)
if hostPort > 0 {
attrs = append(attrs, semconv.ServerPort(hostPort))
}
if methodOriginal != (attribute.KeyValue{}) {
attrs = append(attrs, methodOriginal)
}
if peer, peerPort := SplitHostPort(req.RemoteAddr); peer != "" {
// The Go HTTP server sets RemoteAddr to "IP:port", this will not be a
// file-path that would be interpreted with a sock family.
attrs = append(attrs, semconv.NetworkPeerAddress(peer))
if peerPort > 0 {
attrs = append(attrs, semconv.NetworkPeerPort(peerPort))
}
}
if useragent != "" {
attrs = append(attrs, semconv.UserAgentOriginal(useragent))
}
if clientIP != "" {
attrs = append(attrs, semconv.ClientAddress(clientIP))
}
if req.URL != nil && req.URL.Path != "" {
attrs = append(attrs, semconv.URLPath(req.URL.Path))
}
if protoName != "" && protoName != "http" {
attrs = append(attrs, semconv.NetworkProtocolName(protoName))
}
if protoVersion != "" {
attrs = append(attrs, semconv.NetworkProtocolVersion(protoVersion))
}
if route != "" {
attrs = append(attrs, n.Route(route))
}
return attrs
}
func (s HTTPServer) NetworkTransportAttr(network string) []attribute.KeyValue {
attr := semconv.NetworkTransportPipe
switch network {
case "tcp", "tcp4", "tcp6":
attr = semconv.NetworkTransportTCP
case "udp", "udp4", "udp6":
attr = semconv.NetworkTransportUDP
case "unix", "unixgram", "unixpacket":
attr = semconv.NetworkTransportUnix
}
return []attribute.KeyValue{attr}
}
type ServerMetricData struct {
ServerName string
ResponseSize int64
MetricData
MetricAttributes
}
type MetricAttributes struct {
Req *http.Request
StatusCode int
Route string
AdditionalAttributes []attribute.KeyValue
}
type MetricData struct {
RequestSize int64
// The request duration, in milliseconds
ElapsedTime float64
}
var (
metricAddOptionPool = &sync.Pool{
New: func() any {
return &[]metric.AddOption{}
},
}
metricRecordOptionPool = &sync.Pool{
New: func() any {
return &[]metric.RecordOption{}
},
}
)
func (n HTTPServer) RecordMetrics(ctx context.Context, md ServerMetricData) {
attributes := n.MetricAttributes(md.ServerName, md.Req, md.StatusCode, md.Route, md.AdditionalAttributes)
o := metric.WithAttributeSet(attribute.NewSet(attributes...))
recordOpts := metricRecordOptionPool.Get().(*[]metric.RecordOption)
*recordOpts = append(*recordOpts, o)
n.requestBodySizeHistogram.Inst().Record(ctx, md.RequestSize, *recordOpts...)
n.responseBodySizeHistogram.Inst().Record(ctx, md.ResponseSize, *recordOpts...)
n.requestDurationHistogram.Inst().Record(ctx, md.ElapsedTime/1000.0, o)
*recordOpts = (*recordOpts)[:0]
metricRecordOptionPool.Put(recordOpts)
}
func (n HTTPServer) method(method string) (attribute.KeyValue, attribute.KeyValue) {
if method == "" {
return semconv.HTTPRequestMethodGet, attribute.KeyValue{}
}
if attr, ok := methodLookup[method]; ok {
return attr, attribute.KeyValue{}
}
orig := semconv.HTTPRequestMethodOriginal(method)
if attr, ok := methodLookup[strings.ToUpper(method)]; ok {
return attr, orig
}
return semconv.HTTPRequestMethodGet, orig
}
func (n HTTPServer) scheme(https bool) attribute.KeyValue { //nolint:revive // ignore linter
if https {
return semconv.URLScheme("https")
}
return semconv.URLScheme("http")
}
// ResponseTraceAttrs returns trace attributes for telemetry from an HTTP
// response.
//
// If any of the fields in the ResponseTelemetry are not set the attribute will
// be omitted.
func (n HTTPServer) ResponseTraceAttrs(resp ResponseTelemetry) []attribute.KeyValue {
var count int
if resp.ReadBytes > 0 {
count++
}
if resp.WriteBytes > 0 {
count++
}
if resp.StatusCode > 0 {
count++
}
attributes := make([]attribute.KeyValue, 0, count)
if resp.ReadBytes > 0 {
attributes = append(attributes,
semconv.HTTPRequestBodySize(int(resp.ReadBytes)),
)
}
if resp.WriteBytes > 0 {
attributes = append(attributes,
semconv.HTTPResponseBodySize(int(resp.WriteBytes)),
)
}
if resp.StatusCode > 0 {
attributes = append(attributes,
semconv.HTTPResponseStatusCode(resp.StatusCode),
)
}
return attributes
}
// Route returns the attribute for the route.
func (n HTTPServer) Route(route string) attribute.KeyValue {
return semconv.HTTPRoute(route)
}
func (n HTTPServer) MetricAttributes(server string, req *http.Request, statusCode int, route string, additionalAttributes []attribute.KeyValue) []attribute.KeyValue {
num := len(additionalAttributes) + 3
var host string
var p int
if server == "" {
host, p = SplitHostPort(req.Host)
} else {
// Prioritize the primary server name.
host, p = SplitHostPort(server)
if p < 0 {
_, p = SplitHostPort(req.Host)
}
}
hostPort := requiredHTTPPort(req.TLS != nil, p)
if hostPort > 0 {
num++
}
protoName, protoVersion := netProtocol(req.Proto)
if protoName != "" {
num++
}
if protoVersion != "" {
num++
}
if statusCode > 0 {
num++
}
if route != "" {
num++
}
attributes := slices.Grow(additionalAttributes, num)
attributes = append(attributes,
semconv.HTTPRequestMethodKey.String(standardizeHTTPMethod(req.Method)),
n.scheme(req.TLS != nil),
semconv.ServerAddress(host))
if hostPort > 0 {
attributes = append(attributes, semconv.ServerPort(hostPort))
}
if protoName != "" {
attributes = append(attributes, semconv.NetworkProtocolName(protoName))
}
if protoVersion != "" {
attributes = append(attributes, semconv.NetworkProtocolVersion(protoVersion))
}
if statusCode > 0 {
attributes = append(attributes, semconv.HTTPResponseStatusCode(statusCode))
}
if route != "" {
attributes = append(attributes, semconv.HTTPRoute(route))
}
return attributes
}

View File

@@ -0,0 +1,210 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/server_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
)
func TestHTTPServer_MetricAttributes(t *testing.T) {
defaultRequest, err := http.NewRequest("GET", "http://example.com/path?query=test", http.NoBody)
require.NoError(t, err)
tests := []struct {
name string
server string
req *http.Request
statusCode int
route string
additionalAttributes []attribute.KeyValue
wantFunc func(t *testing.T, attrs []attribute.KeyValue)
}{
{
name: "routine testing",
server: "",
req: defaultRequest,
statusCode: 200,
route: "",
additionalAttributes: []attribute.KeyValue{attribute.String("test", "test")},
wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
require.Len(t, attrs, 7)
assert.ElementsMatch(t, []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("url.scheme", "http"),
attribute.String("server.address", "example.com"),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.Int64("http.response.status_code", 200),
attribute.String("test", "test"),
}, attrs)
},
},
{
name: "use server address",
server: "example.com:9999",
req: defaultRequest,
statusCode: 200,
route: "/path/${id}",
additionalAttributes: nil,
wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
require.Len(t, attrs, 8)
assert.ElementsMatch(t, []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("url.scheme", "http"),
attribute.String("server.address", "example.com"),
attribute.Int("server.port", 9999),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.Int64("http.response.status_code", 200),
attribute.String("http.route", "/path/${id}"),
}, attrs)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := HTTPServer{}.MetricAttributes(tt.server, tt.req, tt.statusCode, tt.route, tt.additionalAttributes)
tt.wantFunc(t, got)
})
}
}
func TestNewMethod(t *testing.T) {
testCases := []struct {
method string
n int
want attribute.KeyValue
wantOrig attribute.KeyValue
}{
{
method: http.MethodPost,
n: 1,
want: attribute.String("http.request.method", "POST"),
},
{
method: "Put",
n: 2,
want: attribute.String("http.request.method", "PUT"),
wantOrig: attribute.String("http.request.method_original", "Put"),
},
{
method: "Unknown",
n: 2,
want: attribute.String("http.request.method", "GET"),
wantOrig: attribute.String("http.request.method_original", "Unknown"),
},
}
for _, tt := range testCases {
t.Run(tt.method, func(t *testing.T) {
got, gotOrig := HTTPServer{}.method(tt.method)
assert.Equal(t, tt.want, got)
assert.Equal(t, tt.wantOrig, gotOrig)
})
}
}
func TestRequestTraceAttrs_HTTPRoute(t *testing.T) {
tests := []struct {
name string
pattern string
wantRoute string
}{
{
name: "only path",
pattern: "/path/{id}",
wantRoute: "/path/{id}",
},
{
name: "with method",
pattern: "GET /path/{id}",
wantRoute: "/path/{id}",
},
{
name: "with domain",
pattern: "example.com/path/{id}",
wantRoute: "/path/{id}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/path/abc123", http.NoBody)
req.Pattern = tt.pattern
attrs := (HTTPServer{}).RequestTraceAttrs("", req, RequestTraceAttrsOpts{})
var gotRoute string
for _, attr := range attrs {
if attr.Key == "http.route" {
gotRoute = attr.Value.AsString()
break
}
}
require.Equal(t, tt.wantRoute, gotRoute)
})
}
}
func TestRequestTraceAttrs_ClientIP(t *testing.T) {
for _, tt := range []struct {
name string
requestModifierFn func(r *http.Request)
requestTraceOpts RequestTraceAttrsOpts
wantClientIP string
}{
{
name: "with a client IP from the network",
wantClientIP: "1.2.3.4",
},
{
name: "with a client IP from x-forwarded-for header",
requestModifierFn: func(r *http.Request) {
r.Header.Add("X-Forwarded-For", "5.6.7.8")
},
wantClientIP: "5.6.7.8",
},
{
name: "with a client IP in options",
requestModifierFn: func(r *http.Request) {
r.Header.Add("X-Forwarded-For", "5.6.7.8")
},
requestTraceOpts: RequestTraceAttrsOpts{
HTTPClientIP: "9.8.7.6",
},
wantClientIP: "9.8.7.6",
},
} {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/example", http.NoBody)
req.RemoteAddr = "1.2.3.4:5678"
if tt.requestModifierFn != nil {
tt.requestModifierFn(req)
}
var found bool
for _, attr := range (HTTPServer{}).RequestTraceAttrs("", req, tt.requestTraceOpts) {
if attr.Key != "client.address" {
continue
}
found = true
assert.Equal(t, tt.wantClientIP, attr.Value.AsString())
}
require.True(t, found)
})
}
}

View File

@@ -0,0 +1,127 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/util.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv"
import (
"net"
"net/http"
"strconv"
"strings"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
semconvNew "go.opentelemetry.io/otel/semconv/v1.39.0"
)
// SplitHostPort splits a network address hostport of the form "host",
// "host%zone", "[host]", "[host%zone], "host:port", "host%zone:port",
// "[host]:port", "[host%zone]:port", or ":port" into host or host%zone and
// port.
//
// An empty host is returned if it is not provided or unparsable. A negative
// port is returned if it is not provided or unparsable.
func SplitHostPort(hostport string) (host string, port int) {
port = -1
if strings.HasPrefix(hostport, "[") {
addrEnd := strings.LastIndexByte(hostport, ']')
if addrEnd < 0 {
// Invalid hostport.
return
}
if i := strings.LastIndexByte(hostport[addrEnd:], ':'); i < 0 {
host = hostport[1:addrEnd]
return
}
} else {
if i := strings.LastIndexByte(hostport, ':'); i < 0 {
host = hostport
return
}
}
host, pStr, err := net.SplitHostPort(hostport)
if err != nil {
return
}
p, err := strconv.ParseUint(pStr, 10, 16)
if err != nil {
return
}
return host, int(p) //nolint:gosec // Byte size checked 16 above.
}
func requiredHTTPPort(https bool, port int) int { //nolint:revive // ignore linter
if https {
if port > 0 && port != 443 {
return port
}
} else {
if port > 0 && port != 80 {
return port
}
}
return -1
}
func serverClientIP(xForwardedFor string) string {
if idx := strings.IndexByte(xForwardedFor, ','); idx >= 0 {
xForwardedFor = xForwardedFor[:idx]
}
return xForwardedFor
}
func httpRoute(pattern string) string {
if idx := strings.IndexByte(pattern, '/'); idx >= 0 {
return pattern[idx:]
}
return ""
}
func netProtocol(proto string) (name string, version string) {
name, version, _ = strings.Cut(proto, "/")
switch name {
case "HTTP":
name = "http"
case "QUIC":
name = "quic"
case "SPDY":
name = "spdy"
default:
name = strings.ToLower(name)
}
return name, version
}
var methodLookup = map[string]attribute.KeyValue{
http.MethodConnect: semconvNew.HTTPRequestMethodConnect,
http.MethodDelete: semconvNew.HTTPRequestMethodDelete,
http.MethodGet: semconvNew.HTTPRequestMethodGet,
http.MethodHead: semconvNew.HTTPRequestMethodHead,
http.MethodOptions: semconvNew.HTTPRequestMethodOptions,
http.MethodPatch: semconvNew.HTTPRequestMethodPatch,
http.MethodPost: semconvNew.HTTPRequestMethodPost,
http.MethodPut: semconvNew.HTTPRequestMethodPut,
http.MethodTrace: semconvNew.HTTPRequestMethodTrace,
}
func handleErr(err error) {
if err != nil {
otel.Handle(err)
}
}
func standardizeHTTPMethod(method string) string {
method = strings.ToUpper(method)
switch method {
case http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPatch, http.MethodPost, http.MethodPut, http.MethodTrace:
default:
method = "_OTHER"
}
return method
}

View File

@@ -0,0 +1,75 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/util_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSplitHostPort(t *testing.T) {
tests := []struct {
hostport string
host string
port int
}{
{"", "", -1},
{":8080", "", 8080},
{"127.0.0.1", "127.0.0.1", -1},
{"www.example.com", "www.example.com", -1},
{"127.0.0.1%25en0", "127.0.0.1%25en0", -1},
{"[]", "", -1}, // Ensure this doesn't panic.
{"[fe80::1", "", -1},
{"[fe80::1]", "fe80::1", -1},
{"[fe80::1%25en0]", "fe80::1%25en0", -1},
{"[fe80::1]:8080", "fe80::1", 8080},
{"[fe80::1]::", "", -1}, // Too many colons.
{"127.0.0.1:", "127.0.0.1", -1},
{"127.0.0.1:port", "127.0.0.1", -1},
{"127.0.0.1:8080", "127.0.0.1", 8080},
{"www.example.com:8080", "www.example.com", 8080},
{"127.0.0.1%25en0:8080", "127.0.0.1%25en0", 8080},
}
for _, test := range tests {
h, p := SplitHostPort(test.hostport)
assert.Equal(t, test.host, h, test.hostport)
assert.Equal(t, test.port, p, test.hostport)
}
}
func TestStandardizeHTTPMethod(t *testing.T) {
tests := []struct {
method string
want string
}{
{"GET", "GET"},
{"get", "GET"},
{"POST", "POST"},
{"post", "POST"},
{"PUT", "PUT"},
{"put", "PUT"},
{"DELETE", "DELETE"},
{"delete", "DELETE"},
{"HEAD", "HEAD"},
{"head", "HEAD"},
{"OPTIONS", "OPTIONS"},
{"options", "OPTIONS"},
{"CONNECT", "CONNECT"},
{"connect", "CONNECT"},
{"TRACE", "TRACE"},
{"trace", "TRACE"},
{"PATCH", "PATCH"},
{"patch", "PATCH"},
{"unknown", "_OTHER"},
{"", "_OTHER"},
}
for _, test := range tests {
assert.Equal(t, test.want, standardizeHTTPMethod(test.method))
}
}

View File

@@ -4,10 +4,10 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"sync/atomic"
"go.uber.org/zap"
"time"
)
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
@@ -27,15 +27,15 @@ var ErrMaximumQueueSizeExceeded = errors.New("maximum queue size exceeded")
type Process interface {
// Start initiates the process and returns an error if the process cannot
// be started.
Start(logger *zap.Logger) error
Start(logger *slog.Logger) error
// Stop terminates the process and returns an error if the process cannot
// be stopped.
Stop(logger *zap.Logger) error
Stop(logger *slog.Logger) error
// Healthy checks the health of the process. It returns true if the process
// is healthy; otherwise, it returns false.
Healthy(logger *zap.Logger) bool
Healthy(logger *slog.Logger) bool
}
// ProcessSupervisor provides methods to manage a [Process], including
@@ -54,9 +54,9 @@ type ProcessSupervisor interface {
// Healthy checks and returns the health status of the managed [Process].
//
// If the process has not been started or is restarting, it is considered
// healthy and true is returned. Otherwise, it returns the health status of
// the actual process.
// A non-started process is considered healthy (startup is deferred until
// the first request). Returns false if the process is currently restarting
// or is reported unhealthy by the underlying [Process].
Healthy() bool
// Run executes a provided task while managing the state of the [Process].
@@ -66,46 +66,59 @@ type ProcessSupervisor interface {
//
// It returns an error if the task cannot be run or if the process state
// cannot be managed properly.
Run(ctx context.Context, logger *zap.Logger, task func() error) error
Run(ctx context.Context, logger *slog.Logger, task func() error) error
// ReqQueueSize returns the current size of the request queue.
ReqQueueSize() int64
// RestartsCount returns the current number of restart.
RestartsCount() int64
// ActiveTasksCount returns the current number of active tasks.
ActiveTasksCount() int64
}
type processSupervisor struct {
logger *zap.Logger
process Process
maxReqLimit int64
maxQueueSize int64
maxConcurrency int64
semaphore chan struct{}
firstStart atomic.Bool
firstStartOnce sync.Once
firstStartErr error
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
isRestarting atomic.Bool
activeTasks atomic.Int64
restartMutex sync.Mutex
logger *slog.Logger
process Process
maxReqLimit int64
maxQueueSize int64
maxConcurrency int64
semaphore chan struct{}
firstStart atomic.Bool
// firstStartMu serializes lazy-launch attempts so concurrent callers do
// not all spawn Launch() simultaneously. Using a mutex (instead of
// sync.Once) lets a failed launch be retried by the next caller, since a
// transient failure (such as a cold-start timeout) must not poison the
// supervisor for the rest of the container's lifetime. See
// https://github.com/gotenberg/gotenberg/issues/1538.
firstStartMu sync.Mutex
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
isRestarting atomic.Bool
activeTasks atomic.Int64
restartMutex sync.Mutex
idleShutdownTimeout time.Duration
lastActivity atomic.Int64 // unix nano timestamp of last completed task
idleMu sync.Mutex // protects idleStopChan
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
}
// NewProcessSupervisor initializes a new [ProcessSupervisor].
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor {
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
if maxConcurrency < 1 {
maxConcurrency = 1
}
b := &processSupervisor{
logger: logger,
process: process,
semaphore: make(chan struct{}, maxConcurrency),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
maxConcurrency: maxConcurrency,
logger: logger,
process: process,
semaphore: make(chan struct{}, maxConcurrency),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
maxConcurrency: maxConcurrency,
idleShutdownTimeout: idleShutdownTimeout,
}
b.reqCounter.Store(0)
b.reqQueueSize.Store(0)
@@ -117,37 +130,47 @@ func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQ
}
func (s *processSupervisor) Launch() error {
s.logger.Debug("start process")
s.logger.DebugContext(context.Background(), "start process")
err := s.process.Start(s.logger)
if err != nil {
return fmt.Errorf("start process: %w", err)
}
s.firstStart.Store(true)
s.logger.Debug("process successfully started")
if s.idleShutdownTimeout > 0 {
s.lastActivity.Store(time.Now().UnixNano())
s.startIdleTicker()
}
s.logger.DebugContext(context.Background(), "process successfully started")
return nil
}
func (s *processSupervisor) Shutdown() error {
s.logger.Debug("shutdown process")
s.logger.DebugContext(context.Background(), "shutdown process")
s.stopIdleTicker()
err := s.process.Stop(s.logger)
if err != nil {
return fmt.Errorf("shutdown process: %w", err)
}
s.logger.Debug("process successfully shutdown")
s.logger.DebugContext(context.Background(), "process successfully shutdown")
return nil
}
func (s *processSupervisor) restart() error {
s.logger.Debug("restart process")
s.logger.DebugContext(context.Background(), "restart process")
err := s.Shutdown()
if err != nil {
// No big deal? Chances are it's already stopped.
s.logger.Debug(fmt.Sprintf("stop process before restart: %s", err))
// Not necessarily critical — chances are the process is already stopped,
// but worth flagging in case it indicates a real issue.
s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
}
err = s.Launch()
@@ -157,124 +180,91 @@ func (s *processSupervisor) restart() error {
s.reqCounter.Store(0)
s.restartsCounter.Add(1)
s.logger.Debug("process successfully restarted")
s.logger.DebugContext(context.Background(), "process successfully restarted")
return nil
}
func (s *processSupervisor) Healthy() bool {
if !s.firstStart.Load() {
// A non-started process is always healthy.
// A non-started process is considered healthy: Gotenberg defers
// process startup until the first request to keep resource usage low.
// Reporting unhealthy here would cause container orchestrators to
// restart the pod before any request arrives.
return true
}
if s.isRestarting.Load() {
// A restarting process is always healthy.
return true
// A restarting process is not yet healthy — this gives load balancers
// honest information so they can avoid routing traffic to this node.
return false
}
return s.process.Healthy(s.logger)
}
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
// A user reported a potential issue:
//
// "Although the counting operation is atomic, nothing prevent 2 concurrent
// goroutines to retrieve the same 'currentQueueSize' and to compare its
// value against the max limit. Then, resulting queue size would be 1 above
// the allowed limit."
//
// However, he was unable to actually trigger this issue, even when sending
// a lot of requests.
//
// For now, the best option is to consider this issue to be unlikely to
// happen, and keep the code as it is because it is more readable this way.
//
// See https://github.com/gotenberg/gotenberg/issues/951.
currentQueueSize := s.reqQueueSize.Load()
if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
return ErrMaximumQueueSizeExceeded
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
// Atomically check and increment the queue size to avoid the TOCTOU race
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
for {
current := s.reqQueueSize.Load()
if s.maxQueueSize > 0 && current >= s.maxQueueSize {
return ErrMaximumQueueSizeExceeded
}
if s.reqQueueSize.CompareAndSwap(current, current+1) {
break
}
}
s.reqQueueSize.Add(1)
// Decrement when Run() returns, regardless of which path is taken
// (context timeout, task completion, error, etc.). This ensures the
// request is counted as "in the queue" for the entire duration of Run(),
// preventing new requests from entering while one is being processed.
// See https://github.com/gotenberg/gotenberg/issues/1502.
defer s.reqQueueSize.Add(-1)
for {
err := func() error {
select {
case s.semaphore <- struct{}{}:
logger.Debug("process lock acquired")
// If a restart drain is in progress, release the slot
// immediately so the drain can acquire it instead.
if s.isRestarting.Load() {
<-s.semaphore
return ErrProcessAlreadyRestarting
}
s.reqQueueSize.Add(-1)
s.reqCounter.Add(1)
s.activeTasks.Add(1)
releaseSemaphore := true
defer func() {
s.activeTasks.Add(-1)
if releaseSemaphore {
logger.Debug("process lock released")
<-s.semaphore
}
}()
if !s.firstStart.Load() {
s.firstStartOnce.Do(func() {
s.firstStartErr = s.runWithDeadline(ctx, func() error {
return s.Launch()
})
})
if s.firstStartErr != nil {
return fmt.Errorf("process first start: %w", s.firstStartErr)
}
}
if !s.Healthy() {
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
err := s.doRestart(ctx)
if err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
}
err := s.runWithDeadline(ctx, task)
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
// Only one goroutine should trigger the restart.
if s.restartMutex.TryLock() {
s.logger.Debug("max request limit reached, restarting eagerly...")
releaseSemaphore = false
go func() {
restartErr := s.doRestartLocked(context.Background())
s.restartMutex.Unlock()
if restartErr != nil {
s.logger.Error(fmt.Sprintf("process restart after task: %v", restartErr))
}
logger.Debug("process lock released")
<-s.semaphore
}()
}
}
// Note: no error wrapping because it leaks on Chromium console exceptions output.
if err := s.acquireSlot(ctx, logger); err != nil {
return err
case <-ctx.Done():
logger.Debug("failed to acquire process lock before deadline")
s.reqQueueSize.Add(-1)
return fmt.Errorf("acquire process lock: %w", ctx.Err())
}
s.reqCounter.Add(1)
s.activeTasks.Add(1)
semaphoreOwned := true
defer func() {
s.activeTasks.Add(-1)
if s.idleShutdownTimeout > 0 {
s.lastActivity.Store(time.Now().UnixNano())
}
if semaphoreOwned {
logger.DebugContext(ctx, "process lock released")
<-s.semaphore
}
}()
if err := s.ensureStarted(ctx); err != nil {
return err
}
if err := s.ensureHealthy(ctx); err != nil {
return err
}
err := s.runWithDeadline(ctx, task)
if s.maybeRestartAfterTask(logger) {
semaphoreOwned = false
}
// Note: no error wrapping because it leaks on Chromium console exceptions output.
return err
}()
if errors.Is(err, ErrProcessAlreadyRestarting) {
logger.Debug("process is already restarting, trying to acquire process lock again...")
logger.DebugContext(ctx, "process is already restarting, trying to acquire process lock again...")
time.Sleep(10 * time.Millisecond)
continue
}
@@ -283,6 +273,178 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
}
}
// startIdleTicker starts a background goroutine that periodically checks
// whether the process has been idle long enough to shut down.
func (s *processSupervisor) startIdleTicker() {
stopChan := make(chan struct{})
s.idleMu.Lock()
s.idleStopChan = stopChan
s.idleMu.Unlock()
go func() {
ticker := time.NewTicker(s.idleShutdownTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.maybeIdleShutdown()
case <-stopChan:
return
}
}
}()
}
// stopIdleTicker signals the idle ticker goroutine to exit, if one is running.
func (s *processSupervisor) stopIdleTicker() {
s.idleMu.Lock()
defer s.idleMu.Unlock()
if s.idleStopChan != nil {
close(s.idleStopChan)
s.idleStopChan = nil
}
}
// maybeIdleShutdown stops the process if it has been idle for longer than
// the configured timeout. It is safe to call concurrently with Run and
// restart.
func (s *processSupervisor) maybeIdleShutdown() {
if !s.firstStart.Load() || s.isRestarting.Load() {
return
}
if s.activeTasks.Load() > 0 || s.reqQueueSize.Load() > 0 {
return
}
lastNano := s.lastActivity.Load()
if lastNano == 0 || time.Since(time.Unix(0, lastNano)) < s.idleShutdownTimeout {
return
}
if !s.restartMutex.TryLock() {
return
}
defer s.restartMutex.Unlock()
// Double-check after acquiring the lock.
if s.activeTasks.Load() > 0 || s.reqQueueSize.Load() > 0 {
return
}
s.logger.DebugContext(context.Background(), "idle shutdown timeout reached, stopping process")
// Stop the ticker — it will be restarted on the next Launch().
s.stopIdleTicker()
err := s.process.Stop(s.logger)
if err != nil {
s.logger.WarnContext(context.Background(), fmt.Sprintf("idle shutdown: %s", err))
return
}
// Reset state so ensureStarted() re-launches on next request.
s.firstStart.Store(false)
s.reqCounter.Store(0)
s.logger.DebugContext(context.Background(), "process stopped due to idle timeout")
}
// acquireSlot attempts to acquire a semaphore slot, yielding it back if a
// restart drain is in progress.
func (s *processSupervisor) acquireSlot(ctx context.Context, logger *slog.Logger) error {
select {
case s.semaphore <- struct{}{}:
// If a restart drain is in progress, release the slot
// immediately so the drain can acquire it instead.
if s.isRestarting.Load() {
<-s.semaphore
return ErrProcessAlreadyRestarting
}
logger.DebugContext(ctx, "process lock acquired")
return nil
case <-ctx.Done():
logger.DebugContext(ctx, "failed to acquire process lock before deadline")
return fmt.Errorf("acquire process lock: %w", ctx.Err())
}
}
// ensureStarted performs a lazy launch of the process on its first use.
// Concurrent callers serialize on firstStartMu; once the launch succeeds,
// subsequent calls short-circuit on the firstStart flag. A failed launch
// leaves firstStart unset, so the next caller retries the launch.
func (s *processSupervisor) ensureStarted(ctx context.Context) error {
if s.firstStart.Load() {
return nil
}
s.firstStartMu.Lock()
defer s.firstStartMu.Unlock()
if s.firstStart.Load() {
return nil
}
err := s.runWithDeadline(ctx, func() error {
return s.Launch()
})
if err != nil {
return fmt.Errorf("process first start: %w", err)
}
return nil
}
// ensureHealthy checks the underlying process health and triggers a
// synchronous restart if the process is unhealthy. Skips the check if a
// restart is already in progress.
func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
if s.isRestarting.Load() || s.process.Healthy(s.logger) {
return nil
}
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
if err := s.doRestart(ctx); err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
return nil
}
// maybeRestartAfterTask checks if the maximum request limit has been reached
// and, if so, triggers an asynchronous restart. If a restart is initiated, it
// takes ownership 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 {
if s.maxReqLimit <= 0 || s.reqCounter.Load() < s.maxReqLimit {
return false
}
if !s.restartMutex.TryLock() {
return false
}
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
go func() {
restartErr := s.doRestartLocked(context.Background())
s.restartMutex.Unlock()
if restartErr != nil {
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
}
logger.DebugContext(context.Background(), "process lock released")
<-s.semaphore
}()
return true
}
// doRestart coordinates a process restart, draining all active concurrent
// tasks before stopping and restarting the process.
func (s *processSupervisor) doRestart(ctx context.Context) error {
@@ -348,6 +510,10 @@ func (s *processSupervisor) RestartsCount() int64 {
return s.restartsCounter.Load()
}
func (s *processSupervisor) ActiveTasksCount() int64 {
return s.activeTasks.Load()
}
// Interface guards.
var (
_ ProcessSupervisor = (*processSupervisor)(nil)

View File

@@ -3,12 +3,11 @@ package gotenberg
import (
"context"
"errors"
"log/slog"
"sync"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
)
func TestProcessSupervisor_Launch(t *testing.T) {
@@ -38,15 +37,15 @@ func TestProcessSupervisor_Launch(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
return tc.startError
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
if tc.firstStartSet {
ps.firstStart.Store(true)
}
@@ -86,15 +85,15 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
return tc.stopError
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1)
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0)
err := ps.Shutdown()
if !tc.expectError && err != nil {
@@ -135,18 +134,18 @@ func TestProcessSupervisor_restart(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
return tc.startError
},
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
return tc.stopError
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
err := ps.restart()
@@ -170,15 +169,15 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
expectHealthy bool
}{
{
scenario: "non-started process is always healthy",
scenario: "non-started process is healthy",
initiallyStarted: false,
expectHealthy: true,
},
{
scenario: "restarting process is always healthy",
scenario: "restarting process is not healthy",
initiallyStarted: true,
initiallyRestarting: true,
expectHealthy: true,
expectHealthy: false,
},
{
scenario: "process reports as healthy",
@@ -194,15 +193,15 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
HealthyMock: func(logger *zap.Logger) bool {
HealthyMock: func(logger *slog.Logger) bool {
return tc.processHealthy
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
@@ -365,7 +364,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
var startCalls, healthyCalls, stopCalls atomic.Int64
startCalls.Store(0)
@@ -373,21 +372,21 @@ func TestProcessSupervisor_Run(t *testing.T) {
stopCalls.Store(0)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
startCalls.Add(1)
return tc.startError
},
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
stopCalls.Add(1)
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
HealthyMock: func(logger *slog.Logger) bool {
healthyCalls.Add(1)
return tc.processHealthy
},
}
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
@@ -471,7 +470,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
if tc.ctxDone {
@@ -496,16 +495,16 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
}
func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
// Simulating a lock.
ps.semaphore <- struct{}{}
@@ -552,6 +551,111 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
}
}
func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
maxQueueSize := int64(50)
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
ps := NewProcessSupervisor(logger, process, 0, maxQueueSize, 1, 0).(*processSupervisor)
// Simulating a lock so that all goroutines queue up.
ps.semaphore <- struct{}{}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
goroutines := 100
var wg sync.WaitGroup
var exceeded atomic.Int64
for range goroutines {
wg.Go(func() {
err := ps.Run(ctx, logger, func() error {
return nil
})
if err != nil {
if errors.Is(err, ErrMaximumQueueSizeExceeded) {
exceeded.Add(1)
}
}
})
}
// Wait a bit for goroutines to queue up.
time.Sleep(50 * time.Millisecond)
currentQueue := ps.ReqQueueSize()
if currentQueue > maxQueueSize {
t.Fatalf("queue size %d exceeded max %d", currentQueue, maxQueueSize)
}
cancel()
wg.Wait()
if exceeded.Load() < int64(goroutines)-maxQueueSize {
t.Errorf("expected at least %d rejections, got %d", goroutines-int(maxQueueSize), exceeded.Load())
}
}
func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
ps := NewProcessSupervisor(logger, process, 0, 1, 1, 0)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
taskStarted := make(chan struct{})
taskDone := make(chan struct{})
// Start a long-running task that holds the slot.
var wg sync.WaitGroup
wg.Go(func() {
err := ps.Run(ctx, logger, func() error {
close(taskStarted)
<-taskDone
return nil
})
if err != nil {
t.Errorf("first task: unexpected error: %v", err)
}
})
// Wait for the first task to be running.
<-taskStarted
// A second request should be rejected immediately because the queue
// slot is still held by the active task.
err := ps.Run(ctx, logger, func() error {
return nil
})
if !errors.Is(err, ErrMaximumQueueSizeExceeded) {
t.Fatalf("expected ErrMaximumQueueSizeExceeded but got: %v", err)
}
close(taskDone)
wg.Wait()
}
func TestProcessSupervisor_RestartsCount(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -593,18 +697,18 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
return tc.startError
},
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
return tc.stopError
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
ps.restartsCounter.Store(tc.initialRestartsCount)
for i := 0; i < tc.restartAttempts; i++ {
@@ -620,24 +724,24 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
}
func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
var startCalls atomic.Int64
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
startCalls.Add(1)
return nil
},
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -684,22 +788,22 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
}
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
return nil
},
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency, 0).(*processSupervisor)
ps.firstStart.Store(true)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -737,3 +841,150 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
t.Fatalf("expected 1 restart, got %d", ps.RestartsCount())
}
}
func TestProcessSupervisor_IdleShutdown(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
var stopCalls atomic.Int64
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
return nil
},
StopMock: func(logger *slog.Logger) error {
stopCalls.Add(1)
return nil
},
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
idleTimeout := 50 * time.Millisecond
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout).(*processSupervisor)
ctx := context.Background()
err := ps.Run(ctx, logger, func() error {
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Wait for idle shutdown to fire.
deadline := time.After(2 * time.Second)
for ps.firstStart.Load() {
select {
case <-deadline:
t.Fatal("timed out waiting for idle shutdown")
default:
time.Sleep(10 * time.Millisecond)
}
}
if stopCalls.Load() < 1 {
t.Fatal("expected process to be stopped via idle shutdown")
}
// Verify re-launch on next request.
err = ps.Run(ctx, logger, func() error {
return nil
})
if err != nil {
t.Fatalf("unexpected error on re-launch: %v", err)
}
if !ps.firstStart.Load() {
t.Fatal("expected process to be re-launched after idle shutdown")
}
}
func TestProcessSupervisor_RetryAfterFailedFirstStart(t *testing.T) {
// Regression test for https://github.com/gotenberg/gotenberg/issues/1538:
// a failed first launch must not poison the supervisor; the next request
// must retry Launch() instead of returning the cached error forever.
logger := slog.New(slog.DiscardHandler)
var startCalls atomic.Int64
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
if startCalls.Add(1) == 1 {
return errors.New("first start failed")
}
return nil
},
StopMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := ps.Run(ctx, logger, func() error { return nil })
if err == nil {
t.Fatal("expected first Run to fail because Launch failed")
}
if ps.firstStart.Load() {
t.Fatal("firstStart must remain false after a failed Launch")
}
err = ps.Run(ctx, logger, func() error { return nil })
if err != nil {
t.Fatalf("expected second Run to succeed after the supervisor retries Launch, got: %v", err)
}
if !ps.firstStart.Load() {
t.Fatal("expected firstStart to be set after the second Launch succeeds")
}
if got := startCalls.Load(); got != 2 {
t.Fatalf("expected exactly 2 Start calls, got %d", got)
}
}
func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
var stopCalls atomic.Int64
taskRunning := make(chan struct{})
taskDone := make(chan struct{})
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
return nil
},
StopMock: func(logger *slog.Logger) error {
stopCalls.Add(1)
return nil
},
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
idleTimeout := 50 * time.Millisecond
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout)
ctx := context.Background()
go func() {
_ = ps.Run(ctx, logger, func() error {
close(taskRunning)
<-taskDone
return nil
})
}()
<-taskRunning
// Wait longer than the idle timeout while a task is active.
time.Sleep(idleTimeout * 3)
if stopCalls.Load() > 0 {
t.Fatal("idle shutdown should not fire while a task is active")
}
close(taskDone)
}

237
pkg/gotenberg/telemetry.go Normal file
View File

@@ -0,0 +1,237 @@
package gotenberg
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"github.com/hashicorp/go-retryablehttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/log"
internalotel "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/otel"
)
const (
AutoLoggingFormat = "auto"
JsonLoggingFormat = "json"
TextLoggingFormat = "text"
)
const (
ErrorLoggingLevel = "error"
WarnLoggingLevel = "warn"
InfoLoggingLevel = "info"
DebugLoggingLevel = "debug"
)
// TelemetryConfig gathers the configuration data for Gotenberg's telemetry.
type TelemetryConfig struct {
ServiceName string
ServiceVersion string
LogLevel string
LogFieldsPrefix string
LogStdFormat string
LogStdEnableGcpFields bool
}
func (cfg TelemetryConfig) slogLevel() slog.Level {
var level slog.Level
err := level.UnmarshalText([]byte(cfg.LogLevel))
if err != nil {
return slog.LevelInfo
}
return level
}
// Validate validates the telemetry configuration.
func (cfg TelemetryConfig) Validate() error {
var err error
if cfg.ServiceName == "" {
err = errors.Join(err,
errors.New("service name must not be empty"),
)
}
if cfg.ServiceVersion == "" {
err = errors.Join(err,
errors.New("service version must not be empty"),
)
}
switch cfg.LogLevel {
case ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel:
break
default:
err = errors.Join(
err,
fmt.Errorf("log level must be either %s, %s, %s or %s", ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel),
)
}
switch cfg.LogStdFormat {
case AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat:
break
default:
err = errors.Join(
err,
fmt.Errorf("standard log format must be either %s, %s or %s", AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat),
)
}
return err
}
// StartTelemetry starts the telemetry utilities.
func StartTelemetry(cfg TelemetryConfig) (shutdown func(context.Context) error, err error) {
var handlers []slog.Handler
stdHandler, err := log.NewStdHandler(cfg.slogLevel(), cfg.LogStdFormat, cfg.LogFieldsPrefix, cfg.LogStdEnableGcpFields)
if err != nil {
return nil, fmt.Errorf("get standard logger handler: %w", err)
}
handlers = append(handlers, stdHandler)
// We need a logger for the other providers.
// We'll use the stdHandler for now.
bootstrapLogger := slog.New(stdHandler)
// OpenTelemetry.
var shutdowns []func(context.Context) error
shutdownFn, err := internalotel.InitTracerProvider(bootstrapLogger, cfg.ServiceName, cfg.ServiceVersion)
if err != nil {
return nil, fmt.Errorf("initialize OpenTelemetry tracer provider: %w", err)
}
shutdowns = append(shutdowns, shutdownFn)
shutdownFn, err = internalotel.InitMeterProvider(bootstrapLogger, cfg.ServiceName, cfg.ServiceVersion)
if err != nil {
return nil, fmt.Errorf("initialize OpenTelemetry meter provider: %w", err)
}
shutdowns = append(shutdowns, shutdownFn)
shutdownFn, otelHandler, err := internalotel.InitLoggerProvider(bootstrapLogger, cfg.ServiceName, cfg.ServiceVersion)
if err != nil {
return nil, fmt.Errorf("initialize OpenTelemetry logger provider: %w", err)
}
handlers = append(handlers, log.LevelFilter(otelHandler, cfg.slogLevel()))
shutdowns = append(shutdowns, shutdownFn)
// Global logger.
log.InitLogger(log.NewGotenbergHandler(log.FanOut(handlers...), cfg.LogFieldsPrefix))
return func(ctx context.Context) error {
filterErr := func(err error) error {
if errors.Is(err, context.Canceled) {
return nil
}
return err
}
var wg sync.WaitGroup
var errs error
var mu sync.Mutex
for _, fn := range shutdowns {
wg.Add(1)
go func(shutdownFn func(context.Context) error) {
defer wg.Done()
shutdownErr := shutdownFn(ctx)
if filterErr(shutdownErr) != nil {
mu.Lock()
errs = errors.Join(errs, shutdownErr)
mu.Unlock()
}
}(fn)
}
wg.Wait()
return errs
}, nil
}
// Logger returns the global logger.
func Logger(mod Module) *slog.Logger {
return log.Logger().With(slog.String("logger", mod.Descriptor().ID))
}
const (
// instrumentationName is the name of the OpenTelemetry instrumentation
// library.
instrumentationName = "github.com/gotenberg/gotenberg"
)
// Tracer returns a [trace.Tracer] with the instrumentation name and version
// already set.
func Tracer() trace.Tracer {
return otel.GetTracerProvider().Tracer(
instrumentationName,
trace.WithInstrumentationVersion(Version),
)
}
// Meter returns a [metric.Meter] with the instrumentation name and version
// already set.
func Meter() metric.Meter {
return otel.GetMeterProvider().Meter(
instrumentationName,
metric.WithInstrumentationVersion(Version),
)
}
// LeveledLogger is a wrapper around a [slog.Logger] so that it may be used by a
// [retryablehttp.Client].
type LeveledLogger struct {
logger *slog.Logger
ctx context.Context
}
// NewLeveledLogger instantiates a [LeveledLogger].
func NewLeveledLogger(logger *slog.Logger) *LeveledLogger {
return &LeveledLogger{
logger: logger,
ctx: context.Background(),
}
}
// WithContext returns a new [LeveledLogger] with the given context.
func (leveled LeveledLogger) WithContext(ctx context.Context) *LeveledLogger {
return &LeveledLogger{
logger: leveled.logger,
ctx: ctx,
}
}
// Error logs a message at the error level using the wrapped slog.Logger.
func (leveled LeveledLogger) Error(msg string, keysAndValues ...any) {
leveled.logger.ErrorContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Warn logs a message at the warning level using the wrapped slog.Logger.
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...any) {
leveled.logger.WarnContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Info logs a message at the info level using the wrapped slog.Logger.
func (leveled LeveledLogger) Info(msg string, keysAndValues ...any) {
leveled.logger.InfoContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Debug logs a message at the debug level using the wrapped slog.Logger.
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...any) {
leveled.logger.DebugContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Interface guards.
var (
_ retryablehttp.LeveledLogger = (*LeveledLogger)(nil)
)

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"sort"
@@ -14,8 +15,6 @@ import (
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/net/http2"
"golang.org/x/sync/errgroup"
@@ -29,20 +28,23 @@ func init() {
// Api is a module that provides an HTTP server. Other modules may add routes,
// middlewares or health checks.
type Api struct {
port int
bindIp string
tlsCertFile string
tlsKeyFile string
startTimeout time.Duration
bodyLimit int64
timeout time.Duration
rootPath string
traceHeader string
basicAuthUsername string
basicAuthPassword string
downloadFromCfg downloadFromConfig
disableHealthCheckLogging bool
enableDebugRoute bool
port int
bindIp string
tlsCertFile string
tlsKeyFile string
startTimeout time.Duration
bodyLimit int64
timeout time.Duration
rootPath string
correlationIdHeader string
basicAuthUsername string
basicAuthPassword string
downloadFromCfg downloadFromConfig
disableHealthCheckRouteTelemetry bool
disableRootRouteTelemetry bool
disableDebugRouteTelemetry bool
disableVersionRouteTelemetry bool
enableDebugRoute bool
routes []Route
externalMiddlewares []Middleware
@@ -50,15 +52,17 @@ type Api struct {
readyFn []func() error
asyncCounters []AsynchronousCounter
fs *gotenberg.FileSystem
logger *zap.Logger
logger *slog.Logger
srv *echo.Echo
}
type downloadFromConfig struct {
allowList *regexp2.Regexp
denyList *regexp2.Regexp
maxRetry int
disable bool
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
maxRetry int
disable bool
}
// Router is a module interface that adds routes to the [Api].
@@ -80,9 +84,10 @@ type Route struct {
// Optional.
IsMultipart bool
// DisableLogging disables the logging for this route.
// DisableTelemetry disables telemetry (logging, tracing, metrics) for
// this route.
// Optional.
DisableLogging bool
DisableTelemetry bool
// Handler is the function that handles the request.
// Required.
@@ -190,14 +195,31 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression")
fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services")
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.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
fs.Bool("api-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-debug-route-telemetry", true, "Disable telemetry for the debug route")
fs.Bool("api-disable-version-route-telemetry", true, "Disable telemetry for the version route")
fs.Bool("api-enable-debug-route", false, "Enable the debug route")
// Deprecated flags.
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
err := errors.Join(
fs.MarkDeprecated("api-trace-header", "use --api-correlation-id-header instead"),
fs.MarkDeprecated("api-disable-health-check-logging", "use --api-disable-health-check-route-telemetry instead"),
)
if err != nil {
panic(err)
}
return fs
}(),
New: func() gotenberg.Module { return new(Api) },
@@ -215,14 +237,19 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.timeout = flags.MustDuration("api-timeout")
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header")
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexp("api-download-from-allow-list"),
denyList: flags.MustRegexp("api-download-from-deny-list"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
}
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")
a.disableDebugRouteTelemetry = flags.MustBool("api-disable-debug-route-telemetry")
a.disableVersionRouteTelemetry = flags.MustBool("api-disable-version-route-telemetry")
a.enableDebugRoute = flags.MustBool("api-enable-debug-route")
// Port from env?
@@ -328,17 +355,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
}
// Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
a.logger = logger
a.logger = gotenberg.Logger(a)
// File system.
a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
@@ -351,35 +368,35 @@ func (a *Api) Validate() error {
var err error
if a.port < 1 || a.port > 65535 {
err = multierr.Append(err,
err = errors.Join(err,
errors.New("port must be more than 1 and less than 65535"),
)
}
if a.bindIp != "" && net.ParseIP(a.bindIp) == nil {
err = multierr.Append(err, errors.New("IP must be a valid IP address"))
err = errors.Join(err, errors.New("IP must be a valid IP address"))
}
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
err = multierr.Append(err,
err = errors.Join(err,
errors.New("both TLS certificate and key files must be set"),
)
}
if !strings.HasPrefix(a.rootPath, "/") {
err = multierr.Append(err,
err = errors.Join(err,
errors.New("root path must start with /"),
)
}
if !strings.HasSuffix(a.rootPath, "/") {
err = multierr.Append(err,
err = errors.Join(err,
errors.New("root path must end with /"),
)
}
if len(strings.TrimSpace(a.traceHeader)) == 0 {
err = multierr.Append(err,
if len(strings.TrimSpace(a.correlationIdHeader)) == 0 {
err = errors.Join(err,
errors.New("trace header must not be empty"),
)
}
@@ -442,28 +459,37 @@ func (a *Api) Start() error {
a.srv.HTTPErrorHandler = httpErrorHandler()
// Let's prepare the modules' routes.
var disableLoggingForPaths []string
var disableTelemetryForPaths []string
for i, route := range a.routes {
a.routes[i].Path = strings.TrimPrefix(route.Path, "/")
if route.DisableLogging {
disableLoggingForPaths = append(disableLoggingForPaths, strings.TrimPrefix(route.Path, "/"))
if route.DisableTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, strings.TrimPrefix(route.Path, "/"))
}
}
// Check if the user wishes to add logging entries related to the health
// check route.
if a.disableHealthCheckLogging {
disableLoggingForPaths = append(disableLoggingForPaths, "health")
// Check if the user wishes to disable telemetry for specific routes.
if a.disableHealthCheckRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "health")
}
if a.disableRootRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "")
}
if a.disableDebugRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "debug")
}
if a.disableVersionRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "version")
}
serverName := fmt.Sprintf("%s:%d", a.bindIp, a.port)
// Add the API middlewares.
a.srv.Pre(
latencyMiddleware(),
rootPathMiddleware(a.rootPath),
traceMiddleware(a.traceHeader),
outputFilenameMiddleware(),
loggerMiddleware(a.logger, disableLoggingForPaths),
telemetryMiddleware(a.logger, serverName, a.correlationIdHeader, disableTelemetryForPaths),
)
// Add the modules' middlewares in their respective stacks.
@@ -535,7 +561,9 @@ func (a *Api) Start() error {
)
// Let's not forget the health check routes...
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checks := make([]health.CheckerOption, len(a.healthChecks), len(a.healthChecks)+1)
copy(checks, a.healthChecks)
checks = append(checks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
healthCheckHandler := health.NewHandler(checker)
@@ -600,7 +628,7 @@ func (a *Api) Start() error {
err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
}
if !errors.Is(err, http.ErrServerClosed) {
a.logger.Fatal(err.Error())
a.logger.ErrorContext(context.Background(), err.Error())
}
}()
@@ -627,12 +655,12 @@ func (a *Api) Stop(ctx context.Context) error {
case <-ctx.Done():
return a.srv.Shutdown(ctx)
default:
a.logger.Debug(fmt.Sprintf("%d asynchronous requests", count))
a.logger.DebugContext(ctx, fmt.Sprintf("%d asynchronous requests", count))
if count > 0 {
time.Sleep(1 * time.Second)
continue
}
a.logger.Debug("no more asynchronous requests, continue with shutdown")
a.logger.DebugContext(ctx, "no more asynchronous requests, continue with shutdown")
err := a.srv.Shutdown(ctx)
if err != nil {
return fmt.Errorf("shutdown: %w", err)

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"mime"
"mime/multipart"
"net/http"
@@ -19,7 +20,11 @@ import (
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"github.com/mholt/archives"
"go.uber.org/zap"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/propagation"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm"
@@ -36,16 +41,17 @@ var (
ErrOutOfBoundsOutputPath = errors.New("output path is not within context's working directory")
)
// Context is the request context for a "multipart/form-data" requests.
// Context is the request context for a "multipart/form-data" request.
type Context struct {
dirPath string
values map[string][]string
files map[string]string
filesByField map[string][]string
outputPaths []string
cancelled bool
dirPath string
values map[string][]string
files map[string]string
filesByField map[string][]string
diskToOriginal map[string]string
outputPaths []string
cancelled bool
logger *zap.Logger
logger *slog.Logger
echoCtx echo.Context
mkdirAll gotenberg.MkdirAll
pathRename gotenberg.PathRename
@@ -92,7 +98,7 @@ type downloadFrom struct {
}
// newContext returns a [Context] by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout)
// We want to make sure the multipart/form-data does not exceed a given
@@ -137,12 +143,12 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
err := os.RemoveAll(ctx.dirPath)
if err != nil {
ctx.logger.Error(fmt.Sprintf("remove context's working directory: %s", err))
ctx.logger.ErrorContext(context.Background(), fmt.Sprintf("remove context's working directory: %s", err))
return
}
ctx.logger.Debug(fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath))
ctx.logger.DebugContext(context.Background(), fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath))
ctx.cancelled = true
}
}()
@@ -195,6 +201,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
ctx.values = form.Value
ctx.files = make(map[string]string)
ctx.filesByField = make(map[string][]string)
ctx.diskToOriginal = make(map[string]string)
// First, try to download files listed in the "downloadFrom" form field, if
// any.
@@ -225,15 +232,27 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
)
}
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
ipOpts := []gotenberg.DecideOption{
gotenberg.WithDenyPrivateIPs(downloadFromCfg.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(downloadFromCfg.denyPublicIPs),
}
err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline, ipOpts...)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
logger.Debug(fmt.Sprintf("download file from '%s'", dl.Url))
dlCtx, dlSpan := gotenberg.Tracer().Start(ctx, "GET Download From",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(dl.Url)),
)
logger.DebugContext(dlCtx, fmt.Sprintf("download file from '%s'", dl.Url))
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
if err != nil {
dlSpan.RecordError(err)
dlSpan.SetStatus(codes.Error, err.Error())
dlSpan.End()
return fmt.Errorf("create request to '%s': %w", dl.Url, err)
}
@@ -241,12 +260,19 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
for key, value := range dl.ExtraHttpHeaders {
req.Header.Set(key, value)
}
req.Header.Set(traceHeader, trace)
// Inject OTEL trace context into outbound request.
otel.GetTextMapPropagator().Inject(dlCtx, propagation.HeaderCarrier(req.Header))
// Propagate correlation ID header.
if correlationIdHeader, ok := echoCtx.Get("correlationIdHeader").(string); ok {
if correlationId, ok := echoCtx.Get("correlationId").(string); ok {
req.Header.Set(correlationIdHeader, correlationId)
}
}
client := &retryablehttp.Client{
HTTPClient: &http.Client{
Timeout: time.Until(deadline),
},
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...),
RetryMax: downloadFromCfg.maxRetry,
RetryWaitMin: time.Duration(1) * time.Second,
RetryWaitMax: time.Until(deadline),
@@ -257,6 +283,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
resp, err := client.Do(req)
if err != nil {
dlSpan.RecordError(err)
dlSpan.SetStatus(codes.Error, err.Error())
dlSpan.End()
return WrapError(
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
@@ -265,21 +294,29 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
defer func() {
err := resp.Body.Close()
if err != nil {
logger.Error(fmt.Sprintf("close response body from '%s': %s", dl.Url, err))
logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", dl.Url, err))
}
}()
if resp.StatusCode != http.StatusOK {
dlErr := fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status),
dlErr,
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': got status: '%s'", dl.Url, resp.Status)),
)
}
contentDisposition := resp.Header.Get("Content-Disposition")
if contentDisposition == "" {
dlErr := fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url),
dlErr,
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("No 'Content-Disposition' header from '%s'", dl.Url)),
)
}
@@ -289,16 +326,24 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
// See: https://github.com/golang/go/issues/69551.
_, params, err := mime.ParseMediaType(contentDisposition)
if err != nil {
dlErr := fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err),
dlErr,
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': %s", contentDisposition, dl.Url, err)),
)
}
filename, ok := params["filename"]
if !ok {
dlErr := fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url),
dlErr,
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': no filename", contentDisposition, dl.Url)),
)
}
@@ -307,16 +352,25 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename = norm.NFC.String(filepath.Base(filename))
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
// Use a UUID-based name on disk to avoid filesystem
// NAME_MAX limits with long filenames.
// See: https://github.com/gotenberg/gotenberg/issues/1500.
safeName := uuid.New().String() + filepath.Ext(filename)
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
out, err := os.Create(path)
if err != nil {
return fmt.Errorf("create local file: %w", err)
dlErr := fmt.Errorf("create local file: %w", err)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return dlErr
}
defer func() {
err := out.Close()
if err != nil {
logger.Error(fmt.Sprintf("close local file: %s", err))
logger.ErrorContext(ctx, fmt.Sprintf("close local file: %s", err))
}
}()
@@ -325,10 +379,18 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
_, err = io.Copy(out, reader)
if err != nil {
return fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
dlErr := fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return dlErr
}
dlSpan.SetStatus(codes.Ok, "")
dlSpan.End()
ctx.files[filename] = path
ctx.diskToOriginal[path] = filename
// Route the downloaded file to the appropriate field bucket.
switch {
@@ -359,7 +421,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
defer func() {
err := in.Close()
if err != nil {
logger.Error(fmt.Sprintf("close file header: %s", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("close file header: %s", err))
}
}()
@@ -370,7 +432,12 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename := norm.NFC.String(filepath.Base(fh.Filename))
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
// Use a UUID-based name on disk to avoid filesystem
// NAME_MAX limits with long filenames.
// See: https://github.com/gotenberg/gotenberg/issues/1500.
safeName := uuid.New().String() + filepath.Ext(filename)
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
out, err := os.Create(path)
if err != nil {
@@ -379,7 +446,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
defer func() {
err := out.Close()
if err != nil {
logger.Error(fmt.Sprintf("close local file: %s", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("close local file: %s", err))
}
}()
@@ -389,6 +456,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
}
ctx.files[filename] = path
ctx.diskToOriginal[path] = filename
return nil
}
@@ -407,10 +475,27 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
}
}
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
ctx.Log().Debug(fmt.Sprintf("form files by field: %+v", ctx.filesByField))
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
// Create symlinks from original filenames to UUID-based disk names
// so that relative asset references (e.g., <img src="image.png">)
// resolve correctly when Chromium navigates to a file:// URL.
// Symlink creation is best-effort: it may fail for filenames that
// exceed the filesystem NAME_MAX limit (the reason UUIDs were
// introduced in the first place).
for originalName, diskPath := range ctx.files {
symlinkPath := fmt.Sprintf("%s/%s", ctx.dirPath, originalName)
if symlinkPath == diskPath {
continue
}
err = os.Symlink(filepath.Base(diskPath), symlinkPath)
if err != nil {
logger.DebugContext(context.Background(), fmt.Sprintf("skip symlink for '%s': %s", originalName, err))
}
}
ctx.Log().DebugContext(ctx, fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files: %+v", ctx.files))
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files by field: %+v", ctx.filesByField))
ctx.Log().DebugContext(ctx, fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
return ctx, cancel, err
}
@@ -423,13 +508,34 @@ func (ctx *Context) Request() *http.Request {
// FormData return a [FormData].
func (ctx *Context) FormData() *FormData {
return &FormData{
values: ctx.values,
files: ctx.files,
filesByField: ctx.filesByField,
errors: nil,
values: ctx.values,
files: ctx.files,
filesByField: ctx.filesByField,
diskToOriginal: ctx.diskToOriginal,
errors: nil,
}
}
// OriginalFilename returns the original filename associated with a disk path.
// If no mapping exists, it falls back to [filepath.Base].
func (ctx *Context) OriginalFilename(diskPath string) string {
if original, ok := ctx.diskToOriginal[diskPath]; ok {
return original
}
return filepath.Base(diskPath)
}
// RegisterDiskPath associates a disk path with an original filename so that
// [Context.OriginalFilename] can resolve it later.
func (ctx *Context) RegisterDiskPath(diskPath, originalFilename string) {
ctx.diskToOriginal[diskPath] = originalFilename
}
// DirPath returns the path to the request's working directory.
func (ctx *Context) DirPath() string {
return ctx.dirPath
}
// GeneratePath generates a path within the context's working directory.
// It generates a new UUID-based filename. It does not create a file.
func (ctx *Context) GeneratePath(extension string) string {
@@ -437,10 +543,14 @@ func (ctx *Context) GeneratePath(extension string) string {
}
// GeneratePathFromFilename generates a path within the context's working
// directory, using the given filename (with extension). It does not create
// a file.
// directory. It uses a UUID-based name on disk to avoid filesystem NAME_MAX
// limits but registers the given filename so that [Context.OriginalFilename]
// can resolve it. It does not create a file.
func (ctx *Context) GeneratePathFromFilename(filename string) string {
return fmt.Sprintf("%s/%s", ctx.dirPath, filename)
safeName := uuid.New().String() + filepath.Ext(filename)
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
ctx.diskToOriginal[path] = filename
return path
}
// CreateSubDirectory creates a subdirectory within the context's working
@@ -457,7 +567,7 @@ func (ctx *Context) CreateSubDirectory(dirName string) (string, error) {
// Rename is just a wrapper around [os.Rename], as we need to mock this
// behavior in our tests.
func (ctx *Context) Rename(oldpath, newpath string) error {
ctx.Log().Debug(fmt.Sprintf("rename %s to %s", oldpath, newpath))
ctx.Log().DebugContext(ctx, fmt.Sprintf("rename %s to %s", oldpath, newpath))
err := ctx.pathRename.Rename(oldpath, newpath)
if err != nil {
return fmt.Errorf("rename path: %w", err)
@@ -483,8 +593,8 @@ func (ctx *Context) AddOutputPaths(paths ...string) error {
return nil
}
// Log returns the context [zap.Logger].
func (ctx *Context) Log() *zap.Logger {
// Log returns the context [slog.Logger].
func (ctx *Context) Log() *slog.Logger {
return ctx.logger
}
@@ -500,14 +610,14 @@ func (ctx *Context) BuildOutputFile() (string, error) {
}
if len(ctx.outputPaths) == 1 {
ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0]))
ctx.logger.DebugContext(ctx, fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0]))
return ctx.outputPaths[0], nil
}
filesInfo, err := archives.FilesFromDisk(ctx.Context, nil, func() map[string]string {
f := make(map[string]string)
for _, outputPath := range ctx.outputPaths {
f[outputPath] = ""
f[outputPath] = ctx.OriginalFilename(outputPath)
}
return f
}())
@@ -523,7 +633,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
defer func(out *os.File) {
err := out.Close()
if err != nil {
ctx.logger.Error(fmt.Sprintf("close zip file: %s", err))
ctx.logger.ErrorContext(ctx, fmt.Sprintf("close zip file: %s", err))
}
}(out)
@@ -532,7 +642,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
return "", fmt.Errorf("archive output files: %w", err)
}
ctx.logger.Debug(fmt.Sprintf("archive '%s' created", archivePath))
ctx.logger.DebugContext(ctx, fmt.Sprintf("archive '%s' created", archivePath))
return archivePath, nil
}
@@ -543,7 +653,7 @@ func (ctx *Context) OutputFilename(outputPath string) string {
filename := ctx.echoCtx.Get("outputFilename").(string)
if filename == "" {
return filepath.Base(outputPath)
return ctx.OriginalFilename(outputPath)
}
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))

View File

@@ -3,6 +3,7 @@ package api
import (
"bytes"
"context"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -10,7 +11,6 @@ import (
"time"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
@@ -35,14 +35,14 @@ func TestNewContext_Cancellation(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
timeout := time.Duration(10) * time.Second
downloadFromCfg := downloadFromConfig{
disable: true,
}
ctx, cancel, err := newContext(c, logger, fs, timeout, 0, downloadFromCfg, "trace", "trace")
ctx, cancel, err := newContext(c, logger, fs, timeout, 0, downloadFromCfg)
if err != nil {
t.Fatalf("expected no error from newContext, got: %v", err)
}

View File

@@ -1,3 +1,3 @@
// Package api provides a module, which is an HTTP server. Other modules may
// add multipart/form-data routes, middlewares, and health checks.
// Package api implements the HTTP server module. Other modules register
// multipart/form data routes, middlewares, and health checks through it.
package api

View File

@@ -1,6 +1,8 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
@@ -12,8 +14,6 @@ import (
"strings"
"time"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
@@ -33,10 +33,11 @@ const (
//
// form := ctx.FormData()
type FormData struct {
values map[string][]string
files map[string]string
filesByField map[string][]string
errors error
values map[string][]string
files map[string]string
filesByField map[string][]string
diskToOriginal map[string]string
errors error
}
// Validate returns nil or an error related to the [FormData] values, with a
@@ -391,6 +392,38 @@ func (form *FormData) Embeds(target *[]string) *FormData {
return form
}
// EmbedsMetadata parses the "embedsMetadata" form field (a JSON string) into
// a map keyed by filename. Each value is a map of property names to values
// (e.g., "mimeType" and "relationship").
//
// var metadata map[string]map[string]string
//
// ctx.FormData().EmbedsMetadata(&metadata)
func (form *FormData) EmbedsMetadata(target *map[string]map[string]string) *FormData {
if form.errors != nil {
return form
}
val, ok := form.values["embedsMetadata"]
if !ok || len(val) == 0 || val[0] == "" {
return form
}
raw := val[0]
parsed := make(map[string]map[string]string)
err := json.Unmarshal([]byte(raw), &parsed)
if err != nil {
form.append(
fmt.Errorf("form field 'embedsMetadata' is invalid: %w", err),
)
return form
}
*target = parsed
return form
}
// MandatoryPaths binds the absolute paths of form data files, according to a
// list of file extensions, to a string slice variable. It populates an error
// if there is no file for given file extensions.
@@ -450,6 +483,15 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
watermarks, wmOk := form.filesByField[WatermarkFormField]
stamps, stOk := form.filesByField[StampFormField]
// Collect (originalFilename, diskPath) pairs so that we can sort by
// original filename rather than by UUID-based disk name.
// See https://github.com/gotenberg/gotenberg/issues/1500.
type entry struct {
original string
disk string
}
var entries []entry
for filename, path := range form.files {
if ok && slices.Contains(embeds, path) {
continue
@@ -466,20 +508,33 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
for _, ext := range extensions {
// See https://github.com/gotenberg/gotenberg/issues/228.
if strings.ToLower(filepath.Ext(filename)) == ext {
*target = append(*target, path)
entries = append(entries, entry{original: filename, disk: path})
}
}
}
// See https://github.com/gotenberg/gotenberg/issues/139.
sort.Sort(gotenberg.AlphanumericSort(*target))
originals := make(gotenberg.AlphanumericSort, len(entries))
for i, e := range entries {
originals[i] = e.original
}
sort.Sort(originals)
// Build a lookup from original name to disk path.
lookup := make(map[string]string, len(entries))
for _, e := range entries {
lookup[e.original] = e.disk
}
for _, o := range originals {
*target = append(*target, lookup[o])
}
return form
}
// append adds an error to the list of errors.
func (form *FormData) append(err error) {
form.errors = multierr.Append(form.errors, err)
form.errors = errors.Join(form.errors, err)
}
// mustValue binds the target interface with a form field. If the value is

View File

@@ -5,6 +5,7 @@ import (
"crypto/subtle"
"errors"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"strings"
@@ -13,9 +14,13 @@ import (
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"go.uber.org/zap"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
semconvutil "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
)
var (
@@ -69,8 +74,7 @@ func ParseError(err error) (int, string) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues"
}
var invalidArgsError *gotenberg.PdfEngineInvalidArgsError
if errors.As(err, &invalidArgsError) {
if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {
return http.StatusBadRequest, invalidArgsError.Error()
}
@@ -87,14 +91,14 @@ func ParseError(err error) (int, string) {
// returns a response as "text/plain; charset=UTF-8".
func httpErrorHandler() echo.HTTPErrorHandler {
return func(err error, c echo.Context) {
logger := c.Get("logger").(*zap.Logger)
logger := c.Get("logger").(*slog.Logger)
status, message := ParseError(err)
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
err = c.String(status, message)
if err != nil {
logger.Error(fmt.Sprintf("send error response: %s", err.Error()))
logger.ErrorContext(c.Request().Context(), fmt.Sprintf("send error response: %s", err.Error()))
}
}
}
@@ -138,32 +142,6 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
}
}
// traceMiddleware sets the request identifier in the [echo.Context] under
// "trace". Its value is either retrieved from the trace header or generated if
// the header is not present / its value is empty.
//
// trace := c.Get("trace").(string)
// traceHeader := c.Get("traceHeader").(string).
func traceMiddleware(header string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Get or create the request identifier.
trace := c.Request().Header.Get(header)
if trace == "" {
trace = uuid.New().String()
}
c.Set("trace", trace)
c.Set("traceHeader", header)
c.Response().Header().Add(header, trace)
// Call the next middleware in the chain.
return next(c)
}
}
}
// outputFilenameMiddleware sets the output filename in the [echo.Context]
// under "outputFilename".
//
@@ -183,59 +161,28 @@ func outputFilenameMiddleware() echo.MiddlewareFunc {
}
}
// loggerMiddleware sets the logger in the [echo.Context] under "logger" and
// logs a synchronous request result.
// telemetryMiddleware manages telemetry. It sets the correlation ID in the
// [echo.Context] under "correlationId".
//
// logger := c.Get("logger").(*zap.Logger)
func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.MiddlewareFunc {
// correlationIdHeader := c.Get("correlationIdHeader").(string)
// correlationId := c.Get("correlationId").(string)
func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader string, disableTelemetryForPaths []string) echo.MiddlewareFunc {
meter := gotenberg.Meter()
semconvSrv := semconvutil.NewHTTPServer(meter)
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
startTime := c.Get("startTime").(time.Time)
trace := c.Get("trace").(string)
rootPath := c.Get("rootPath").(string)
// Create the application logger and add it to our locals.
appLogger := logger.
With(zap.String("log_type", "application")).
With(zap.String("trace", trace))
request := c.Request()
savedCtx := request.Context()
defer func() {
request = request.WithContext(savedCtx)
c.SetRequest(request)
}()
c.Set("logger", appLogger.Named(func() string {
return strings.ReplaceAll(
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
"/",
"",
)
}()))
// Call the next middleware in the chain.
err := next(c)
if err != nil {
c.Error(err)
}
// Create the access logger.
accessLogger := logger.
With(zap.String("log_type", "access")).
With(zap.String("trace", trace))
for _, path := range disableLoggingForPaths {
URI := fmt.Sprintf("%s%s", rootPath, path)
if c.Request().RequestURI == URI {
return nil
}
}
// Last piece for calculating the latency.
finishTime := time.Now()
// Now, let's log!
fields := make([]zap.Field, 12)
fields[0] = zap.String("remote_ip", c.RealIP())
fields[1] = zap.String("host", c.Request().Host)
fields[2] = zap.String("uri", c.Request().RequestURI)
fields[3] = zap.String("method", c.Request().Method)
fields[4] = zap.String("path", func() string {
routePath := func() string {
path := c.Request().URL.Path
if path == "" {
@@ -243,21 +190,127 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
}
return path
}())
fields[5] = zap.String("referer", c.Request().Referer())
fields[6] = zap.String("user_agent", c.Request().UserAgent())
fields[7] = zap.Int("status", c.Response().Status)
fields[8] = zap.Int64("latency", int64(finishTime.Sub(startTime)))
fields[9] = zap.String("latency_human", finishTime.Sub(startTime).String())
fields[10] = zap.Int64("bytes_in", c.Request().ContentLength)
fields[11] = zap.Int64("bytes_out", c.Response().Size)
}()
// Evaluate if we should skip telemetry for this path.
skipTelemetry := false
for _, path := range disableTelemetryForPaths {
URI := fmt.Sprintf("%s%s", rootPath, path)
if c.Request().RequestURI == URI {
skipTelemetry = true
break
}
}
if skipTelemetry {
c.Set("logger", slog.New(slog.DiscardHandler))
err := next(c)
if err != nil {
c.Error(err)
}
return nil
}
correlationId := request.Header.Get(correlationIdHeader)
if correlationId == "" {
correlationId = uuid.NewString()
}
c.Set("correlationIdHeader", correlationIdHeader)
c.Set("correlationId", correlationId)
ctx := otel.GetTextMapPropagator().Extract(savedCtx, propagation.HeaderCarrier(request.Header))
rAttr := semconvSrv.Route(routePath)
opts := []trace.SpanStartOption{
trace.WithAttributes(
semconvSrv.RequestTraceAttrs(serverName, request, semconvutil.RequestTraceAttrsOpts{})...,
),
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(rAttr),
}
spanName := strings.ToUpper(c.Request().Method) + " " + routePath
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, spanName, opts...)
defer span.End()
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(c.Response().Header()))
c.Response().Header().Set(correlationIdHeader, correlationId)
c.SetRequest(c.Request().WithContext(ctx))
appLogger := logger.
With(slog.String("log_type", "application")).
With(slog.String("correlation_id", correlationId))
loggerName := strings.ReplaceAll(
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
"/",
"",
)
c.Set("logger", appLogger.With(slog.String("logger", loggerName)))
// Call the next middleware in the chain.
err := next(c)
finishTime := time.Now()
status := c.Response().Status
if err != nil {
parsedStatus, _ := ParseError(err)
status = parsedStatus
span.SetAttributes(attribute.String("error", err.Error()))
c.Error(err)
}
span.SetStatus(semconvSrv.Status(status))
span.SetAttributes(semconvSrv.ResponseTraceAttrs(semconvutil.ResponseTelemetry{
StatusCode: status,
WriteBytes: c.Response().Size,
})...)
accessLogger := logger.
With(slog.String("log_type", "access")).
With(slog.String("correlation_id", correlationId)).
With(slog.String("remote_ip", c.RealIP())).
With(slog.String("host", c.Request().Host)).
With(slog.String("uri", c.Request().RequestURI)).
With(slog.String("method", c.Request().Method)).
With(slog.String("path", routePath)).
With(slog.String("referer", c.Request().Referer())).
With(slog.String("user_agent", c.Request().UserAgent())).
With(slog.Int("status", c.Response().Status)).
With(slog.Int64("latency", int64(finishTime.Sub(startTime)))).
With(slog.String("latency_human", finishTime.Sub(startTime).String())).
With(slog.Int64("bytes_in", c.Request().ContentLength)).
With(slog.Int64("bytes_out", c.Response().Size))
if err != nil {
accessLogger.Error(err.Error(), fields...)
accessLogger.ErrorContext(ctx, err.Error())
} else {
accessLogger.Info("request handled", fields...)
accessLogger.InfoContext(ctx, "request handled")
}
additionalAttributes := []attribute.KeyValue{
semconvSrv.Route(routePath),
}
semconvSrv.RecordMetrics(ctx, semconvutil.ServerMetricData{
ServerName: serverName,
ResponseSize: c.Response().Size,
MetricAttributes: semconvutil.MetricAttributes{
Req: request,
StatusCode: status,
AdditionalAttributes: additionalAttributes,
},
MetricData: semconvutil.MetricData{
RequestSize: request.ContentLength,
ElapsedTime: float64(time.Since(startTime)) / float64(time.Millisecond),
},
})
return nil
}
}
@@ -284,13 +337,14 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger)
traceHeader := c.Get("traceHeader").(string)
trace := c.Get("trace").(string)
logger, _ := c.Get("logger").(*slog.Logger)
if logger == nil {
return errors.New("no logger in context (possible pool reuse)")
}
// We create a context with a timeout so that underlying processes are
// able to stop early and correctly handle a timeout scenario.
ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace)
ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg)
if err != nil {
cancel()
@@ -344,7 +398,14 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger)
// Guard the type assertion so a pooled [echo.Context] whose
// store has been recycled under us does not crash the process.
// See the webhook async handler for the race this protects
// against.
logger, _ := c.Get("logger").(*slog.Logger)
if logger == nil {
return errors.New("no logger in context (possible pool reuse)")
}
// Define a hard timeout if the route handler fails to timeout as
// expected.
@@ -361,7 +422,7 @@ func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
// This deferred function allows us to recover from such scenarios.
defer func() {
if r := recover(); r != nil {
logger.Debug(fmt.Sprintf("recovering from a panic (possible cause being a hard timeout): %s", r))
logger.DebugContext(hardTimeoutCtx, fmt.Sprintf("recovering from a panic (possible cause being a hard timeout): %s", r))
}
}()
@@ -373,7 +434,7 @@ func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
case err := <-errChan:
return err
case <-hardTimeoutCtx.Done():
logger.Debug("hard timeout as the route handler did not timeout as expected")
logger.DebugContext(hardTimeoutCtx, "hard timeout as the route handler did not timeout as expected")
return fmt.Errorf("hard timeout: %w", hardTimeoutCtx.Err())
}

View File

@@ -0,0 +1,39 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/labstack/echo/v4"
)
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
mw := hardTimeoutMiddleware(100 * time.Millisecond)
handler := mw(func(c echo.Context) error { return nil })
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// c has no "logger" key, mimicking a pooled context whose store was
// recycled under a concurrently running webhook goroutine. The
// middleware must surface an error instead of panicking on the
// unchecked type assertion the pre-fix code relied on.
defer func() {
if r := recover(); r != nil {
t.Fatalf("hardTimeoutMiddleware panicked: %v", r)
}
}()
err := handler(c)
if err == nil {
t.Fatal("expected an error for missing logger, got nil")
}
if !strings.Contains(err.Error(), "logger") {
t.Fatalf("error = %q, want a message mentioning logger", err)
}
}

View File

@@ -1,9 +1,10 @@
package api
import (
"log/slog"
"github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
@@ -73,8 +74,8 @@ func (ctx *ContextMock) OutputPaths() []string {
// SetLogger sets the logger.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetLogger(zap.NewNop())
func (ctx *ContextMock) SetLogger(logger *zap.Logger) {
// ctx.SetLogger(slog.Default())
func (ctx *ContextMock) SetLogger(logger *slog.Logger) {
ctx.logger = logger
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"sync"
@@ -18,15 +19,14 @@ import (
"github.com/chromedp/chromedp"
"github.com/dlclark/regexp2"
"github.com/shirou/gopsutil/v4/process"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type browser interface {
gotenberg.Process
pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
}
type browserArguments struct {
@@ -42,8 +42,10 @@ type browserArguments struct {
hyphenDataDirPath string
// Tasks specific.
allowList *regexp2.Regexp
denyList *regexp2.Regexp
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
clearCache bool
clearCookies bool
disableJavaScript bool
@@ -57,22 +59,24 @@ type chromiumBrowser struct {
ctxMu sync.RWMutex
isStarted atomic.Bool
arguments browserArguments
fs *gotenberg.FileSystem
arguments browserArguments
fs *gotenberg.FileSystem
pinningProxy *pinningProxy
}
func newChromiumBrowser(arguments browserArguments) browser {
b := &chromiumBrowser{
initialCtx: context.Background(),
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
initialCtx: context.Background(),
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs),
}
b.isStarted.Store(false)
return b
}
func (b *chromiumBrowser) Start(logger *zap.Logger) error {
func (b *chromiumBrowser) Start(logger *slog.Logger) error {
if b.isStarted.Load() {
return errors.New("browser is already started")
}
@@ -136,6 +140,25 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error {
opts = append(opts, chromedp.ProxyServer(b.arguments.proxyServer))
}
// Default: route Chromium through the internal pinning proxy so that
// Chromium never performs its own DNS lookup for the navigation URL
// or any sub-resource. The proxy resolves and validates each URL
// once per request and dials the pinned IP, closing the DNS
// rebinding window between Gotenberg's validation and Chromium's
// connect.
//
// Skip when the operator has configured their own egress proxy or
// custom host-resolver mappings: those deployments take
// responsibility for outbound safety themselves and routing through
// an internal proxy would override their configuration.
if b.arguments.proxyServer == "" && b.arguments.hostResolverRules == "" {
err = b.pinningProxy.Start(logger)
if err != nil {
return fmt.Errorf("start pinning proxy: %w", err)
}
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
}
// See https://github.com/gotenberg/gotenberg/issues/524.
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
@@ -164,7 +187,7 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error {
return nil
}
func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
if !b.isStarted.Load() {
// No big deal? Like calling cancel twice.
return nil
@@ -181,7 +204,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
// Clean up stuck processes.
ps, err := process.Processes()
if err != nil {
logger.Error(fmt.Sprintf("list processes: %v", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("list processes: %v", err))
} else {
for _, p := range ps {
func() {
@@ -199,9 +222,9 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
err = p.KillWithContext(killCtx)
if err != nil {
logger.Error(fmt.Sprintf("kill process: %v", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("kill process: %v", err))
} else {
logger.Debug(fmt.Sprintf("Chromium process %d killed", p.Pid))
logger.DebugContext(context.Background(), fmt.Sprintf("Chromium process %d killed", p.Pid))
}
}()
}
@@ -215,15 +238,15 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
err = os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("remove Chromium's user profile directory: %s", err))
} else {
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
logger.DebugContext(context.Background(), fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
}
// Also, remove Chromium-specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime)
err = gotenberg.GarbageCollect(context.Background(), logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime)
if err != nil {
logger.Error(err.Error())
logger.ErrorContext(context.Background(), err.Error())
}
}()
}(copyUserProfileDirPath, expirationTime)
@@ -236,10 +259,19 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
b.userProfileDirPath = ""
b.isStarted.Store(false)
// Stop the pinning proxy after Chromium shutdown so that any
// in-flight requests Chromium issues during teardown complete. The
// Stop call is a no-op when the proxy was not started (operator
// configured --chromium-proxy-server or --chromium-host-resolver-rules).
err := b.pinningProxy.Stop(logger)
if err != nil {
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy: %s", err))
}
return nil
}
func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
// Good to know: the supervisor does not call this method if no first start
// or if the process is restarting.
@@ -266,14 +298,14 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
return err
}))
if err != nil {
logger.Error(fmt.Sprintf("browser health check failed: %s", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("browser health check failed: %s", err))
return false
}
return true
}
func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
@@ -285,7 +317,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(logger, options.PrintBackground),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
@@ -299,7 +331,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
})
}
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
@@ -311,7 +343,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(logger, true),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
@@ -326,7 +358,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
})
}
func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string, options Options, tasks chromedp.Tasks) error {
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, tasks chromedp.Tasks) error {
if !b.isStarted.Load() {
return errors.New("browser not started, cannot handle tasks")
}
@@ -336,8 +368,12 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
return errors.New("context has no deadline")
}
// We validate the "main" URL against our allowed / deny lists.
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
// We validate the "main" URL against our allowed / deny lists, and
// against the IP-based outbound URL guard. See [gotenberg.FilterOutboundURL].
err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline,
gotenberg.WithDenyPrivateIPs(b.arguments.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(b.arguments.denyPublicIPs),
)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
@@ -356,9 +392,12 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
// the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011.
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
extraHttpHeaders: options.ExtraHttpHeaders,
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
denyPrivateIPs: b.arguments.denyPrivateIPs,
denyPublicIPs: b.arguments.denyPublicIPs,
allowedFilePrefixes: options.AllowedFilePrefixes,
extraHttpHeaders: options.ExtraHttpHeaders,
})
var (
@@ -381,6 +420,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
cancelOnMainPageError: taskCancel,
})
}
@@ -410,11 +450,45 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
loadingFailedMu: &loadingFailedMu,
resourceLoadingFailed: &resourceLoadingFailed,
resourceLoadingFailedMu: &resourceLoadingFailedMu,
cancelOnMainPageError: taskCancel,
})
err = chromedp.Run(taskCtx, tasks...)
if err != nil {
errMessage := err.Error()
runErr := chromedp.Run(taskCtx, tasks...)
// Check event-driven errors first — they take priority over chromedp.Run
// 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,
// chromedp.Run returns a context error that is less informative.
// See https://github.com/gotenberg/gotenberg/issues/1492.
// See https://github.com/gotenberg/gotenberg/issues/613.
invalidHttpStatusCodeMu.RLock()
defer invalidHttpStatusCodeMu.RUnlock()
if invalidHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
invalidResourceHttpStatusCodeMu.RLock()
defer invalidResourceHttpStatusCodeMu.RUnlock()
if invalidResourceHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
}
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
loadingFailedMu.RLock()
defer loadingFailedMu.RUnlock()
if loadingFailed != nil {
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
}
if runErr != nil {
errMessage := runErr.Error()
if strings.Contains(errMessage, "Printing failed (-32000)") {
return ErrPrintingFailed
@@ -436,23 +510,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
return ErrRpccMessageTooLarge
}
return fmt.Errorf("handle tasks: %w", err)
}
// See https://github.com/gotenberg/gotenberg/issues/613.
invalidHttpStatusCodeMu.RLock()
defer invalidHttpStatusCodeMu.RUnlock()
if invalidHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
invalidResourceHttpStatusCodeMu.RLock()
defer invalidResourceHttpStatusCodeMu.RUnlock()
if invalidResourceHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
return fmt.Errorf("handle tasks: %w", runErr)
}
// See https://github.com/gotenberg/gotenberg/issues/262.
@@ -463,16 +521,6 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
}
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
loadingFailedMu.RLock()
defer loadingFailedMu.RUnlock()
if loadingFailed != nil {
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
if options.FailOnResourceLoadingFailed {
if resourceLoadingFailed != nil {

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
@@ -14,7 +15,11 @@ import (
"github.com/chromedp/cdproto/network"
"github.com/dlclark/regexp2"
flag "github.com/spf13/pflag"
"go.uber.org/zap"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -91,10 +96,17 @@ type Chromium struct {
maxConcurrency int64
args browserArguments
logger *zap.Logger
logger *slog.Logger
browser browser
supervisor gotenberg.ProcessSupervisor
engine gotenberg.PdfEngine
reqsCounter metric.Int64Counter
errsCounter metric.Int64Counter
conversionDurationCounter metric.Float64Histogram
queueWaitDurationCounter metric.Float64Histogram
pdfOutputSizeCounter metric.Int64Histogram
imageOutputSizeCounter metric.Int64Histogram
}
// Options are the common options for all conversions.
@@ -105,6 +117,10 @@ type Options struct {
// rendered until this event is fired.
SkipNetworkIdleEvent bool
// SkipNetworkAlmostIdleEvent set if the conversion should wait for the
// "networkAlmostIdle" event.
SkipNetworkAlmostIdleEvent bool
// FailOnHttpStatusCodes sets if the conversion should fail if the status
// code from the main page matches with one of its entries.
FailOnHttpStatusCodes []int64
@@ -172,6 +188,15 @@ type Options struct {
// OmitBackground hides the default white background and allows generating
// PDFs with transparency.
OmitBackground bool
// AllowedFilePrefixes restricts file:// sub-resource access to only
// these directory prefixes. Applied in listenForEventRequestPaused in
// addition to the global allow/deny lists. An empty slice
// default-denies every file:// sub-resource, so routes that legitimately
// render local files (HTML, Markdown) must populate this with the
// request working directory while routes that navigate remote URLs
// leave it empty. Set internally by route handlers, not via form data.
AllowedFilePrefixes []string
}
// EmulatedMediaFeature gathers the available entries for emulating a media
@@ -191,6 +216,7 @@ type EmulatedMediaFeature struct {
func DefaultOptions() Options {
return Options{
SkipNetworkIdleEvent: true,
SkipNetworkAlmostIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599},
FailOnResourceHttpStatusCodes: nil,
IgnoreResourceHttpStatusDomains: nil,
@@ -389,8 +415,8 @@ type ExtraHttpHeader struct {
// Api helps to interact with Chromium for converting HTML documents to PDF.
type Api interface {
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
}
// Provider is a module interface that exposes a method for creating an [Api]
@@ -412,6 +438,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
fs.Int64("chromium-restart-after", 100, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
fs.Duration("chromium-idle-shutdown-timeout", 0, "Shutdown Chromium after being idle for the given duration. Set to 0 to disable this feature")
fs.Int64("chromium-max-concurrency", 6, "Maximum number of concurrent conversions. Chromium supports up to 6")
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
@@ -421,8 +448,10 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
fs.String("chromium-deny-list", `^file:(?!//\/tmp/).*`, "Set the denied URLs for Chromium using a regular expression")
fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values")
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services")
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
@@ -469,27 +498,21 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
hyphenDataDirPath: hyphenDataDirPath,
allowList: flags.MustRegexp("chromium-allow-list"),
denyList: flags.MustRegexp("chromium-deny-list"),
allowList: flags.MustRegexpSlice("chromium-allow-list"),
denyList: flags.MustRegexpSlice("chromium-deny-list"),
denyPrivateIPs: flags.MustBool("chromium-deny-private-ips"),
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
clearCache: flags.MustBool("chromium-clear-cache"),
clearCookies: flags.MustBool("chromium-clear-cookies"),
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
}
// Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(mod)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
mod.logger = logger.Named("browser")
mod.logger = gotenberg.Logger(mod).With(slog.String("logger", "browser"))
// Process.
mod.browser = newChromiumBrowser(mod.args)
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency)
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
// PDF Engine.
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
@@ -502,6 +525,109 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
}
mod.engine = engine
// Metrics.
meter := gotenberg.Meter()
// Observable gauges.
_, err = meter.Int64ObservableGauge(
"chromium.requests.active",
metric.WithDescription("Current number of active Chromium requests"),
metric.WithUnit("{request}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(mod.supervisor.ActiveTasksCount())
return nil
}),
)
if err != nil {
return fmt.Errorf("create chromium.requests.active gauge: %w", err)
}
_, err = meter.Int64ObservableGauge(
"chromium.requests.queue_size",
metric.WithDescription("Current number of Chromium conversion requests waiting to be treated"),
metric.WithUnit("{request}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(mod.supervisor.ReqQueueSize())
return nil
}),
)
if err != nil {
return fmt.Errorf("create chromium.requests.queue_size gauge: %w", err)
}
_, err = meter.Int64ObservableCounter(
"chromium.process.restarts.total",
metric.WithDescription("Current number of Chromium restarts"),
metric.WithUnit("{restart}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(mod.supervisor.RestartsCount())
return nil
}),
)
if err != nil {
return fmt.Errorf("create chromium.process.restarts.total counter: %w", err)
}
// Counters.
mod.reqsCounter, err = meter.Int64Counter(
"chromium.requests.total",
metric.WithDescription("Total number of Chromium conversion requests"),
metric.WithUnit("{request}"),
)
if err != nil {
return fmt.Errorf("create chromium.requests.total counter: %w", err)
}
mod.errsCounter, err = meter.Int64Counter(
"chromium.errors.total",
metric.WithDescription("Total number of Chromium conversion errors"),
metric.WithUnit("{error}"),
)
if err != nil {
return fmt.Errorf("create chromium.errors.total counter: %w", err)
}
// Histograms.
durationBuckets := metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60)
mod.conversionDurationCounter, err = meter.Float64Histogram(
"chromium.conversion.duration",
metric.WithDescription("Duration of Chromium conversions"),
metric.WithUnit("s"),
durationBuckets,
)
if err != nil {
return fmt.Errorf("create chromium.conversion.duration histogram: %w", err)
}
mod.queueWaitDurationCounter, err = meter.Float64Histogram(
"chromium.queue.wait.duration",
metric.WithDescription("Duration of waiting in queue for Chromium conversions"),
metric.WithUnit("s"),
durationBuckets,
)
if err != nil {
return fmt.Errorf("create chromium.queue.wait.duration histogram: %w", err)
}
mod.pdfOutputSizeCounter, err = meter.Int64Histogram(
"chromium.pdf.output.size",
metric.WithDescription("Size of PDF output from Chromium conversions"),
metric.WithUnit("By"),
)
if err != nil {
return fmt.Errorf("create chromium.pdf.output.size histogram: %w", err)
}
mod.imageOutputSizeCounter, err = meter.Int64Histogram(
"chromium.image.output.size",
metric.WithDescription("Size of image output from Chromium screenshots"),
metric.WithUnit("By"),
)
if err != nil {
return fmt.Errorf("create chromium.image.output.size histogram: %w", err)
}
return nil
}
@@ -552,7 +678,7 @@ func (mod *Chromium) StartupMessage() string {
func (mod *Chromium) Stop(ctx context.Context) error {
// Block until the context is done so that another module may gracefully
// stop before we do a shutdown.
mod.logger.Debug("wait for the end of grace duration")
mod.logger.DebugContext(ctx, "wait for the end of grace duration")
<-ctx.Done()
@@ -668,20 +794,160 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
}
// Pdf converts a URL to PDF.
func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return mod.supervisor.Run(ctx, logger, func() error {
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
)
defer span.End()
start := time.Now()
var conversionStart time.Time
err := mod.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return mod.browser.pdf(ctx, logger, url, outputPath, options)
})
end := time.Now()
status := "success"
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
status = "timeout"
} else {
status = "error"
}
reason := "unknown"
switch {
case errors.Is(err, context.DeadlineExceeded):
reason = "timeout"
case errors.Is(err, context.Canceled):
reason = "context_cancelled"
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
reason = "invalid_input"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
reason = "chromium_unavailable"
}
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
))
}
if !conversionStart.IsZero() {
waitDuration := conversionStart.Sub(start).Seconds()
conversionDuration := end.Sub(conversionStart).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
mod.conversionDurationCounter.Record(ctx, conversionDuration, metric.WithAttributes(
attribute.String("status", status),
))
} else {
waitDuration := end.Sub(start).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
}
mod.reqsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("status", status),
))
if err == nil {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
}
span.SetStatus(codes.Ok, "")
return nil
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
func (mod *Chromium) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return mod.supervisor.Run(ctx, logger, func() error {
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
)
defer span.End()
start := time.Now()
var conversionStart time.Time
err := mod.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return mod.browser.screenshot(ctx, logger, url, outputPath, options)
})
end := time.Now()
status := "success"
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
status = "timeout"
} else {
status = "error"
}
reason := "unknown"
switch {
case errors.Is(err, context.DeadlineExceeded):
reason = "timeout"
case errors.Is(err, context.Canceled):
reason = "context_cancelled"
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
reason = "invalid_input"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
reason = "chromium_maximum_queue_size_exceeded"
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
reason = "chromium_unavailable"
}
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
))
}
if !conversionStart.IsZero() {
waitDuration := conversionStart.Sub(start).Seconds()
conversionDuration := end.Sub(conversionStart).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
mod.conversionDurationCounter.Record(ctx, conversionDuration, metric.WithAttributes(
attribute.String("status", status),
))
} else {
waitDuration := end.Sub(start).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
}
mod.reqsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("status", status),
))
if err == nil {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
}
span.SetStatus(codes.Ok, "")
return nil
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Interface guards.

View File

@@ -1,28 +1,28 @@
package chromium
import (
"context"
"fmt"
"io"
"go.uber.org/zap"
"log/slog"
)
// debugLogger is wrapper around a [zap.Logger] which is used for debugging
// debugLogger is wrapper around a [slog.Logger] which is used for debugging
// Chromium.
type debugLogger struct {
logger *zap.Logger
logger *slog.Logger
}
// Write logs the bytes in a debug message.
func (debug *debugLogger) Write(p []byte) (n int, err error) {
debug.logger.Debug(string(p))
debug.logger.DebugContext(context.Background(), string(p))
return len(p), nil
}
// Printf logs a debug message.
func (debug *debugLogger) Printf(format string, v ...any) {
debug.logger.Debug(fmt.Sprintf(format, v...))
debug.logger.DebugContext(context.Background(), fmt.Sprintf(format, v...))
}
// Interface guards.

View File

@@ -1,4 +1,3 @@
// Package chromium provides a module which adds routes for converting HTML
// documents to PDF. Other modules may also retrieve the [Api] provided by this
// module.
// Package chromium adds routes for converting HTML documents to PDF. Exposes
// an [Api] for other modules.
package chromium

Some files were not shown because too many files have changed in this diff Show More