From db81e8d154078994a70b8a98d781d6f5588e05fb Mon Sep 17 00:00:00 2001 From: Mario Meyer Date: Wed, 17 Jun 2026 11:45:33 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(gotenberg):=20add=20Gotenberg=20HTML?= =?UTF-8?q?=E2=86=92PDF=20sidecar=20across=20compose=20and=20helm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes Gotenberg a first-class, optional service in both the local docker-compose stack and the production Helm chart, so consuming apps can render HTML→PDF without baking headless Chromium into the app image. - compose: add gotenberg.stub (gotenberg/gotenberg:8, sail network, /health check, stateless) and register `gotenberg` as an opt-in service; depends_on is wired by the existing generic mechanism. replaceEnvVariables appends GOTENBERG_URL=http://gotenberg:3000 for local dev. - helm: add gotenberg.stub Deployment+Service gated on .Values.gotenberg.enabled; wire GOTENBERG_URL=http://{name}-gotenberg:3000 into web/worker/scheduler via the shared sail.laravelEnv helper; add the gotenberg block to values.stub. - tests: helm render coverage (enabled/disabled + GOTENBERG_URL wiring) and a compose-stub validity check. The runtime image (runtimes/8.x) is intentionally untouched — no Chromium. --- .../InteractsWithDockerComposeServices.php | 5 ++ stubs/gotenberg.stub | 11 ++++ stubs/helm/templates/_helpers.tpl | 7 +++ stubs/helm/templates/deployment-web.stub | 1 + stubs/helm/templates/deployment-worker.stub | 1 + stubs/helm/templates/gotenberg.stub | 59 ++++++++++++++++++ stubs/helm/values.stub | 9 +++ tests/Feature/BuildCommandTest.php | 21 +++++++ tests/Feature/HelmTemplateTest.php | 62 +++++++++++++++++++ 9 files changed, 176 insertions(+) create mode 100644 stubs/gotenberg.stub create mode 100644 stubs/helm/templates/gotenberg.stub diff --git a/src/Console/Concerns/InteractsWithDockerComposeServices.php b/src/Console/Concerns/InteractsWithDockerComposeServices.php index 3464e292..8c65bc59 100644 --- a/src/Console/Concerns/InteractsWithDockerComposeServices.php +++ b/src/Console/Concerns/InteractsWithDockerComposeServices.php @@ -41,6 +41,7 @@ trait InteractsWithDockerComposeServices 'rabbitmq', 'selenium', 'soketi', + 'gotenberg', ]; /** @@ -329,6 +330,10 @@ protected function replaceEnvVariables(string $project, array $services) $environment = str_replace('RABBITMQ_HOST=127.0.0.1', 'RABBITMQ_HOST=rabbitmq', $environment); } + if (in_array('gotenberg', $services)) { + $environment .= "\nGOTENBERG_URL=http://gotenberg:3000\n"; + } + $environment = str_replace('# PHP_CLI_SERVER_WORKERS=4', 'PHP_CLI_SERVER_WORKERS=4', $environment); file_put_contents($this->laravel->basePath('.env'), $environment); diff --git a/stubs/gotenberg.stub b/stubs/gotenberg.stub new file mode 100644 index 00000000..d8e18121 --- /dev/null +++ b/stubs/gotenberg.stub @@ -0,0 +1,11 @@ +gotenberg: + image: 'gotenberg/gotenberg:8' + restart: unless-stopped + ports: + - '${SAIL_IP:-172.20.0.10}:${FORWARD_GOTENBERG_PORT:-3000}:3000' + networks: + - sail + healthcheck: + test: ['CMD', 'curl', '--fail', 'http://localhost:3000/health'] + retries: 3 + timeout: 5s diff --git a/stubs/helm/templates/_helpers.tpl b/stubs/helm/templates/_helpers.tpl index 41b2ecf4..63d1521b 100644 --- a/stubs/helm/templates/_helpers.tpl +++ b/stubs/helm/templates/_helpers.tpl @@ -157,16 +157,19 @@ opt in via `redis.useSentinel: true` in values.yaml. {{- $redis := dict -}} {{- $s3 := dict -}} {{- $logging := dict -}} +{{- $gotenberg := dict -}} {{- if .main -}} {{- $database = .main.database | default dict -}} {{- $redis = .main.redis | default dict -}} {{- $s3 = .main.s3 | default dict -}} {{- $logging = .main.logging | default dict -}} +{{- $gotenberg = .main.gotenberg | default dict -}} {{- else -}} {{- $database = .Values.database | default dict -}} {{- $redis = .Values.redis | default dict -}} {{- $s3 = .Values.s3 | default dict -}} {{- $logging = .Values.logging | default dict -}} +{{- $gotenberg = .Values.gotenberg | default dict -}} {{- end }} - name: SAIL_LOG_MODE value: {{ $logging.mode | default "both" | quote }} @@ -271,6 +274,10 @@ opt in via `redis.useSentinel: true` in values.yaml. value: {{ $s3.url }} {{- end }} {{- end }} +{{- if $gotenberg.enabled }} +- name: GOTENBERG_URL + value: {{ printf "http://%s-gotenberg:3000" (include "sail.name" .) | quote }} +{{- end }} {{- end -}} {{/* diff --git a/stubs/helm/templates/deployment-web.stub b/stubs/helm/templates/deployment-web.stub index 544f10ce..92cb9f87 100644 --- a/stubs/helm/templates/deployment-web.stub +++ b/stubs/helm/templates/deployment-web.stub @@ -5,6 +5,7 @@ "secret" (.Values.secret | default dict) "name" (.Values.name | default "website") "typesense" (.Values.typesense | default dict) + "gotenberg" (.Values.gotenberg | default dict) "database" (.Values.database | default dict) "redis" (.Values.redis | default dict) "s3" (.Values.s3 | default dict) diff --git a/stubs/helm/templates/deployment-worker.stub b/stubs/helm/templates/deployment-worker.stub index 016bce3d..b47055e0 100644 --- a/stubs/helm/templates/deployment-worker.stub +++ b/stubs/helm/templates/deployment-worker.stub @@ -5,6 +5,7 @@ "secret" (.Values.secret | default dict) "name" (.Values.name | default "website") "typesense" (.Values.typesense | default dict) + "gotenberg" (.Values.gotenberg | default dict) "database" (.Values.database | default dict) "redis" (.Values.redis | default dict) "s3" (.Values.s3 | default dict) diff --git a/stubs/helm/templates/gotenberg.stub b/stubs/helm/templates/gotenberg.stub new file mode 100644 index 00000000..bcdebc4f --- /dev/null +++ b/stubs/helm/templates/gotenberg.stub @@ -0,0 +1,59 @@ +{{- if .Values.gotenberg.enabled }} +{{- $appName := include "sail.name" . }} +{{- $gotenbergName := printf "%s-gotenberg" $appName }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $gotenbergName }} + labels: + {{- include "sail.labels" . | nindent 4 }} + app.kubernetes.io/component: gotenberg +spec: + replicas: {{ .Values.gotenberg.replicas | default 1 }} + selector: + matchLabels: + app.kubernetes.io/name: {{ $gotenbergName }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ $gotenbergName }} + app.kubernetes.io/component: gotenberg + spec: + containers: + - name: gotenberg + image: {{ .Values.gotenberg.image | default "gotenberg/gotenberg:8" }} + args: ["gotenberg", "--api-timeout={{ .Values.gotenberg.apiTimeout | default "60s" }}"] + ports: + - containerPort: 3000 + name: http + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 30 + resources: + {{- toYaml (.Values.gotenberg.resources | default (dict "requests" (dict "cpu" "100m" "memory" "256Mi") "limits" (dict "memory" "1Gi"))) | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $gotenbergName }} + labels: + {{- include "sail.labels" . | nindent 4 }} + app.kubernetes.io/component: gotenberg +spec: + selector: + app.kubernetes.io/name: {{ $gotenbergName }} + ports: + - port: 3000 + targetPort: 3000 + name: http +{{- end }} diff --git a/stubs/helm/values.stub b/stubs/helm/values.stub index b516982f..025fd43c 100644 --- a/stubs/helm/values.stub +++ b/stubs/helm/values.stub @@ -334,6 +334,15 @@ typesense: storageSize: 2Gi storageClassName: "" +# Gotenberg HTML→PDF rendering sidecar (deploys Gotenberg when enabled). +# Stateless and internal-only — no PVC, no ingress. When enabled, the web, +# worker, and scheduler pods receive GOTENBERG_URL=http://{appname}-gotenberg:3000. +gotenberg: + enabled: true + image: gotenberg/gotenberg:8 + replicas: 1 + apiTimeout: 60s + # Ingress settings (e.g., for Traefik or NGINX) ingress: enabled: true diff --git a/tests/Feature/BuildCommandTest.php b/tests/Feature/BuildCommandTest.php index bbb724f5..1c4d922a 100644 --- a/tests/Feature/BuildCommandTest.php +++ b/tests/Feature/BuildCommandTest.php @@ -4,9 +4,30 @@ use Illuminate\Support\Facades\File; use Laravel\Sail\Tests\TestCase; +use Symfony\Component\Yaml\Yaml; class BuildCommandTest extends TestCase { + public function test_gotenberg_compose_stub_is_valid_and_internal_only(): void + { + $stub = realpath(__DIR__.'/../../stubs/gotenberg.stub'); + $this->assertNotFalse($stub, 'stubs/gotenberg.stub must exist'); + + $parsed = Yaml::parseFile($stub); + $this->assertArrayHasKey('gotenberg', $parsed, 'stub must define a top-level "gotenberg" service'); + + $service = $parsed['gotenberg']; + $this->assertSame('gotenberg/gotenberg:8', $service['image']); + $this->assertContains('sail', $service['networks'], 'gotenberg must join the sail network'); + $this->assertArrayHasKey('healthcheck', $service, 'gotenberg must define a /health healthcheck'); + $this->assertStringContainsString('/health', implode(' ', $service['healthcheck']['test'])); + + // Stateless sidecar: no volumes (it must not appear in the volume-creation + // allowlist in InteractsWithDockerComposeServices either). + $this->assertArrayNotHasKey('volumes', $service, 'gotenberg is stateless and must not declare volumes'); + } + + protected string $testBasePath; protected function setUp(): void diff --git a/tests/Feature/HelmTemplateTest.php b/tests/Feature/HelmTemplateTest.php index 521bf17b..7204a0cf 100644 --- a/tests/Feature/HelmTemplateTest.php +++ b/tests/Feature/HelmTemplateTest.php @@ -262,6 +262,68 @@ public function test_top_level_resources_used_when_tier_unset(): void ); } + public function test_gotenberg_deployment_and_service_render_when_enabled(): void + { + // values.stub ships gotenberg.enabled: true, so the baseline render + // already includes it; assert explicitly anyway for clarity. + $out = $this->renderChart(['gotenberg' => ['enabled' => true]]); + + $this->assertMatchesRegularExpression( + '/kind:\s*Deployment\n[^-]*?name:\s*testapp-gotenberg/s', + $out, + 'Expected a Deployment named testapp-gotenberg' + ); + $this->assertMatchesRegularExpression( + '/kind:\s*Service\n[^-]*?name:\s*testapp-gotenberg/s', + $out, + 'Expected a Service named testapp-gotenberg' + ); + + // Grab the gotenberg Deployment specifically. Matching on a bare substring + // would wrongly hit the web Deployment, whose env block contains the string + // "testapp-gotenberg" inside the GOTENBERG_URL value — so match the + // metadata name on its own line instead. + $gotenberg = ''; + foreach (preg_split("/^---\s*\n/m", $out) as $doc) { + if (preg_match('/kind:\s*Deployment/', $doc) && preg_match('/^\s*name:\s*testapp-gotenberg\s*$/m', $doc)) { + $gotenberg = $doc; + break; + } + } + $this->assertStringContainsString('image: gotenberg/gotenberg:8', $gotenberg); + $this->assertStringContainsString('path: /health', $gotenberg); + $this->assertStringContainsString('containerPort: 3000', $gotenberg); + } + + public function test_gotenberg_omitted_when_disabled(): void + { + $out = $this->renderChart(['gotenberg' => ['enabled' => false]]); + + $this->assertStringNotContainsString('testapp-gotenberg', $out, + 'Gotenberg Deployment/Service must not render when gotenberg.enabled is false'); + } + + public function test_gotenberg_url_wired_into_all_laravel_pods(): void + { + $out = $this->renderChart(['gotenberg' => ['enabled' => true]]); + + // GOTENBERG_URL flows through the shared sail.laravelEnv helper, so it + // appears on web, worker, and scheduler — the same three tiers as SAIL_LOG_*. + $this->assertSame( + 3, + preg_match_all('#name:\s*GOTENBERG_URL\s*\n\s*value:\s*"http://testapp-gotenberg:3000"#', $out), + 'GOTENBERG_URL=http://testapp-gotenberg:3000 must appear on web, worker, and scheduler' + ); + } + + public function test_gotenberg_url_absent_when_disabled(): void + { + $out = $this->renderChart(['gotenberg' => ['enabled' => false]]); + + $this->assertSame(0, preg_match_all('/name:\s*GOTENBERG_URL/', $out), + 'GOTENBERG_URL must not be wired into any pod when gotenberg is disabled'); + } + /** * Returns the YAML document with the matching `kind:` from a multi-doc helm * template render. Documents are separated by `^---`. From 5713e6174966b402ed3896981914db415677effc Mon Sep 17 00:00:00 2001 From: Mario Meyer Date: Wed, 17 Jun 2026 11:49:11 -0400 Subject: [PATCH 2/3] ci(auto-assign): stop running on pull_request events pozil/auto-assign-issue@v1 only understands issue context, so the pull_request: opened trigger crashed every PR with "Couldn't find issue info in current context". Drop the PR trigger (and its pull-requests: write permission) so the workflow only assigns new issues, and remove the invalid numOfAssignee input that emitted an "unexpected input" warning. --- .github/workflows/auto-assign.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index 330a00f1..8e931c1c 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -2,18 +2,14 @@ name: Auto Assign on: issues: types: [opened] - pull_request: - types: [opened] jobs: run: runs-on: ubuntu-latest permissions: issues: write - pull-requests: write steps: - name: 'Auto-assign issue' uses: pozil/auto-assign-issue@v1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} assignees: mariomeyer - numOfAssignee: 1 From 8cd0351f25528a07b9d2def52088554f9e0fdc92 Mon Sep 17 00:00:00 2001 From: Mario Meyer Date: Wed, 17 Jun 2026 11:53:59 -0400 Subject: [PATCH 3/3] fix(helm): guard nullable gotenberg values before dereferencing A consumer override setting `gotenberg: null` (the same nullable-map pattern the chart already guards for `app`) caused helm template to fail with "nil pointer evaluating interface {}.enabled", breaking the entire chart render. Bind `.Values.gotenberg | default dict` once and test that map's flags, matching the existing app/typesense guarding. Add a regression test mirroring test_defaults_secret_renders_when_app_values_is_null. --- stubs/helm/templates/gotenberg.stub | 11 ++++++----- tests/Feature/HelmTemplateTest.php | 13 +++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/stubs/helm/templates/gotenberg.stub b/stubs/helm/templates/gotenberg.stub index bcdebc4f..f4374ebe 100644 --- a/stubs/helm/templates/gotenberg.stub +++ b/stubs/helm/templates/gotenberg.stub @@ -1,4 +1,5 @@ -{{- if .Values.gotenberg.enabled }} +{{- $gotenberg := .Values.gotenberg | default dict }} +{{- if $gotenberg.enabled }} {{- $appName := include "sail.name" . }} {{- $gotenbergName := printf "%s-gotenberg" $appName }} --- @@ -10,7 +11,7 @@ metadata: {{- include "sail.labels" . | nindent 4 }} app.kubernetes.io/component: gotenberg spec: - replicas: {{ .Values.gotenberg.replicas | default 1 }} + replicas: {{ $gotenberg.replicas | default 1 }} selector: matchLabels: app.kubernetes.io/name: {{ $gotenbergName }} @@ -22,8 +23,8 @@ spec: spec: containers: - name: gotenberg - image: {{ .Values.gotenberg.image | default "gotenberg/gotenberg:8" }} - args: ["gotenberg", "--api-timeout={{ .Values.gotenberg.apiTimeout | default "60s" }}"] + image: {{ $gotenberg.image | default "gotenberg/gotenberg:8" }} + args: ["gotenberg", "--api-timeout={{ $gotenberg.apiTimeout | default "60s" }}"] ports: - containerPort: 3000 name: http @@ -40,7 +41,7 @@ spec: initialDelaySeconds: 15 periodSeconds: 30 resources: - {{- toYaml (.Values.gotenberg.resources | default (dict "requests" (dict "cpu" "100m" "memory" "256Mi") "limits" (dict "memory" "1Gi"))) | nindent 12 }} + {{- toYaml ($gotenberg.resources | default (dict "requests" (dict "cpu" "100m" "memory" "256Mi") "limits" (dict "memory" "1Gi"))) | nindent 12 }} --- apiVersion: v1 kind: Service diff --git a/tests/Feature/HelmTemplateTest.php b/tests/Feature/HelmTemplateTest.php index 7204a0cf..9e4cfee5 100644 --- a/tests/Feature/HelmTemplateTest.php +++ b/tests/Feature/HelmTemplateTest.php @@ -303,6 +303,19 @@ public function test_gotenberg_omitted_when_disabled(): void 'Gotenberg Deployment/Service must not render when gotenberg.enabled is false'); } + public function test_gotenberg_renders_when_values_is_null(): void + { + // Regression: `gotenberg: null` in consumer values would panic helm template + // (nil pointer evaluating .Values.gotenberg.enabled) if the template didn't + // bind `.Values.gotenberg | default dict` before dereferencing. Mirrors the + // existing app:null guard. The chart must still render and simply omit gotenberg. + $out = $this->renderChart(['gotenberg' => null]); + + $this->assertStringContainsString('kind: Deployment', $out, 'chart must still render with gotenberg: null'); + $this->assertStringNotContainsString('testapp-gotenberg', $out, + 'gotenberg must be omitted (not error) when its values block is null'); + } + public function test_gotenberg_url_wired_into_all_laravel_pods(): void { $out = $this->renderChart(['gotenberg' => ['enabled' => true]]);