mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db51f9026d | ||
|
|
3187980ead | ||
|
|
eff9444294 | ||
|
|
999dd2db40 | ||
|
|
f44d59d514 | ||
|
|
092b526ab5 | ||
|
|
0b5eaff8d9 | ||
|
|
46e190970f | ||
|
|
55d19522a8 | ||
|
|
924576d3d4 | ||
|
|
405d8d1c2b | ||
|
|
1b1e100107 | ||
|
|
c3b4702424 | ||
|
|
866ae53436 | ||
|
|
53e6a0ecf8 | ||
|
|
eab3d2da12 | ||
|
|
3f01ca18d3 | ||
|
|
405f1069c0 | ||
|
|
f1d1215c7c | ||
|
|
9bf12e7cb9 | ||
|
|
b87a6d4dfb | ||
|
|
a80191a288 | ||
|
|
e4a43434dc | ||
|
|
4811a00543 | ||
|
|
cfb48d9af4 | ||
|
|
8b65315a09 | ||
|
|
20522fd1ac | ||
|
|
e28e2ebf0f | ||
|
|
15050a311b | ||
|
|
c99eb471f9 | ||
|
|
53c2f97008 | ||
|
|
61cadd0e6a | ||
|
|
813926cd47 | ||
|
|
f447e31ae2 | ||
|
|
00c6ed0387 | ||
|
|
cdf861bcfd | ||
|
|
5279d73928 | ||
|
|
e6d2a5640b | ||
|
|
4387a4f122 | ||
|
|
bf0d0a4f40 | ||
|
|
f8809b3943 | ||
|
|
9e5b05ad3c | ||
|
|
452a500955 | ||
|
|
1ab0117943 | ||
|
|
5be574081c | ||
|
|
6430bc6a5c | ||
|
|
e0eff40f74 | ||
|
|
385cbe6590 | ||
|
|
043b1588de | ||
|
|
e5d861def3 | ||
|
|
72bc6f895a | ||
|
|
7549a69f71 | ||
|
|
ebf0548d19 | ||
|
|
d3a65a587c | ||
|
|
8f7c1c98ad | ||
|
|
40d75882a2 | ||
|
|
bb91259829 | ||
|
|
0b33be17a4 | ||
|
|
3a78b89c97 | ||
|
|
c72be765b0 | ||
|
|
4e9f63004d | ||
|
|
08088c15f4 | ||
|
|
8625a4e899 | ||
|
|
06b2b2e10c | ||
|
|
bd6d92be9b | ||
|
|
ed22f1e5e6 | ||
|
|
8e3acc8d0a | ||
|
|
51afa5ba85 | ||
|
|
d67500ee05 | ||
|
|
0bec14c6ac |
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
65
.bruno/README.md
Normal 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
3
.env
@@ -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
|
||||
|
||||
34
.github/actions/build-test-push/action.yml
vendored
34
.github/actions/build-test-push/action.yml
vendored
@@ -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 }}"
|
||||
|
||||
164
.github/actions/build-test-push/build.sh
vendored
164
.github/actions/build-test-push/build.sh
vendored
@@ -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
|
||||
|
||||
2
.github/actions/merge/action.yml
vendored
2
.github/actions/merge/action.yml
vendored
@@ -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
|
||||
|
||||
62
.github/workflows/continuous-delivery.yml
vendored
62
.github/workflows/continuous-delivery.yml
vendored
@@ -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 }}"
|
||||
|
||||
122
.github/workflows/continuous-integration.yml
vendored
122
.github/workflows/continuous-integration.yml
vendored
@@ -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 }}"
|
||||
|
||||
@@ -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$
|
||||
|
||||
336
AGENTS.md
336
AGENTS.md
@@ -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,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.
|
||||
197
CONTRIBUTING.md
197
CONTRIBUTING.md
@@ -1,41 +1,30 @@
|
||||
# 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: **backward compatibility** (never rename or remove CLI flags, environment variables, API form fields, or HTTP endpoints without discussion) and **defensive programming** (assume input is malformed, handle errors explicitly, never panic).
|
||||
|
||||
## Before You Start
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
make build # Build the Docker image
|
||||
make run # Run a local Gotenberg container
|
||||
```
|
||||
|
||||
### Development Loop
|
||||
|
||||
```bash
|
||||
# Write your code, then:
|
||||
make build # Build the Docker image
|
||||
make run # Run a local Gotenberg container
|
||||
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 build # Required before integration tests
|
||||
make test-integration # Run all integration tests
|
||||
make telemetry # Start OpenTelemetry collector and OpenObserve
|
||||
make down # Stop all compose containers
|
||||
```
|
||||
|
||||
To run only the integration tests relevant to your change:
|
||||
Run only the integration tests relevant to your change:
|
||||
|
||||
```bash
|
||||
make test-integration TAGS=health
|
||||
@@ -43,21 +32,159 @@ make test-integration TAGS=chromium-convert-html
|
||||
make test-integration TAGS="merge,split"
|
||||
```
|
||||
|
||||
## Submitting a Pull Request
|
||||
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
|
||||
|
||||
Before opening a PR, verify:
|
||||
| Command | Purpose | When to use |
|
||||
| ---------------- | --------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `make run` | Run Gotenberg container via `docker compose` | Manual testing. Flags configured via Makefile variables and compose.yaml |
|
||||
| `make telemetry` | Start 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 |
|
||||
|
||||
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
|
||||
## 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.
|
||||
|
||||
## Coding rules
|
||||
|
||||
### Module system
|
||||
|
||||
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/`.
|
||||
|
||||
Determine if a feature belongs in an existing module before creating a new one. Only create a new module for a genuinely separate concern.
|
||||
|
||||
### 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.
|
||||
- Input is validated defensively.
|
||||
|
||||
### Import ordering
|
||||
|
||||
Enforced by `gci`: standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
|
||||
|
||||
### 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.
|
||||
|
||||
### No business logic in `cmd/`
|
||||
|
||||
The `cmd/gotenberg/` package is strictly for wiring and startup.
|
||||
|
||||
### Mocks
|
||||
|
||||
Comprehensive mock implementations for all major interfaces live in `pkg/gotenberg/mocks.go`. Use these for unit tests rather than creating new ones.
|
||||
|
||||
## Documentation rules
|
||||
|
||||
### 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]`.
|
||||
|
||||
### Formatting non-Go files
|
||||
|
||||
Run `make prettify && make lint-prettier` for YAML, Markdown, and JSON.
|
||||
|
||||
## Testing
|
||||
|
||||
Integration tests use 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` to understand step definitions before writing new tests.
|
||||
|
||||
Unit tests: table-driven tests in `*_test.go` files using mocks from `pkg/gotenberg/mocks.go`.
|
||||
|
||||
Run only the relevant integration test tag rather than the full suite (40min timeout).
|
||||
|
||||
See:
|
||||
|
||||
- [`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).
|
||||
|
||||
## Pull requests
|
||||
|
||||
Plan non-trivial changes before coding. Open an issue or draft PR describing what needs to change, the proposed solution (files to modify, interface changes, form fields), and which integration test tags are affected.
|
||||
|
||||
### Guidelines
|
||||
|
||||
- **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/`.
|
||||
- One thing per PR. Keep features, bug fixes, and refactoring separate.
|
||||
- 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.
|
||||
- Bruno collection must be updated if routes were added or modified.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] 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
|
||||
- [ ] Deprecated flags have both old and new names registered, with `fs.MarkDeprecated()`
|
||||
- [ ] Errors wrapped with context: `fmt.Errorf("description: %w", err)`
|
||||
- [ ] No business logic in `cmd/`
|
||||
- [ ] No panics in production code paths
|
||||
- [ ] New features belong in the correct module (or justify a new one)
|
||||
- [ ] Linting: `make fmt && make lint && make prettify && make lint-prettier` passes with zero warnings
|
||||
- [ ] Every exported function, type, constant, and variable has a Godoc comment starting with its name
|
||||
- [ ] New packages include a `doc.go` file
|
||||
- [ ] Integration tests pass: `make test-integration` (at minimum, the relevant tags)
|
||||
- [ ] Unit tests pass: `make test-unit`
|
||||
- [ ] Bruno collection updated (if routes were added or modified)
|
||||
|
||||
If any backward compatibility item is violated, the change **must** be flagged as a breaking change.
|
||||
|
||||
### Commits
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/): `<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`).
|
||||
|
||||
Stage specific files. Never `git add -A` or `git add .`.
|
||||
|
||||
@@ -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.
|
||||
141
Makefile
141
Makefile
@@ -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,14 @@ 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_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 +76,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 +126,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 +164,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) \
|
||||
|
||||
48
README.md
48
README.md
@@ -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> · <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> ·
|
||||
<a href="https://gotenberg.dev/docs/getting-started/installation#live-demo"><strong>Live Demo</strong></a> ·
|
||||
<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**
|
||||
|
||||
|
||||
34
SECURITY.md
34
SECURITY.md
@@ -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.
|
||||
|
||||
584
build/Dockerfile
584
build/Dockerfile
@@ -17,16 +17,16 @@ 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.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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
11
cmd/gotenberg-chromium/main.go
Normal file
11
cmd/gotenberg-chromium/main.go
Normal 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()
|
||||
}
|
||||
11
cmd/gotenberg-libreoffice/main.go
Normal file
11
cmd/gotenberg-libreoffice/main.go
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
120
compose.yaml
Normal file
120
compose.yaml
Normal file
@@ -0,0 +1,120 @@
|
||||
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-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
|
||||
75
go.mod
75
go.mod
@@ -5,29 +5,36 @@ go 1.26.0
|
||||
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/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a
|
||||
github.com/chromedp/chromedp v0.15.1
|
||||
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/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/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 +42,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 +61,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 +75,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 +118,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
|
||||
)
|
||||
|
||||
175
go.sum
175
go.sum
@@ -10,8 +10,8 @@ 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=
|
||||
@@ -30,10 +30,10 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||
github.com/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a h1:Kk4P1W58eAf+OUGtx51cM7CcJokJuBEmOxxwPdHFH4Q=
|
||||
github.com/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
|
||||
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
@@ -47,8 +47,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=
|
||||
@@ -63,8 +63,6 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
||||
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/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 +96,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 +107,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 +132,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=
|
||||
@@ -152,14 +152,14 @@ 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/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 +172,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 +186,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 +198,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 +208,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,8 +231,8 @@ 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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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=
|
||||
@@ -240,8 +240,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
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 +259,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 +324,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 +351,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=
|
||||
|
||||
32
otel-collector-config.yaml
Normal file
32
otel-collector-config.yaml
Normal 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
22
package-lock.json
generated
@@ -5,7 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.8.2",
|
||||
"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.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz",
|
||||
"integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
|
||||
"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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.8.2",
|
||||
"prettier-plugin-gherkin": "^3.1.3",
|
||||
"prettier-plugin-sh": "^0.18.0"
|
||||
}
|
||||
|
||||
@@ -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,7 +50,7 @@ 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
|
||||
}
|
||||
@@ -63,7 +62,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 +109,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 +124,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 +134,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 +150,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 +163,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 +194,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
2
pkg/gotenberg/internal/log/doc.go
Normal file
2
pkg/gotenberg/internal/log/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package log gathers internal logging utilities.
|
||||
package log
|
||||
18
pkg/gotenberg/internal/log/gcp.go
Normal file
18
pkg/gotenberg/internal/log/gcp.go
Normal 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"
|
||||
}
|
||||
}
|
||||
171
pkg/gotenberg/internal/log/handler.go
Normal file
171
pkg/gotenberg/internal/log/handler.go
Normal 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}
|
||||
}
|
||||
37
pkg/gotenberg/internal/log/log.go
Normal file
37
pkg/gotenberg/internal/log/log.go
Normal 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
|
||||
)
|
||||
128
pkg/gotenberg/internal/log/stdhandler.go
Normal file
128
pkg/gotenberg/internal/log/stdhandler.go
Normal 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)
|
||||
}
|
||||
7
pkg/gotenberg/internal/otel/doc.go
Normal file
7
pkg/gotenberg/internal/otel/doc.go
Normal 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
|
||||
166
pkg/gotenberg/internal/otel/otel.go
Normal file
166
pkg/gotenberg/internal/otel/otel.go
Normal 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
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
325
pkg/gotenberg/outbound.go
Normal file
325
pkg/gotenberg/outbound.go
Normal file
@@ -0,0 +1,325 @@
|
||||
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")
|
||||
|
||||
// netipResolver is the subset of [net.Resolver] used by
|
||||
// [ResolveAndCheckPublic]. 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 [ResolveAndCheckPublic]. 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 returns the resolved addresses,
|
||||
// or an error if any resolved address fails [IsPublicIP]. If host is itself
|
||||
// an IP literal, it is checked directly without performing a DNS lookup.
|
||||
// The returned slice can be used to pin a subsequent dial to a specific IP
|
||||
// and prevent DNS rebinding between this validation and the connect.
|
||||
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
if host == "" {
|
||||
return nil, errors.New("empty host")
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
if !IsPublicIP(addr) {
|
||||
return nil, fmt.Errorf("%q: %w", addr, ErrNonPublicIP)
|
||||
}
|
||||
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 !IsPublicIP(a) {
|
||||
return nil, fmt.Errorf("%q resolves to non-public address %q: %w", host, a, ErrNonPublicIP)
|
||||
}
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// outboundDecision is the result of validating an outbound URL. It is
|
||||
// stashed in the request context by [outboundRoundTripper] so that
|
||||
// [secureDialContext] can either bypass the IP check (allow-list match) or
|
||||
// pin the dial to the IPs that were resolved at validation time.
|
||||
type outboundDecision struct {
|
||||
// bypass is true when an allow-list pattern matched the URL. In that
|
||||
// case the operator has explicitly opted into the destination and the
|
||||
// dial should proceed without an IP check.
|
||||
bypass bool
|
||||
|
||||
// pinned holds the IPs resolved by [ResolveAndCheckPublic] for the URL
|
||||
// host. The dial should be pinned to one of these 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{}
|
||||
|
||||
// httpLikeScheme reports whether scheme is one of http, https, ws, or wss.
|
||||
// Only these schemes go through the IP-based public-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
|
||||
// rejects any non-public address. It returns the resulting
|
||||
// [outboundDecision] which the caller can stash in a context for the dial.
|
||||
func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) (outboundDecision, error) {
|
||||
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 := ResolveAndCheckPublic(ctx, host)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNonPublicIP) {
|
||||
return outboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
||||
}
|
||||
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 function:
|
||||
//
|
||||
// 1. Parses rawURL with [net/url] and lowercases the scheme and host. This
|
||||
// prevents case-variant bypasses such as HTTP://127.0.0.1 from evading
|
||||
// case-sensitive deny-list regexes.
|
||||
// 2. Applies allowList and denyList against the normalized form using the
|
||||
// same OR semantics as [FilterDeadline].
|
||||
// 3. When no allow-list entry explicitly matched and the scheme is one of
|
||||
// http, https, ws, or wss, resolves the host and verifies every
|
||||
// resolved address with [IsPublicIP]. This blocks loopback, private,
|
||||
// link-local, and other internal targets even when the regex layer
|
||||
// does not cover the textual form (for example IPv4-mapped IPv6 like
|
||||
// [::ffff:127.0.0.1], or hostnames that resolve to a private address).
|
||||
//
|
||||
// An allow-list match bypasses the IP check, allowing operators to opt
|
||||
// into specific internal destinations via --*-allow-list flags. 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) error {
|
||||
_, err := decideOutbound(ctx, rawURL, allowList, denyList, deadline)
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 a resolved public IP. An allow-list match
|
||||
// (operator opt-in to a specific destination) bypasses the IP check.
|
||||
//
|
||||
// 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.
|
||||
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp) *http.Client {
|
||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||
base.DialContext = secureDialContext
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &outboundRoundTripper{
|
||||
base: base,
|
||||
allowList: allowList,
|
||||
denyList: denyList,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 and checking the destination itself.
|
||||
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 := ResolveAndCheckPublic(ctx, host)
|
||||
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.
|
||||
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
|
||||
}
|
||||
308
pkg/gotenberg/outbound_test.go
Normal file
308
pkg/gotenberg/outbound_test.go
Normal file
@@ -0,0 +1,308 @@
|
||||
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
|
||||
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,
|
||||
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,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to public IP passes",
|
||||
rawURL: "https://example.com/",
|
||||
deny: defaultDeny,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to loopback blocked",
|
||||
rawURL: "https://rebind.example/",
|
||||
deny: defaultDeny,
|
||||
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",
|
||||
rawURL: "https://mixed.example/",
|
||||
deny: defaultDeny,
|
||||
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,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "10.0.0.1"), nil },
|
||||
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: "Issue 1: Chromium default does not block http to public host (regex layer)",
|
||||
rawURL: "https://example.com/",
|
||||
deny: chromiumDeny,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 1: Chromium default now blocks http to loopback via IP layer",
|
||||
rawURL: "http://127.0.0.1:3000/health",
|
||||
deny: chromiumDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 1: Chromium default now blocks cloud metadata via IP layer",
|
||||
rawURL: "http://169.254.169.254/latest/meta-data/",
|
||||
deny: chromiumDeny,
|
||||
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",
|
||||
rawURL: "http://example.com@127.0.0.1/",
|
||||
deny: defaultDeny,
|
||||
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))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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].
|
||||
|
||||
48
pkg/gotenberg/semconv/bench_test.go
Normal file
48
pkg/gotenberg/semconv/bench_test.go
Normal 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{})
|
||||
}
|
||||
}
|
||||
308
pkg/gotenberg/semconv/client.go
Normal file
308
pkg/gotenberg/semconv/client.go
Normal 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
|
||||
}
|
||||
182
pkg/gotenberg/semconv/client_test.go
Normal file
182
pkg/gotenberg/semconv/client_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
71
pkg/gotenberg/semconv/common_test.go
Normal file
71
pkg/gotenberg/semconv/common_test.go
Normal 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{}))
|
||||
}
|
||||
5
pkg/gotenberg/semconv/doc.go
Normal file
5
pkg/gotenberg/semconv/doc.go
Normal 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
|
||||
15
pkg/gotenberg/semconv/gen.go
Normal file
15
pkg/gotenberg/semconv/gen.go
Normal 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
|
||||
455
pkg/gotenberg/semconv/httpconvtest_test.go
Normal file
455
pkg/gotenberg/semconv/httpconvtest_test.go
Normal 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"
|
||||
}
|
||||
405
pkg/gotenberg/semconv/server.go
Normal file
405
pkg/gotenberg/semconv/server.go
Normal 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
|
||||
}
|
||||
210
pkg/gotenberg/semconv/server_test.go
Normal file
210
pkg/gotenberg/semconv/server_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
127
pkg/gotenberg/semconv/util.go
Normal file
127
pkg/gotenberg/semconv/util.go
Normal 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
|
||||
}
|
||||
75
pkg/gotenberg/semconv/util_test.go
Normal file
75
pkg/gotenberg/semconv/util_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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,58 @@ 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
|
||||
firstStartOnce sync.Once
|
||||
// firstStartErr stores the error from the first Launch attempt executed
|
||||
// via firstStartOnce. Subsequent callers that enter the !firstStart block
|
||||
// need to observe this value after the Once has completed, without
|
||||
// re-executing the closure.
|
||||
firstStartErr error
|
||||
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 +129,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 +179,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 +272,174 @@ 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.firstStartOnce = sync.Once{}
|
||||
s.firstStartErr = nil
|
||||
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 one-time lazy launch of the process on its first
|
||||
// use. Subsequent calls are no-ops.
|
||||
func (s *processSupervisor) ensureStarted(ctx context.Context) error {
|
||||
if s.firstStart.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 +505,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)
|
||||
|
||||
@@ -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,103 @@ 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_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
237
pkg/gotenberg/telemetry.go
Normal 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)
|
||||
)
|
||||
@@ -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,13 +52,13 @@ 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
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
maxRetry int
|
||||
disable bool
|
||||
}
|
||||
@@ -80,9 +82,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 +193,29 @@ 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{`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
|
||||
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 +233,17 @@ 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"),
|
||||
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
|
||||
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 +349,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 +362,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 +453,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 +555,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 +622,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 +649,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)
|
||||
|
||||
@@ -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,23 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
)
|
||||
}
|
||||
|
||||
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
|
||||
err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline)
|
||||
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 +256,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),
|
||||
RetryMax: downloadFromCfg.maxRetry,
|
||||
RetryWaitMin: time.Duration(1) * time.Second,
|
||||
RetryWaitMax: time.Until(deadline),
|
||||
@@ -257,6 +279,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 +290,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 +322,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 +348,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 +375,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 +417,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 +428,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 +442,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 +452,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
}
|
||||
|
||||
ctx.files[filename] = path
|
||||
ctx.diskToOriginal[path] = filename
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -407,10 +471,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 +504,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 +539,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 +563,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 +589,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 +606,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 +629,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 +638,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 +649,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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,11 @@ 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)
|
||||
|
||||
// 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 +395,7 @@ 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)
|
||||
logger := c.Get("logger").(*slog.Logger)
|
||||
|
||||
// Define a hard timeout if the route handler fails to timeout as
|
||||
// expected.
|
||||
@@ -361,7 +412,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 +424,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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 @@ type browserArguments struct {
|
||||
hyphenDataDirPath string
|
||||
|
||||
// Tasks specific.
|
||||
allowList *regexp2.Regexp
|
||||
denyList *regexp2.Regexp
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
clearCache bool
|
||||
clearCookies bool
|
||||
disableJavaScript bool
|
||||
@@ -72,7 +72,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
|
||||
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")
|
||||
}
|
||||
@@ -164,7 +164,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 +181,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 +199,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 +215,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)
|
||||
@@ -239,7 +239,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
|
||||
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 +266,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 +285,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 +299,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 +311,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 +326,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 +336,9 @@ 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
@@ -356,9 +357,10 @@ 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,
|
||||
allowedFilePrefixes: options.AllowedFilePrefixes,
|
||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||
})
|
||||
|
||||
var (
|
||||
@@ -381,6 +383,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
||||
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
|
||||
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
|
||||
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
|
||||
cancelOnMainPageError: taskCancel,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -410,11 +413,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 +473,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 +484,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 {
|
||||
|
||||
@@ -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,12 @@ 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. Set internally by route handlers, not
|
||||
// via form data.
|
||||
AllowedFilePrefixes []string
|
||||
}
|
||||
|
||||
// EmulatedMediaFeature gathers the available entries for emulating a media
|
||||
@@ -191,6 +213,7 @@ type EmulatedMediaFeature struct {
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
SkipNetworkIdleEvent: true,
|
||||
SkipNetworkAlmostIdleEvent: true,
|
||||
FailOnHttpStatusCodes: []int64{499, 599},
|
||||
FailOnResourceHttpStatusCodes: nil,
|
||||
IgnoreResourceHttpStatusDomains: nil,
|
||||
@@ -389,8 +412,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 +435,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 +445,8 @@ 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-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 +493,19 @@ 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"),
|
||||
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 +518,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 +671,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 +787,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,9 @@ package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
@@ -16,15 +18,14 @@ import (
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/dlclark/regexp2"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
type eventRequestPausedOptions struct {
|
||||
allowList, denyList *regexp2.Regexp
|
||||
allowList, denyList []*regexp2.Regexp
|
||||
allowedFilePrefixes []string
|
||||
extraHttpHeaders []ExtraHttpHeader
|
||||
}
|
||||
|
||||
@@ -32,40 +33,67 @@ type eventRequestPausedOptions struct {
|
||||
// allowed or not. It also set the extra HTTP headers, if any.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
|
||||
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) {
|
||||
func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, options eventRequestPausedOptions) {
|
||||
if len(options.extraHttpHeaders) == 0 {
|
||||
logger.Debug("no extra HTTP headers")
|
||||
logger.DebugContext(ctx, "no extra HTTP headers")
|
||||
} else {
|
||||
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
|
||||
}
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
switch e := ev.(type) {
|
||||
case *fetch.EventRequestPaused:
|
||||
if e, ok := ev.(*fetch.EventRequestPaused); ok {
|
||||
go func() {
|
||||
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
|
||||
allow := true
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
logger.Error("context has no deadline, cannot filter URL")
|
||||
logger.ErrorContext(ctx, "context has no deadline, cannot filter URL")
|
||||
return
|
||||
}
|
||||
|
||||
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
|
||||
err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline)
|
||||
if err != nil {
|
||||
logger.Warn(err.Error())
|
||||
logger.WarnContext(ctx, err.Error())
|
||||
allow = false
|
||||
}
|
||||
|
||||
// Additional restriction: if the sub-resource is a file:// URL
|
||||
// and we have allowed file prefixes, restrict access to only
|
||||
// those directories. This prevents cross-request file access
|
||||
// in /tmp.
|
||||
if allow && strings.HasPrefix(e.Request.URL, "file://") && len(options.allowedFilePrefixes) > 0 {
|
||||
prefixMatch := false
|
||||
for _, prefix := range options.allowedFilePrefixes {
|
||||
if strings.HasPrefix(e.Request.URL, "file://"+prefix) {
|
||||
prefixMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !prefixMatch {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL))
|
||||
allow = false
|
||||
}
|
||||
}
|
||||
|
||||
cctx := chromedp.FromContext(ctx)
|
||||
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
|
||||
|
||||
if !allow {
|
||||
// Use AccessDenied so Chromium emits net::ERR_ACCESS_DENIED,
|
||||
// which is intentionally absent from the EventLoadingFailed
|
||||
// known-errors list. Routing through BlockedByClient would
|
||||
// surface the failure, but the Document-type dispatcher in
|
||||
// listenForEventLoadingFailed cannot distinguish a blocked
|
||||
// iframe (sub-frame Document) from a main-page Document, and
|
||||
// would attribute the iframe failure to the main page.
|
||||
// Filter-block observability is provided by the warn log
|
||||
// above instead.
|
||||
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
||||
err = req.Do(executorCtx)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("fail request: %s", err))
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("fail request: %s", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -81,25 +109,26 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
|
||||
for _, header := range options.extraHttpHeaders {
|
||||
if header.Scope == nil {
|
||||
// Non-scoped header.
|
||||
logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
|
||||
continue
|
||||
}
|
||||
|
||||
ok, err := header.Scope.MatchString(e.Request.URL)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
||||
} else if ok {
|
||||
logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
switch {
|
||||
case err != nil:
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
||||
case ok:
|
||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
|
||||
} else {
|
||||
logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
default:
|
||||
logger.DebugContext(ctx, fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(extraHttpHeadersToSet) > 0 {
|
||||
logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
|
||||
|
||||
originalHeaders := e.Request.Headers
|
||||
headers := make(map[string]string)
|
||||
@@ -109,7 +138,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
|
||||
if ok {
|
||||
headers[key] = strValue
|
||||
} else {
|
||||
logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +161,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
|
||||
|
||||
err = req.Do(executorCtx)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("continue request: %s", err))
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("continue request: %s", err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -148,6 +177,7 @@ type eventResponseReceivedOptions struct {
|
||||
ignoreResourceHttpStatusDomains []string
|
||||
invalidResourceHttpStatusCode *error
|
||||
invalidResourceHttpStatusCodeMu *sync.RWMutex
|
||||
cancelOnMainPageError context.CancelFunc
|
||||
}
|
||||
|
||||
// listenForEventResponseReceived listens for an invalid HTTP status code
|
||||
@@ -157,7 +187,7 @@ type eventResponseReceivedOptions struct {
|
||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
func listenForEventResponseReceived(
|
||||
ctx context.Context,
|
||||
logger *zap.Logger,
|
||||
logger *slog.Logger,
|
||||
options eventResponseReceivedOptions,
|
||||
) {
|
||||
normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains)
|
||||
@@ -177,33 +207,40 @@ func listenForEventResponseReceived(
|
||||
}
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventResponseReceived:
|
||||
if ev, ok := ev.(*network.EventResponseReceived); ok {
|
||||
if ev.Response.URL == options.mainPageUrl {
|
||||
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
|
||||
|
||||
if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) {
|
||||
options.invalidHttpStatusCodeMu.Lock()
|
||||
defer options.invalidHttpStatusCodeMu.Unlock()
|
||||
|
||||
*options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
|
||||
|
||||
// Cancel the task context so that any in-flight wait
|
||||
// operations (waitForSelector, waitForExpression, etc.)
|
||||
// abort immediately instead of polling until timeout.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1492.
|
||||
if options.cancelOnMainPageError != nil {
|
||||
options.cancelOnMainPageError()
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
|
||||
|
||||
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
|
||||
if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) {
|
||||
logger.Debug(fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL))
|
||||
return
|
||||
}
|
||||
|
||||
options.invalidResourceHttpStatusCodeMu.Lock()
|
||||
defer options.invalidResourceHttpStatusCodeMu.Unlock()
|
||||
|
||||
*options.invalidResourceHttpStatusCode = multierr.Append(
|
||||
*options.invalidResourceHttpStatusCode = errors.Join(
|
||||
*options.invalidResourceHttpStatusCode,
|
||||
fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
|
||||
)
|
||||
@@ -290,6 +327,7 @@ type eventLoadingFailedOptions struct {
|
||||
loadingFailedMu *sync.RWMutex
|
||||
resourceLoadingFailed *error
|
||||
resourceLoadingFailedMu *sync.RWMutex
|
||||
cancelOnMainPageError context.CancelFunc
|
||||
}
|
||||
|
||||
// listenForEventLoadingFailed listens for an event indicating that the main
|
||||
@@ -298,15 +336,14 @@ type eventLoadingFailedOptions struct {
|
||||
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
|
||||
func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, options eventLoadingFailedOptions) {
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventLoadingFailed:
|
||||
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
|
||||
if ev, ok := ev.(*network.EventLoadingFailed); ok {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
|
||||
|
||||
// We are looking for common errors.
|
||||
// TODO: sufficient?
|
||||
errors := []string{
|
||||
knownErrors := []string{
|
||||
"net::ERR_CONNECTION_CLOSED",
|
||||
"net::ERR_CONNECTION_RESET",
|
||||
"net::ERR_CONNECTION_REFUSED",
|
||||
@@ -320,30 +357,37 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
|
||||
"net::ERR_FILE_NOT_FOUND",
|
||||
"net::ERR_HTTP2_PROTOCOL_ERROR",
|
||||
}
|
||||
if !slices.Contains(errors, ev.ErrorText) {
|
||||
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
|
||||
if !slices.Contains(knownErrors, ev.ErrorText) {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, knownErrors))
|
||||
return
|
||||
}
|
||||
|
||||
if ev.Type == network.ResourceTypeDocument {
|
||||
// Supposition: except iframe, an event loading failed with a
|
||||
// resource type Document is about the main page.
|
||||
logger.Debug("event EventLoadingFailed fired for main page")
|
||||
logger.DebugContext(ctx, "event EventLoadingFailed fired for main page")
|
||||
|
||||
options.loadingFailedMu.Lock()
|
||||
defer options.loadingFailedMu.Unlock()
|
||||
|
||||
*options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
|
||||
|
||||
// Cancel the task context so that any in-flight wait
|
||||
// operations abort immediately.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1492.
|
||||
if options.cancelOnMainPageError != nil {
|
||||
options.cancelOnMainPageError()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("event EventLoadingFailed fired for a resource")
|
||||
logger.DebugContext(ctx, "event EventLoadingFailed fired for a resource")
|
||||
|
||||
options.resourceLoadingFailedMu.Lock()
|
||||
defer options.resourceLoadingFailedMu.Unlock()
|
||||
|
||||
*options.resourceLoadingFailed = multierr.Append(
|
||||
*options.resourceLoadingFailed = errors.Join(
|
||||
*options.resourceLoadingFailed,
|
||||
fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
|
||||
)
|
||||
@@ -354,29 +398,27 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
|
||||
// listenForEventExceptionThrown listens for exceptions in the console and
|
||||
// appends those exceptions to the given error pointer.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
|
||||
func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
switch ev := ev.(type) {
|
||||
case *runtime.EventExceptionThrown:
|
||||
logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
|
||||
if ev, ok := ev.(*runtime.EventExceptionThrown); ok {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
|
||||
|
||||
consoleExceptionsMu.Lock()
|
||||
defer consoleExceptionsMu.Unlock()
|
||||
|
||||
*consoleExceptions = multierr.Append(*consoleExceptions, fmt.Errorf("\n%+v", ev.ExceptionDetails))
|
||||
*consoleExceptions = errors.Join(*consoleExceptions, fmt.Errorf("\n%+v", ev.ExceptionDetails))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// waitForEventDomContentEventFired waits until the event DomContentEventFired
|
||||
// is fired or the context timeout.
|
||||
func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) func() error {
|
||||
func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger) func() error {
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
switch ev.(type) {
|
||||
case *page.EventDomContentEventFired:
|
||||
if _, ok := ev.(*page.EventDomContentEventFired); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
@@ -384,7 +426,7 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.Debug("event DomContentEventFired fired")
|
||||
logger.DebugContext(ctx, "event DomContentEventFired fired")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for event DomContentEventFired: %w", ctx.Err())
|
||||
@@ -394,13 +436,12 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f
|
||||
|
||||
// waitForEventLoadEventFired waits until the event LoadEventFired is fired or
|
||||
// the context timeout.
|
||||
func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func() error {
|
||||
func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func() error {
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
switch ev.(type) {
|
||||
case *page.EventLoadEventFired:
|
||||
if _, ok := ev.(*page.EventLoadEventFired); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
@@ -408,7 +449,7 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func()
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.Debug("event LoadEventFired fired")
|
||||
logger.DebugContext(ctx, "event LoadEventFired fired")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for event LoadEventFired: %w", ctx.Err())
|
||||
@@ -418,39 +459,12 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func()
|
||||
|
||||
// waitForEventNetworkIdle waits until the event networkIdle is fired or the
|
||||
// context timeout.
|
||||
func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() error {
|
||||
func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() error {
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
switch e := ev.(type) {
|
||||
case *page.EventLifecycleEvent:
|
||||
if e.Name == "networkIdle" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.Debug("event networkIdle fired")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for event networkIdle: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventLoadingFinished waits until the event LoadingFinished is fired
|
||||
// or the context timeout.
|
||||
func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func() error {
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
switch ev.(type) {
|
||||
case *network.EventLoadingFinished:
|
||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
@@ -458,7 +472,53 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.Debug("event LoadingFinished fired")
|
||||
logger.DebugContext(ctx, "event networkIdle fired")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for event networkIdle: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventNetworkAlmostIdle waits until the event networkIdle2 is fired
|
||||
// or the context timeout.
|
||||
func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) func() error {
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event networkAlmostIdle fired")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for event networkAlmostIdle: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventLoadingFinished waits until the event LoadingFinished is fired
|
||||
// or the context timeout.
|
||||
func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func() error {
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if _, ok := ev.(*network.EventLoadingFinished); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event LoadingFinished fired")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for event LoadingFinished: %w", ctx.Err())
|
||||
|
||||
@@ -2,38 +2,37 @@ package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// ApiMock is a mock for the [Api] interface.
|
||||
type ApiMock struct {
|
||||
PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
|
||||
ScreenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
PdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
|
||||
ScreenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
}
|
||||
|
||||
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
func (api *ApiMock) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return api.PdfMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
func (api *ApiMock) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
func (api *ApiMock) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return api.ScreenshotMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
// browserMock is a mock for the [browser] interface.
|
||||
type browserMock struct {
|
||||
gotenberg.ProcessMock
|
||||
pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
|
||||
screenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
|
||||
screenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
}
|
||||
|
||||
func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
func (b *browserMock) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return b.pdfMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
func (b *browserMock) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
func (b *browserMock) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return b.screenshotMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
@@ -48,6 +47,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
|
||||
var (
|
||||
skipNetworkIdleEvent bool
|
||||
skipNetworkAlmostIdleEvent bool
|
||||
failOnHttpStatusCodes []int64
|
||||
failOnResourceHttpStatusCodes []int64
|
||||
ignoreResourceHttpStatusDomains []string
|
||||
@@ -67,6 +67,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
|
||||
form := ctx.FormData().
|
||||
Bool("skipNetworkIdleEvent", &skipNetworkIdleEvent, defaultOptions.SkipNetworkIdleEvent).
|
||||
Bool("skipNetworkAlmostIdleEvent", &skipNetworkAlmostIdleEvent, defaultOptions.SkipNetworkAlmostIdleEvent).
|
||||
Custom("failOnHttpStatusCodes", func(value string) error {
|
||||
if value == "" {
|
||||
failOnHttpStatusCodes = defaultOptions.FailOnHttpStatusCodes
|
||||
@@ -148,7 +149,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
|
||||
for i, cookie := range cookies {
|
||||
if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
|
||||
err = multierr.Append(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
|
||||
err = errors.Join(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,14 +182,12 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
|
||||
scope = parts[1]
|
||||
} else {
|
||||
err = multierr.Append(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
|
||||
err = errors.Join(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
|
||||
invalidScopeToken = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if token != "" {
|
||||
valueTokens = append(valueTokens, token)
|
||||
}
|
||||
} else if token != "" {
|
||||
valueTokens = append(valueTokens, token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,9 +199,10 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
if len(scope) > 0 {
|
||||
p, errCompile := regexp2.Compile(scope, regexp2.None)
|
||||
if errCompile != nil {
|
||||
err = multierr.Append(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
||||
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
||||
continue
|
||||
}
|
||||
p.MatchTimeout = 5 * time.Second
|
||||
scopeRegexp = p
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
|
||||
options := Options{
|
||||
SkipNetworkIdleEvent: skipNetworkIdleEvent,
|
||||
SkipNetworkAlmostIdleEvent: skipNetworkAlmostIdleEvent,
|
||||
FailOnHttpStatusCodes: failOnHttpStatusCodes,
|
||||
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
|
||||
IgnoreResourceHttpStatusDomains: ignoreResourceHttpStatusDomains,
|
||||
@@ -420,6 +421,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var url string
|
||||
err := form.
|
||||
@@ -436,7 +438,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
stamp.Expression = stampFile
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||
}
|
||||
@@ -495,6 +497,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var inputPath string
|
||||
err := form.
|
||||
@@ -512,7 +515,8 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("file://%s", inputPath)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||
}
|
||||
@@ -542,6 +546,7 @@ func screenshotHtmlRoute(chromium Api) api.Route {
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("file://%s", inputPath)
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = screenshotUrl(ctx, chromium, url, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTML screenshot: %w", err)
|
||||
@@ -572,6 +577,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
@@ -598,7 +604,8 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||
}
|
||||
@@ -637,6 +644,7 @@ func screenshotMarkdownRoute(chromium Api) api.Route {
|
||||
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||
}
|
||||
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = screenshotUrl(ctx, chromium, url, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("markdown screenshot: %w", err)
|
||||
@@ -662,7 +670,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
||||
var path string
|
||||
|
||||
for _, markdownPath := range markdownPaths {
|
||||
markdownFilename := filepath.Base(markdownPath)
|
||||
markdownFilename := ctx.OriginalFilename(markdownPath)
|
||||
|
||||
if filename == markdownFilename {
|
||||
path = markdownPath
|
||||
@@ -671,7 +679,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
markdownFilesNotFoundErr = multierr.Append(
|
||||
markdownFilesNotFoundErr = errors.Join(
|
||||
markdownFilesNotFoundErr,
|
||||
fmt.Errorf("'%s'", filename),
|
||||
)
|
||||
@@ -722,7 +730,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
||||
return fmt.Sprintf("file://%s", inputPath), nil
|
||||
}
|
||||
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||
filename := ctx.OutputFilename(outputPath)
|
||||
@@ -826,6 +834,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -13,16 +14,15 @@ import (
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||
func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
paperHeight := options.PaperHeight
|
||||
pageRanges := options.PageRanges
|
||||
|
||||
if options.SinglePage {
|
||||
logger.Debug("single page PDF")
|
||||
logger.DebugContext(ctx, "single page PDF")
|
||||
|
||||
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
|
||||
if err != nil {
|
||||
@@ -31,7 +31,9 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
|
||||
|
||||
// There are 96 CSS pixels per inch.
|
||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||
paperHeight = cssContentSize.Height / 96
|
||||
// We add top and bottom margins so that the content area
|
||||
// is large enough to fit the entire content.
|
||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||
}
|
||||
|
||||
@@ -56,11 +58,11 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
|
||||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
||||
|
||||
if !hasCustomHeaderFooter {
|
||||
logger.Debug("no custom header nor footer")
|
||||
logger.DebugContext(ctx, "no custom header nor footer")
|
||||
|
||||
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
|
||||
} else {
|
||||
logger.Debug("with custom header and/or footer")
|
||||
logger.DebugContext(ctx, "with custom header and/or footer")
|
||||
|
||||
printToPdf = printToPdf.
|
||||
WithDisplayHeaderFooter(true).
|
||||
@@ -68,7 +70,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
|
||||
WithFooterTemplate(options.FooterTemplate)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("print to PDF with: %+v", printToPdf))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
|
||||
|
||||
_, stream, err := printToPdf.Do(ctx)
|
||||
if err != nil {
|
||||
@@ -86,7 +88,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
|
||||
defer func() {
|
||||
err = reader.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close reader: %s", err))
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -98,7 +100,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
|
||||
defer func() {
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close output path: %s", err))
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -113,7 +115,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
|
||||
}
|
||||
}
|
||||
|
||||
func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc {
|
||||
func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
captureScreenshot := page.CaptureScreenshot().
|
||||
WithCaptureBeyondViewport(true).
|
||||
@@ -134,7 +136,7 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
|
||||
WithQuality(int64(options.Quality))
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("capture screenshot with: %+v", captureScreenshot))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("capture screenshot with: %+v", captureScreenshot))
|
||||
|
||||
buffer, err := captureScreenshot.Do(ctx)
|
||||
if err != nil {
|
||||
@@ -149,7 +151,7 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
|
||||
defer func() {
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close output path: %s", err))
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -162,9 +164,9 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
|
||||
}
|
||||
}
|
||||
|
||||
func setDeviceMetricsOverride(logger *zap.Logger, width, height int) chromedp.ActionFunc {
|
||||
func setDeviceMetricsOverride(logger *slog.Logger, width, height int) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
logger.Debug("set device metrics override")
|
||||
logger.DebugContext(ctx, "set device metrics override")
|
||||
|
||||
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
|
||||
if err == nil {
|
||||
@@ -175,15 +177,15 @@ func setDeviceMetricsOverride(logger *zap.Logger, width, height int) chromedp.Ac
|
||||
}
|
||||
}
|
||||
|
||||
func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
|
||||
func clearCacheActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/753.
|
||||
if !clear {
|
||||
logger.Debug("cache not cleared")
|
||||
logger.DebugContext(ctx, "cache not cleared")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("clear cache")
|
||||
logger.DebugContext(ctx, "clear cache")
|
||||
|
||||
err := network.ClearBrowserCache().Do(ctx)
|
||||
if err == nil {
|
||||
@@ -194,15 +196,15 @@ func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func clearCookiesActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
|
||||
func clearCookiesActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/753.
|
||||
if !clear {
|
||||
logger.Debug("cookies not cleared")
|
||||
logger.DebugContext(ctx, "cookies not cleared")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("clear cookies")
|
||||
logger.DebugContext(ctx, "clear cookies")
|
||||
|
||||
err := network.ClearBrowserCookies().Do(ctx)
|
||||
if err == nil {
|
||||
@@ -213,15 +215,15 @@ func clearCookiesActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc
|
||||
}
|
||||
}
|
||||
|
||||
func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.ActionFunc {
|
||||
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
||||
if !disable {
|
||||
logger.Debug("JavaScript not disabled")
|
||||
logger.DebugContext(ctx, "JavaScript not disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("disable JavaScript")
|
||||
logger.DebugContext(ctx, "disable JavaScript")
|
||||
|
||||
err := emulation.SetScriptExecutionDisabled(true).Do(ctx)
|
||||
if err == nil {
|
||||
@@ -232,10 +234,10 @@ func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.Acti
|
||||
}
|
||||
}
|
||||
|
||||
func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionFunc {
|
||||
func setCookiesActionFunc(logger *slog.Logger, cookies []Cookie) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if len(cookies) == 0 {
|
||||
logger.Debug("no cookies to set")
|
||||
logger.DebugContext(ctx, "no cookies to set")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -274,21 +276,21 @@ func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionF
|
||||
return fmt.Errorf("set cookie %s: %w", cookiePretty(cookieParams), err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("set cookie %s", cookiePretty(cookieParams)))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("set cookie %s", cookiePretty(cookieParams)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc {
|
||||
func userAgentOverride(logger *slog.Logger, userAgent string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if len(userAgent) == 0 {
|
||||
logger.Debug("no user agent override")
|
||||
logger.DebugContext(ctx, "no user agent override")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("user agent override: %s", userAgent))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("user agent override: %s", userAgent))
|
||||
err := emulation.SetUserAgentOverride(userAgent).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -303,32 +305,32 @@ func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc
|
||||
// network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
//
|
||||
//func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
||||
// return func(ctx context.Context) error {
|
||||
// if len(extraHttpHeaders) == 0 {
|
||||
// logger.Debug("no extra HTTP headers")
|
||||
// return nil
|
||||
// }
|
||||
// func extraHttpHeadersActionFunc(logger *slog.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
||||
// return func(ctx context.Context) error {
|
||||
// if len(extraHttpHeaders) == 0 {
|
||||
// logger.DebugContext(ctx,"no extra HTTP headers")
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
|
||||
// logger.DebugContext(ctx,fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
|
||||
//
|
||||
// headers := make(network.Headers, len(extraHttpHeaders))
|
||||
// for key, value := range extraHttpHeaders {
|
||||
// headers[key] = value
|
||||
// }
|
||||
// headers := make(network.Headers, len(extraHttpHeaders))
|
||||
// for key, value := range extraHttpHeaders {
|
||||
// headers[key] = value
|
||||
// }
|
||||
//
|
||||
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
||||
// if err == nil {
|
||||
// return nil
|
||||
// }
|
||||
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
||||
// if err == nil {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// return fmt.Errorf("set extra HTTP headers: %w", err)
|
||||
// }
|
||||
//}
|
||||
// return fmt.Errorf("set extra HTTP headers: %w", err)
|
||||
// }
|
||||
// }
|
||||
|
||||
func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc {
|
||||
func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, skipNetworkAlmostIdleEvent bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
logger.Debug(fmt.Sprintf("navigate to '%s'", url))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("navigate to '%s'", url))
|
||||
|
||||
_, _, _, _, err := page.Navigate(url).Do(ctx)
|
||||
if err != nil {
|
||||
@@ -344,7 +346,13 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent boo
|
||||
if !skipNetworkIdleEvent {
|
||||
waitFunc = append(waitFunc, waitForEventNetworkIdle(ctx, logger))
|
||||
} else {
|
||||
logger.Debug("skipping network idle event")
|
||||
logger.DebugContext(ctx, "skipping network idle event")
|
||||
}
|
||||
|
||||
if !skipNetworkAlmostIdleEvent {
|
||||
waitFunc = append(waitFunc, waitForEventNetworkAlmostIdle(ctx, logger))
|
||||
} else {
|
||||
logger.DebugContext(ctx, "skipping network almost idle event")
|
||||
}
|
||||
|
||||
err = runBatch(
|
||||
@@ -360,11 +368,11 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent boo
|
||||
}
|
||||
}
|
||||
|
||||
func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, printBackground bool) chromedp.ActionFunc {
|
||||
func hideDefaultWhiteBackgroundActionFunc(logger *slog.Logger, omitBackground, printBackground bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/226.
|
||||
if !omitBackground {
|
||||
logger.Debug("default white background not hidden")
|
||||
logger.DebugContext(ctx, "default white background not hidden")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -373,7 +381,7 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr
|
||||
return fmt.Errorf("validate omit background: %w", ErrOmitBackgroundWithoutPrintBackground)
|
||||
}
|
||||
|
||||
logger.Debug("hide default white background")
|
||||
logger.DebugContext(ctx, "hide default white background")
|
||||
|
||||
err := emulation.SetDefaultBackgroundColorOverride().WithColor(
|
||||
&cdp.RGBA{
|
||||
@@ -391,7 +399,7 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr
|
||||
}
|
||||
}
|
||||
|
||||
func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chromedp.ActionFunc {
|
||||
func forceExactColorsActionFunc(logger *slog.Logger, printBackground bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
css := "html { -webkit-print-color-adjust: exact !important; }"
|
||||
if !printBackground {
|
||||
@@ -399,7 +407,7 @@ func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chrome
|
||||
// print of the background, whatever the printToPDF args.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1154.
|
||||
additionalCss := "html, body { background: none !important; }"
|
||||
logger.Debug(fmt.Sprintf("inject %s as printBackground is %t", additionalCss, printBackground))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("inject %s as printBackground is %t", additionalCss, printBackground))
|
||||
css += additionalCss
|
||||
}
|
||||
|
||||
@@ -423,10 +431,10 @@ func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chrome
|
||||
}
|
||||
}
|
||||
|
||||
func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatures []EmulatedMediaFeature) chromedp.ActionFunc {
|
||||
func emulateMediaTypeActionFunc(logger *slog.Logger, mediaType string, mediaFeatures []EmulatedMediaFeature) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if mediaType == "" && len(mediaFeatures) == 0 {
|
||||
logger.Debug("no emulated media type or features")
|
||||
logger.DebugContext(ctx, "no emulated media type or features")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -437,12 +445,12 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatu
|
||||
emulatedMedia := emulation.SetEmulatedMedia()
|
||||
|
||||
if mediaType != "" {
|
||||
logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("emulate media type '%s'", mediaType))
|
||||
emulatedMedia = emulatedMedia.WithMedia(mediaType)
|
||||
}
|
||||
|
||||
if len(mediaFeatures) > 0 {
|
||||
logger.Debug(fmt.Sprintf("emulate media features %+v", mediaFeatures))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("emulate media features %+v", mediaFeatures))
|
||||
|
||||
features := make([]*emulation.MediaFeature, len(mediaFeatures))
|
||||
for i, f := range mediaFeatures {
|
||||
@@ -464,21 +472,21 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatu
|
||||
}
|
||||
}
|
||||
|
||||
func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, delay time.Duration) chromedp.ActionFunc {
|
||||
func waitDelayBeforePrintActionFunc(logger *slog.Logger, disableJavaScript bool, delay time.Duration) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping wait delay")
|
||||
logger.DebugContext(ctx, "JavaScript disabled, skipping wait delay")
|
||||
return nil
|
||||
}
|
||||
|
||||
if delay <= 0 {
|
||||
logger.Debug("no wait delay")
|
||||
logger.DebugContext(ctx, "no wait delay")
|
||||
return nil
|
||||
}
|
||||
|
||||
// We wait for a given amount of time so that JavaScript
|
||||
// scripts have a chance to finish before printing the page.
|
||||
logger.Debug(fmt.Sprintf("wait '%s' before print", delay))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("wait '%s' before print", delay))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -489,21 +497,21 @@ func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool,
|
||||
}
|
||||
}
|
||||
|
||||
func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, expression string) chromedp.ActionFunc {
|
||||
func waitForExpressionBeforePrintActionFunc(logger *slog.Logger, disableJavaScript bool, expression string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping wait expression")
|
||||
logger.DebugContext(ctx, "JavaScript disabled, skipping wait expression")
|
||||
return nil
|
||||
}
|
||||
|
||||
if expression == "" {
|
||||
logger.Debug("no wait expression")
|
||||
logger.DebugContext(ctx, "no wait expression")
|
||||
return nil
|
||||
}
|
||||
|
||||
// We wait until the evaluation of the expression is true or
|
||||
// until the context is done.
|
||||
logger.Debug(fmt.Sprintf("wait until '%s' is true before print", expression))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("wait until '%s' is true before print", expression))
|
||||
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
|
||||
|
||||
for {
|
||||
@@ -531,14 +539,14 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
|
||||
}
|
||||
}
|
||||
|
||||
func waitForSelectorVisibleBeforePrintActionFunc(logger *zap.Logger, selector string) chromedp.ActionFunc {
|
||||
func waitForSelectorVisibleBeforePrintActionFunc(logger *slog.Logger, selector string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if selector == "" {
|
||||
logger.Debug("no wait selector")
|
||||
logger.DebugContext(ctx, "no wait selector")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("wait until '%s' is visible before print", selector))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("wait until '%s' is visible before print", selector))
|
||||
err := chromedp.WaitVisible(selector, chromedp.ByQuery, chromedp.RetryInterval(time.Duration(100)*time.Millisecond)).Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait visible: %v: %w", err, ErrInvalidSelectorQuery)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user