Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ jobs:
- name: Build service
run: go build ./src/cmd/document-service

trivy:
name: Security scan (filesystem)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@v0.35.0
with:
scan-type: fs
scan-ref: .
severity: CRITICAL,HIGH
exit-code: "0"

docker:
name: Build and Push Docker Image
runs-on: ubuntu-latest
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# CodeQL satisfies branch protection when "Code scanning" is required.
# https://docs.github.com/en/code-security/code-scanning/introduction-to-code-scanning/about-code-scanning-with-codeql
name: CodeQL

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
schedule:
- cron: "29 7 * * 3"

jobs:
analyze:
name: Analyze (Go)
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write

strategy:
fail-fast: false

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: go

- name: Autobuild
uses: github/codeql-action/autobuild@v3

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:go"
8 changes: 6 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ RUN apk add --no-cache \

ENV CHROME_PATH=/usr/bin/chromium

WORKDIR /app
COPY --from=builder /bin/document-service /usr/local/bin/document-service
RUN adduser -D -u 65532 appuser

WORKDIR /tmp
COPY --from=builder --chown=65532:65532 /bin/document-service /usr/local/bin/document-service

USER appuser

EXPOSE 8080

Expand Down
45 changes: 37 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ Base URL defaults to `http://localhost:8080`.
| `GET` | `/healthz` | — | Liveness/readiness probe; returns `200` and body `ok` |
| `POST` | `/html-to-pdf` | Raw HTML | Renders HTML and returns PDF |
| `POST` | `/xml-to-pdf` | Raw XML | Validates XML, escapes it, wraps in HTML `<pre>`, returns PDF |
| `GET` | `/metrics` | — | [Prometheus](https://prometheus.io/) metrics for scraping |

**Limits**

- Request body is capped at **2 MiB**; larger bodies receive `413 Request Entity Too Large`.
- XML must be **well-formed**; invalid XML returns `400` with message `invalid XML`.
- Request body size defaults to **2 MiB** (configurable); larger bodies receive `413 Request Entity Too Large`.
- XML must be **well-formed** (strict decoder); invalid XML returns `400` with message `invalid XML`.
- Concurrent PDF renders are **bounded**; extra requests **wait** for a slot. If the client disconnects while waiting, the server responds with **`408 Request Timeout`**. Other failures while acquiring a slot may yield **`503 Service Unavailable`**.

**Response (success)**

Expand Down Expand Up @@ -61,17 +63,33 @@ curl -sS -o out-xml.pdf \

## Configuration

| Variable | Description |
|----------|-------------|
| `CHROME_PATH` | Full path to Chrome/Chromium executable. If unset, common locations and `PATH` are tried (including macOS Google Chrome). |
All service tuning variables use the `DOCUMENT_SERVICE_*` prefix (except `CHROME_PATH`, which Chromium expects).

| Variable | Default | Description |
|----------|---------|-------------|
| `CHROME_PATH` | *(auto)* | Path to Chrome/Chromium. The Docker image sets `/usr/bin/chromium`. |
| `DOCUMENT_SERVICE_ADDR` | `:8080` | Listen address. |
| `DOCUMENT_SERVICE_READ_HEADER_TIMEOUT` | `5s` | `ReadHeaderTimeout`. |
| `DOCUMENT_SERVICE_READ_TIMEOUT` | `10s` | `ReadTimeout`. |
| `DOCUMENT_SERVICE_WRITE_TIMEOUT` | `60s` | `WriteTimeout`. |
| `DOCUMENT_SERVICE_IDLE_TIMEOUT` | `120s` | `IdleTimeout`. |
| `DOCUMENT_SERVICE_SHUTDOWN_TIMEOUT` | `30s` | Graceful shutdown deadline on SIGINT/SIGTERM. |
| `DOCUMENT_SERVICE_MAX_BODY_BYTES` | `2097152` | Max request body size (bytes). |
| `DOCUMENT_SERVICE_PDF_TIMEOUT` | `30s` | Per-render Chromium timeout. |
| `DOCUMENT_SERVICE_MAX_CONCURRENT_RENDERS` | `2` | Maximum concurrent PDF jobs (protects memory/CPU). |
| `DOCUMENT_SERVICE_API_KEYS` | *(empty)* | Comma-separated API keys. If set, `POST /html-to-pdf` and `POST /xml-to-pdf` require `X-API-Key` or `Authorization: Bearer <key>`. `/healthz` and `/metrics` stay open. |
| `DOCUMENT_SERVICE_RATE_LIMIT_RPS` | `0` (off) | Per–client-IP rate limit (requests/sec). `X-Forwarded-For` first hop is trusted when present. |
| `DOCUMENT_SERVICE_RATE_LIMIT_BURST` | `20` | Token bucket burst for rate limiting. |
| `DOCUMENT_SERVICE_LOG_JSON` | `false` | If `true`, logs are JSON (`slog`). |

Example (macOS):

```bash
export CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
export DOCUMENT_SERVICE_API_KEYS="dev-key-1,dev-key-2"
```

The Docker image sets `CHROME_PATH=/usr/bin/chromium`.
The process shuts down gracefully: it stops accepting new connections and drains in-flight work up to `DOCUMENT_SERVICE_SHUTDOWN_TIMEOUT`.

## Run locally

Expand Down Expand Up @@ -109,7 +127,7 @@ docker compose up --build

## Kubernetes (local)

Manifests live under `k8s/local` (namespace `document-service-local`, image `document-service:local`).
Manifests live under [`k8s/local`](k8s/local) (namespace `document-service-local`, image `document-service:local`).

**Docker Desktop Kubernetes:** build the image on your machine first, then apply:

Expand All @@ -120,11 +138,21 @@ kubectl -n document-service-local rollout restart deploy/document-service
kubectl -n document-service-local port-forward svc/document-service 8080:8080
```

## Kubernetes (production-oriented)

[`k8s/production`](k8s/production) provides a starting point: namespace `document-service`, **2** replicas, **GHCR** image `ghcr.io/oljasshaiken/document-service:latest`, non-root `securityContext`, PodDisruptionBudget, and higher resource requests/limits.

```bash
kubectl apply -k k8s/production
```

Set `DOCUMENT_SERVICE_API_KEYS` (and other env) via `Secret`/`ConfigMap` patches before relying on this in a real cluster. Use an **Ingress** with TLS termination in front of the Service.

## CI/CD

GitHub Actions workflow: [`.github/workflows/ci-cd.yml`](.github/workflows/ci-cd.yml)

- On pull requests to `main`: format check, tests, Go build
- On pull requests to `main`: format check, tests, Go build, **Trivy filesystem scan** (report-only)
- On push to `main` and version tags `v*`: build and push image to `ghcr.io/<owner>/<repo>`

## Project layout
Expand All @@ -133,6 +161,7 @@ GitHub Actions workflow: [`.github/workflows/ci-cd.yml`](.github/workflows/ci-cd
.
├── src/cmd/document-service/ # application entrypoint and tests
├── k8s/local/ # local Kubernetes manifests (Kustomize)
├── k8s/production/ # example production-oriented manifests
├── Dockerfile
├── docker-compose.yaml
├── go.mod
Expand Down
11 changes: 10 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,23 @@ go 1.26.1
require (
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.1
github.com/prometheus/client_golang v1.20.5
golang.org/x/time v0.9.0
)

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/jung-kurt/gofpdf v1.16.2 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
36 changes: 24 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
Expand All @@ -14,20 +16,30 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/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/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/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
54 changes: 54 additions & 0 deletions k8s/production/deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: document-service
namespace: document-service
labels:
app: document-service
spec:
replicas: 2
selector:
matchLabels:
app: document-service
template:
metadata:
labels:
app: document-service
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532
containers:
- name: document-service
image: ghcr.io/oljasshaiken/document-service:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
name: http
env:
- name: CHROME_PATH
value: /usr/bin/chromium
- name: DOCUMENT_SERVICE_MAX_CONCURRENT_RENDERS
value: "2"
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 3
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
8 changes: 8 additions & 0 deletions k8s/production/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: document-service
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
- pdb.yaml
4 changes: 4 additions & 0 deletions k8s/production/namespace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: document-service
10 changes: 10 additions & 0 deletions k8s/production/pdb.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: document-service
namespace: document-service
spec:
minAvailable: 1
selector:
matchLabels:
app: document-service
15 changes: 15 additions & 0 deletions k8s/production/service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: document-service
namespace: document-service
labels:
app: document-service
spec:
type: ClusterIP
selector:
app: document-service
ports:
- name: http
port: 8080
targetPort: http
Loading
Loading