From d62f2ad3ed5d84c2a414b00b727b5efa32d46e54 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 12 May 2026 13:22:15 +0100 Subject: [PATCH] unoserver docker (#6328) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/workflows/push-docker.yml | 170 +++++++++++++++++- .github/workflows/test-build-docker.yml | 55 ++++++ .../software/common/util/ProcessExecutor.java | 11 +- .../software/common/util/UnoServerPool.java | 24 +++ .../common/util/UnoServerPoolTest.java | 65 +++++++ docker/embedded/compose/README-remote-uno.md | 2 +- ...ker-compose-latest-security-remote-uno.yml | 32 ++-- docker/unoserver/Dockerfile | 106 +++++++++++ docker/unoserver/VERSION | 1 + docker/unoserver/entrypoint.sh | 104 +++++++++++ docker/unoserver/fonts.conf | 30 ++++ docker/unoserver/healthcheck.sh | 12 ++ scripts/init-without-ocr.sh | 32 +++- 13 files changed, 614 insertions(+), 30 deletions(-) create mode 100644 docker/unoserver/Dockerfile create mode 100644 docker/unoserver/VERSION create mode 100644 docker/unoserver/entrypoint.sh create mode 100644 docker/unoserver/fonts.conf create mode 100644 docker/unoserver/healthcheck.sh diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index bd9969cce..b760000a1 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -2,6 +2,22 @@ name: Push Docker Image with VersionNumber on: workflow_dispatch: + inputs: + build_main_app: + description: "Build & push the main Stirling-PDF image (latest, fat, ultra-lite)." + required: false + type: boolean + default: true + build_unoserver: + description: "Build & push the standalone stirling-unoserver image." + required: false + type: boolean + default: true + force_unoserver_rebuild: + description: "Rebuild stirling-unoserver even if its source hash is unchanged." + required: false + type: boolean + default: false push: branches: - master @@ -31,6 +47,10 @@ jobs: permissions: packages: write id-token: write + # On push events these stay 'true'; on workflow_dispatch they follow the inputs. + env: + RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }} + RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }} steps: - name: Harden Runner uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 @@ -108,6 +128,7 @@ jobs: - name: Generate tags for latest id: meta + if: env.RUN_MAIN_APP == 'true' uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: | @@ -122,6 +143,8 @@ jobs: - name: Build and push Unified Dockerfile (latest variant) id: build-push-latest + # Empty-tag guard: build-push-action errors when asked to push with no tags. + if: env.RUN_MAIN_APP == 'true' && steps.meta.outputs.tags != '' uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: builder: ${{ steps.buildx.outputs.name }} @@ -140,7 +163,7 @@ jobs: sbom: true - name: Sign regular images - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' + if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != '' env: DIGEST: ${{ steps.build-push-latest.outputs.digest }} TAGS: ${{ steps.meta.outputs.tags }} @@ -156,7 +179,7 @@ jobs: - name: Generate tags for latest-fat id: meta-fat uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 - if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' + if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' with: images: | ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf @@ -170,7 +193,7 @@ jobs: - name: Build and push Unified Dockerfile (fat variant) id: build-push-fat uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 - if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' + if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-fat.outputs.tags != '' with: builder: ${{ steps.buildx.outputs.name }} context: . @@ -186,7 +209,7 @@ jobs: sbom: true - name: Sign fat images - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' + if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != '' env: DIGEST: ${{ steps.build-push-fat.outputs.digest }} TAGS: ${{ steps.meta-fat.outputs.tags }} @@ -200,7 +223,7 @@ jobs: - name: Generate tags for ultra-lite id: meta-lite uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 - if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' + if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' with: images: | ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf @@ -214,7 +237,7 @@ jobs: - name: Build and push Unified Dockerfile (ultra-lite variant) id: build-push-lite uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 - if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' + if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-lite.outputs.tags != '' with: builder: ${{ steps.buildx.outputs.name }} context: . @@ -230,7 +253,7 @@ jobs: sbom: true - name: Sign ultra-lite images - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' + if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != '' env: DIGEST: ${{ steps.build-push-lite.outputs.digest }} TAGS: ${{ steps.meta-lite.outputs.tags }} @@ -240,3 +263,136 @@ jobs: echo "$TAGS" | tr ',' '\n' | while read -r tag; do cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}" done + + # Standalone unoserver image — versioned independently via + # docker/unoserver/VERSION. master/V2-master: publish +latest + # only when the version is new. main/testMain: republish :alpha only + # when the source hash differs from the published image's annotation. + - name: Read unoserver image version + id: unoserverVersion + if: env.RUN_UNOSERVER == 'true' + run: | + version=$(tr -d '[:space:]' < docker/unoserver/VERSION) + if [ -z "$version" ]; then + echo "docker/unoserver/VERSION is empty"; exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "Unoserver image version (from file): ${version}" + + - name: Compute unoserver image source hash + id: unoserverHash + if: env.RUN_UNOSERVER == 'true' + run: | + set -eu + hash=$(cat \ + docker/unoserver/Dockerfile \ + docker/unoserver/entrypoint.sh \ + docker/unoserver/healthcheck.sh \ + docker/unoserver/VERSION \ + | sha256sum | cut -d' ' -f1) + echo "hash=${hash}" >> "$GITHUB_OUTPUT" + echo "Unoserver source hash: ${hash}" + + - name: Decide whether to publish unoserver image + id: unoserverDecision + if: env.RUN_UNOSERVER == 'true' + env: + UNOSERVER_VERSION: ${{ steps.unoserverVersion.outputs.version }} + UNOSERVER_HASH: ${{ steps.unoserverHash.outputs.hash }} + UNOSERVER_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-unoserver + UNOSERVER_HASH_ANNOTATION: org.stirlingpdf.unoserver-source-hash + FORCE_REBUILD: ${{ inputs.force_unoserver_rebuild }} + GH_REF: ${{ github.ref }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -eu + mode="skip" + tags="" + + read_published_hash() { + local ref="$1" + docker buildx imagetools inspect "$ref" --raw 2>/dev/null \ + | jq -r --arg key "$UNOSERVER_HASH_ANNOTATION" \ + '.annotations[$key] // empty' \ + 2>/dev/null || true + } + + # Manual dispatch from any branch routes to the :alpha publish path. + EFFECTIVE_REF="$GH_REF" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + EFFECTIVE_REF="refs/heads/testMain" + fi + + case "$EFFECTIVE_REF" in + refs/heads/master|refs/heads/V2-master) + if [ "${FORCE_REBUILD}" = "true" ]; then + echo "force_unoserver_rebuild=true — building stable regardless" + mode="stable" + tags="${UNOSERVER_IMAGE}:${UNOSERVER_VERSION},${UNOSERVER_IMAGE}:latest" + elif docker manifest inspect "${UNOSERVER_IMAGE}:${UNOSERVER_VERSION}" >/dev/null 2>&1; then + echo "stirling-unoserver:${UNOSERVER_VERSION} already on GHCR — skipping" + else + echo "stirling-unoserver:${UNOSERVER_VERSION} is new — will publish" + mode="stable" + tags="${UNOSERVER_IMAGE}:${UNOSERVER_VERSION},${UNOSERVER_IMAGE}:latest" + fi + ;; + refs/heads/main|refs/heads/testMain) + published_hash=$(read_published_hash "${UNOSERVER_IMAGE}:alpha") + if [ "${FORCE_REBUILD}" = "true" ]; then + echo "force_unoserver_rebuild=true — rebuilding :alpha regardless" + mode="alpha" + tags="${UNOSERVER_IMAGE}:alpha" + elif [ -n "$published_hash" ] && [ "$published_hash" = "$UNOSERVER_HASH" ]; then + echo "Published :alpha source hash matches (${published_hash}) — skipping" + else + if [ -z "$published_hash" ]; then + echo ":alpha has no source-hash annotation (first publish or pre-tracking image) — will publish" + else + echo "Source hash changed (was ${published_hash}, now ${UNOSERVER_HASH}) — will publish" + fi + mode="alpha" + tags="${UNOSERVER_IMAGE}:alpha" + fi + ;; + *) + echo "Branch ${GH_REF} does not publish unoserver image" + ;; + esac + echo "mode=${mode}" >> "$GITHUB_OUTPUT" + echo "tags=${tags}" >> "$GITHUB_OUTPUT" + + - name: Build and push unoserver image + id: build-push-unoserver + if: env.RUN_UNOSERVER == 'true' && steps.unoserverDecision.outputs.mode != 'skip' + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + file: ./docker/unoserver/Dockerfile + push: true + cache-from: type=gha,scope=stirling-unoserver + cache-to: type=gha,mode=max,scope=stirling-unoserver + tags: ${{ steps.unoserverDecision.outputs.tags }} + # Manifest annotation read by the decision step above to detect drift. + annotations: | + index:org.stirlingpdf.unoserver-source-hash=${{ steps.unoserverHash.outputs.hash }} + platforms: linux/amd64,linux/arm64/v8 + provenance: true + sbom: true + + - name: Sign unoserver image + if: env.RUN_UNOSERVER == 'true' && steps.unoserverDecision.outputs.mode == 'stable' + env: + DIGEST: ${{ steps.build-push-unoserver.outputs.digest }} + TAGS: ${{ steps.unoserverDecision.outputs.tags }} + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + if [ -n "$COSIGN_PRIVATE_KEY" ]; then + echo "$TAGS" | tr ',' '\n' | while read -r tag; do + cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}" + done + else + echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing" + fi diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 68978a4d9..10d35c440 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -160,3 +160,58 @@ jobs: build/reports/problems/ retention-days: 3 if-no-files-found: warn + + test-build-unoserver-image: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build docker/unoserver/Dockerfile + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + file: ./docker/unoserver/Dockerfile + push: false + load: true + cache-from: type=gha,scope=stirling-unoserver + cache-to: type=gha,mode=max,scope=stirling-unoserver + platforms: linux/amd64 + tags: stirling-unoserver:pr-test + provenance: false + sbom: false + + - name: Smoke test the built image + run: | + set -eu + docker run -d --name unoserver-smoke \ + -e UNOSERVER_RECYCLE_INTERVAL_SECONDS=0 \ + stirling-unoserver:pr-test + deadline=$((SECONDS + 60)) + while [ $SECONDS -lt $deadline ]; do + status=$(docker inspect -f '{{.State.Health.Status}}' unoserver-smoke 2>/dev/null || echo "starting") + if [ "$status" = "healthy" ]; then + echo "unoserver became healthy" + docker logs unoserver-smoke | tail -30 + docker rm -f unoserver-smoke + exit 0 + fi + sleep 3 + done + echo "unoserver did not become healthy in time" + docker logs unoserver-smoke || true + docker rm -f unoserver-smoke || true + exit 1 diff --git a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java index 99d25838b..eb36711c0 100644 --- a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java +++ b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java @@ -15,6 +15,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import io.github.pixee.security.BoundedLineReader; @@ -202,7 +203,15 @@ public class ProcessExecutor { boolean useSemaphore = true; List commandToRun = command; if (shouldUseUnoServerPool(command)) { - unoLease = unoServerPool.acquireEndpoint(); + try { + unoLease = unoServerPool.acquireEndpoint(timeoutDuration, TimeUnit.MINUTES); + } catch (TimeoutException e) { + throw new IOException( + "All unoserver endpoints busy; request timed out after " + + timeoutDuration + + " minutes", + e); + } commandToRun = applyUnoServerEndpoint(command, unoLease.getEndpoint()); useSemaphore = false; } diff --git a/app/common/src/main/java/stirling/software/common/util/UnoServerPool.java b/app/common/src/main/java/stirling/software/common/util/UnoServerPool.java index 1ae85c019..3863bd531 100644 --- a/app/common/src/main/java/stirling/software/common/util/UnoServerPool.java +++ b/app/common/src/main/java/stirling/software/common/util/UnoServerPool.java @@ -3,8 +3,11 @@ package stirling.software.common.util; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import stirling.software.common.model.ApplicationProperties; @@ -42,6 +45,27 @@ public class UnoServerPool { return new UnoServerLease(endpoints.get(index), index, this); } + /** Fail-fast variant; non-positive timeout falls back to unbounded acquire. */ + public UnoServerLease acquireEndpoint(long timeout, TimeUnit unit) + throws InterruptedException, TimeoutException { + if (endpoints.isEmpty()) { + return new UnoServerLease(defaultEndpoint(), null, this); + } + if (timeout <= 0) { + return acquireEndpoint(); + } + + Integer index = availableIndices.poll(timeout, unit); + if (index == null) { + throw new TimeoutException( + "Timed out waiting for a free unoserver endpoint after " + + timeout + + " " + + unit.name().toLowerCase(Locale.ROOT)); + } + return new UnoServerLease(endpoints.get(index), index, this); + } + private void releaseEndpoint(Integer index) { if (index != null) { availableIndices.offer(index); diff --git a/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java b/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java index 424e66a80..06d1575ef 100644 --- a/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java +++ b/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java @@ -9,6 +9,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; @@ -173,6 +174,70 @@ public class UnoServerPoolTest { assertEquals(port1, port2, "Should reuse the same endpoint after release"); } + @Test + void testAcquireWithTimeoutFailsFast() throws InterruptedException { + List endpoints = + createEndpoints(1); + UnoServerPool pool = new UnoServerPool(endpoints); + + UnoServerPool.UnoServerLease held = pool.acquireEndpoint(); + + long start = System.nanoTime(); + assertThrows( + TimeoutException.class, + () -> pool.acquireEndpoint(100, TimeUnit.MILLISECONDS), + "Should time out when no endpoint available"); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + assertTrue( + elapsedMs >= 100 && elapsedMs < 1000, + "Should fail-fast after roughly the timeout, got " + elapsedMs + "ms"); + + held.close(); + } + + @Test + void testAcquireWithTimeoutSucceedsWhenAvailable() + throws InterruptedException, TimeoutException { + List endpoints = + createEndpoints(1); + UnoServerPool pool = new UnoServerPool(endpoints); + + try (UnoServerPool.UnoServerLease lease = pool.acquireEndpoint(1, TimeUnit.SECONDS)) { + assertNotNull(lease); + assertEquals(2003, lease.getEndpoint().getPort()); + } + } + + @Test + void testAcquireWithZeroTimeoutBlocksUnbounded() throws InterruptedException { + List endpoints = + createEndpoints(1); + UnoServerPool pool = new UnoServerPool(endpoints); + UnoServerPool.UnoServerLease held = pool.acquireEndpoint(); + + AtomicInteger acquired = new AtomicInteger(0); + Thread t = + Thread.ofVirtual() + .start( + () -> { + try { + UnoServerPool.UnoServerLease lease = + pool.acquireEndpoint(0, TimeUnit.MILLISECONDS); + acquired.incrementAndGet(); + lease.close(); + } catch (Exception e) { + fail("unexpected: " + e); + } + }); + + Thread.sleep(150); + assertEquals(0, acquired.get(), "Should still be blocked"); + held.close(); + t.join(1000); + assertEquals(1, acquired.get(), "Should acquire after release"); + } + @Test void testHostLocationAndProtocol() throws InterruptedException { List endpoints = new ArrayList<>(); diff --git a/docker/embedded/compose/README-remote-uno.md b/docker/embedded/compose/README-remote-uno.md index b7d21e0d4..45a75b526 100644 --- a/docker/embedded/compose/README-remote-uno.md +++ b/docker/embedded/compose/README-remote-uno.md @@ -97,7 +97,7 @@ To add a 3rd UNO server: ```yaml unoserver3: container_name: UNO-Server-3 - image: ghcr.io/unoconv/unoserver-docker:0.4.4 + image: ghcr.io/stirling-tools/stirling-unoserver:latest # ... same config as unoserver1/2 ``` diff --git a/docker/embedded/compose/docker-compose-latest-security-remote-uno.yml b/docker/embedded/compose/docker-compose-latest-security-remote-uno.yml index a80a04e13..4f57adfa4 100644 --- a/docker/embedded/compose/docker-compose-latest-security-remote-uno.yml +++ b/docker/embedded/compose/docker-compose-latest-security-remote-uno.yml @@ -57,38 +57,34 @@ services: unoserver1: container_name: UNO-Server-1 - # NOTE: This image needs to be updated to unoserver 3.6 to match Stirling-PDF's client version - # Current :latest uses 3.4 which causes API mismatch errors - image: ghcr.io/unoconv/unoserver-docker:latest + # See docker/unoserver/VERSION for available tags. + image: ghcr.io/stirling-tools/stirling-unoserver:latest + environment: + UNOSERVER_PORT: "2003" + UNOSERVER_UNO_PORT: "2002" + UNOSERVER_CONVERSION_TIMEOUT: "1800" + # Recycle hourly to bound LibreOffice memory; 0 disables, floor 60s. + UNOSERVER_RECYCLE_INTERVAL_SECONDS: "3600" volumes: - stirling-tmp:/tmp/stirling-pdf:rw expose: - "2003" - healthcheck: - test: ["CMD-SHELL", "timeout 2 bash -c 'cat < /dev/null > /dev/tcp/localhost/2003' || exit 1"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s networks: - stirling-network restart: on-failure:5 unoserver2: container_name: UNO-Server-2 - # NOTE: This image needs to be updated to unoserver 3.6 to match Stirling-PDF's client version - # Current :latest uses 3.4 which causes API mismatch errors - image: ghcr.io/unoconv/unoserver-docker:latest + image: ghcr.io/stirling-tools/stirling-unoserver:latest + environment: + UNOSERVER_PORT: "2003" + UNOSERVER_UNO_PORT: "2002" + UNOSERVER_CONVERSION_TIMEOUT: "1800" + UNOSERVER_RECYCLE_INTERVAL_SECONDS: "3600" volumes: - stirling-tmp:/tmp/stirling-pdf:rw expose: - "2003" - healthcheck: - test: ["CMD-SHELL", "timeout 2 bash -c 'cat < /dev/null > /dev/tcp/localhost/2003' || exit 1"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s networks: - stirling-network restart: on-failure:5 diff --git a/docker/unoserver/Dockerfile b/docker/unoserver/Dockerfile new file mode 100644 index 000000000..8adf0ab98 --- /dev/null +++ b/docker/unoserver/Dockerfile @@ -0,0 +1,106 @@ +# Standalone unoserver image for Stirling-PDF remote UNO mode. +# Pinned to unoserver 3.6 to match Stirling-PDF's client (avoids 3.4/3.6 wire mismatch). + +FROM ubuntu:noble@sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b + +ARG UNOSERVER_VERSION=3.6 +# ~120 MB of CJK fonts — opt-in. +ARG INSTALL_CJK_FONTS=false + +ENV DEBIAN_FRONTEND=noninteractive \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + UNOSERVER_PORT=2003 \ + UNOSERVER_UNO_PORT=2002 \ + UNOSERVER_INTERFACE=0.0.0.0 \ + UNOSERVER_CONVERSION_TIMEOUT=1800 \ + UNOSERVER_RECYCLE_INTERVAL_SECONDS=0 \ + UNOSERVER_PROFILE_DIR=/var/lib/unoserver/profile + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + software-properties-common ca-certificates curl gnupg tini; \ + add-apt-repository -y ppa:libreoffice/ppa; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + python3 python3-venv python3-uno python3-packaging \ + libreoffice-writer-nogui libreoffice-calc-nogui \ + libreoffice-impress-nogui libreoffice-draw-nogui \ + libreoffice-base-nogui libreoffice-java-common \ + xvfb x11-utils coreutils \ + fonts-dejavu fonts-liberation fonts-liberation2 \ + fonts-crosextra-caladea fonts-crosextra-carlito \ + fonts-noto-core fonts-noto-mono fonts-noto-color-emoji \ + hyphen-en-gb hyphen-en-us hyphen-fr hyphen-de hyphen-es hyphen-it \ + hyphen-pt-pt hyphen-pt-br hyphen-nl hyphen-pl hyphen-ru \ + fontconfig \ + libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 \ + libfreetype6 libfontconfig1 libx11-6 libxt6 libxext6 libxrender1 \ + libxtst6 libxi6 libxinerama1 libxkbcommon0 libsm6 libice6 \ + libglib2.0-0; \ + if [ "${INSTALL_CJK_FONTS}" = "true" ]; then \ + apt-get install -y --no-install-recommends fonts-noto-cjk; \ + fi; \ + apt-get remove --purge -y software-properties-common; \ + apt-get autoremove --purge -y; \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + +# Noble's hyphen-* packages skip LibreOffice registration; wire it up by hand +# or the dicts never load. +RUN set -eu; \ + mkdir -p /usr/share/myspell/dicts /usr/share/myspell/infos/ooo; \ + for f in /usr/share/hyphen/hyph_*_*.dic; do \ + base=$(basename "$f" .dic); \ + locale=${base#hyph_}; \ + lang=${locale%_*}; \ + country=${locale#*_}; \ + ln -sfn "../../hyphen/${base}.dic" "/usr/share/myspell/dicts/${base}.dic"; \ + printf "HYPH %s %s %s\n" "$lang" "$country" "$base" \ + > "/usr/share/myspell/infos/ooo/hyphen-${lang}-${country}"; \ + done + +COPY --chmod=0644 docker/unoserver/fonts.conf /etc/fonts/conf.d/100-stirling.conf +RUN fc-cache -f + +# venv with --system-site-packages so it picks up distro python3-uno. +RUN python3 -m venv /opt/unoserver-venv --system-site-packages && \ + /opt/unoserver-venv/bin/pip install --no-cache-dir "unoserver==${UNOSERVER_VERSION}" && \ + ln -sf /opt/unoserver-venv/bin/unoserver /usr/local/bin/unoserver && \ + ln -sf /opt/unoserver-venv/bin/unoconvert /usr/local/bin/unoconvert && \ + ln -sf /opt/unoserver-venv/bin/unoping /usr/local/bin/unoping && \ + find /opt/unoserver-venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + +RUN rm -rf /usr/lib/libreoffice/share/gallery \ + /usr/lib/libreoffice/share/template \ + /usr/lib/libreoffice/share/wizards \ + /usr/lib/libreoffice/help \ + /usr/lib/libreoffice/share/extensions/wiki-publisher \ + /usr/lib/libreoffice/share/extensions/nlpsolver \ + /usr/share/doc/* /usr/share/man/* /usr/share/info/* + +ARG PUID=1001 +ARG PGID=1001 +RUN groupadd -g "${PGID}" unoserver && \ + useradd -u "${PUID}" -g unoserver -m -d /home/unoserver -s /bin/bash unoserver && \ + mkdir -p "${UNOSERVER_PROFILE_DIR}" && \ + chown -R unoserver:unoserver "${UNOSERVER_PROFILE_DIR}" /home/unoserver + +COPY --chmod=0755 docker/unoserver/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY --chmod=0755 docker/unoserver/healthcheck.sh /usr/local/bin/healthcheck.sh + +USER unoserver +WORKDIR /home/unoserver + +EXPOSE 2003 + +HEALTHCHECK --interval=15s --timeout=5s --retries=4 --start-period=45s \ + CMD /usr/local/bin/healthcheck.sh + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"] + +LABEL org.opencontainers.image.title="Stirling-PDF Unoserver" \ + org.opencontainers.image.description="Pinned unoserver build for Stirling-PDF remote UNO mode" \ + org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.vendor="Stirling-Tools" diff --git a/docker/unoserver/VERSION b/docker/unoserver/VERSION new file mode 100644 index 000000000..3eefcb9dd --- /dev/null +++ b/docker/unoserver/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/docker/unoserver/entrypoint.sh b/docker/unoserver/entrypoint.sh new file mode 100644 index 000000000..bf1b6dbc3 --- /dev/null +++ b/docker/unoserver/entrypoint.sh @@ -0,0 +1,104 @@ +#!/bin/bash +set -eu + +PORT="${UNOSERVER_PORT:-2003}" +UNO_PORT="${UNOSERVER_UNO_PORT:-2002}" +INTERFACE="${UNOSERVER_INTERFACE:-0.0.0.0}" +CONVERSION_TIMEOUT="${UNOSERVER_CONVERSION_TIMEOUT:-1800}" +RECYCLE_INTERVAL_SECONDS="${UNOSERVER_RECYCLE_INTERVAL_SECONDS:-0}" +RECYCLE_INTERVAL_FLOOR=60 +PROFILE_DIR="${UNOSERVER_PROFILE_DIR:-/var/lib/unoserver/profile}" + +log() { printf '%s %s\n' "[unoserver-entrypoint]" "$*" >&2; } + +case "$PORT" in ''|*[!0-9]*) log "Invalid UNOSERVER_PORT='$PORT'"; exit 64 ;; esac +case "$UNO_PORT" in ''|*[!0-9]*) log "Invalid UNOSERVER_UNO_PORT='$UNO_PORT'"; exit 64 ;; esac +case "$CONVERSION_TIMEOUT" in ''|*[!0-9]*) log "Invalid UNOSERVER_CONVERSION_TIMEOUT='$CONVERSION_TIMEOUT'"; exit 64 ;; esac +case "$RECYCLE_INTERVAL_SECONDS" in ''|*[!0-9]*) log "Invalid UNOSERVER_RECYCLE_INTERVAL_SECONDS='$RECYCLE_INTERVAL_SECONDS'"; exit 64 ;; esac + +mkdir -p "$PROFILE_DIR" + +start_xvfb() { + if command -v Xvfb >/dev/null 2>&1 && [ -z "${DISPLAY:-}" ]; then + Xvfb :99 -screen 0 1024x768x24 -ac +extension GLX +render -noreset >/dev/null 2>&1 & + XVFB_PID=$! + export DISPLAY=:99 + sleep 1 + log "Xvfb started (pid $XVFB_PID, DISPLAY=$DISPLAY)" + fi +} + +cleanup() { + trap '' TERM INT EXIT + if [ -n "${UNOSERVER_PID:-}" ] && kill -0 "$UNOSERVER_PID" 2>/dev/null; then + log "Stopping unoserver (pid $UNOSERVER_PID)" + pkill -TERM -P "$UNOSERVER_PID" 2>/dev/null || true + kill -TERM "$UNOSERVER_PID" 2>/dev/null || true + wait "$UNOSERVER_PID" 2>/dev/null || true + fi + if [ -n "${XVFB_PID:-}" ] && kill -0 "$XVFB_PID" 2>/dev/null; then + kill -TERM "$XVFB_PID" 2>/dev/null || true + fi +} +trap cleanup TERM INT EXIT + +start_unoserver() { + log "Starting unoserver on ${INTERFACE}:${PORT} (uno-port ${UNO_PORT}, timeout ${CONVERSION_TIMEOUT}s, profile ${PROFILE_DIR})" + # Pass --user-installation as a plain path; unoserver 3.6 wraps it itself + # and crashes if pre-wrapped as a file:// URI. + unoserver \ + --interface "$INTERFACE" \ + --port "$PORT" \ + --uno-port "$UNO_PORT" \ + --user-installation "${PROFILE_DIR}" \ + --conversion-timeout "$CONVERSION_TIMEOUT" \ + 2> >(grep --line-buffered -v "POST /RPC2" >&2) \ + & + UNOSERVER_PID=$! +} + +# Wall-clock recycle to bound LibreOffice memory growth. wait -n is unreliable +# here because the unoserver job is wrapped in a process substitution. +recycle_supervisor() { + if [ "$RECYCLE_INTERVAL_SECONDS" -le 0 ]; then + log "Recycle disabled (UNOSERVER_RECYCLE_INTERVAL_SECONDS=0)" + wait "$UNOSERVER_PID" + return $? + fi + local interval="$RECYCLE_INTERVAL_SECONDS" + if [ "$interval" -lt "$RECYCLE_INTERVAL_FLOOR" ]; then + log "Clamping recycle interval ${interval}s up to floor ${RECYCLE_INTERVAL_FLOOR}s" + interval="$RECYCLE_INTERVAL_FLOOR" + fi + log "Recycle enabled: restart every ${interval}s" + while true; do + local elapsed=0 + while [ "$elapsed" -lt "$interval" ]; do + if ! kill -0 "$UNOSERVER_PID" 2>/dev/null; then + wait "$UNOSERVER_PID" + local rc=$? + log "unoserver exited on its own (rc=$rc); not recycling" + return "$rc" + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + log "Recycling unoserver (pid ${UNOSERVER_PID})" + pkill -TERM -P "$UNOSERVER_PID" 2>/dev/null || true + kill -TERM "$UNOSERVER_PID" 2>/dev/null || true + for _ in 1 2 3 4 5; do + kill -0 "$UNOSERVER_PID" 2>/dev/null || break + sleep 1 + done + pkill -KILL -P "$UNOSERVER_PID" 2>/dev/null || true + kill -KILL "$UNOSERVER_PID" 2>/dev/null || true + wait "$UNOSERVER_PID" 2>/dev/null || true + rm -rf "${PROFILE_DIR:?}"/* 2>/dev/null || true + start_unoserver + log "unoserver restarted (pid ${UNOSERVER_PID})" + done +} + +start_xvfb +start_unoserver +recycle_supervisor diff --git a/docker/unoserver/fonts.conf b/docker/unoserver/fonts.conf new file mode 100644 index 000000000..babfb6aa3 --- /dev/null +++ b/docker/unoserver/fonts.conf @@ -0,0 +1,30 @@ + + + + + + + rgb + + + + + true + + + + + hintslight + + + + + true + + + + + lcddefault + + + diff --git a/docker/unoserver/healthcheck.sh b/docker/unoserver/healthcheck.sh new file mode 100644 index 000000000..8a48d2cd0 --- /dev/null +++ b/docker/unoserver/healthcheck.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Real RPC ping — a TCP socket can be bound while LibreOffice is wedged. +set -eu + +PORT="${UNOSERVER_PORT:-2003}" + +if command -v unoping >/dev/null 2>&1; then + unoping --host 127.0.0.1 --port "$PORT" >/dev/null 2>&1 + exit $? +fi + +exec timeout 2 bash -c "cat < /dev/null > /dev/tcp/127.0.0.1/${PORT}" diff --git a/scripts/init-without-ocr.sh b/scripts/init-without-ocr.sh index 0a406e9a5..a22ee61d8 100755 --- a/scripts/init-without-ocr.sh +++ b/scripts/init-without-ocr.sh @@ -255,23 +255,49 @@ get_unoserver_count() { read_setting_value "libreOfficeSessionLimit" } +# Mirror libreOfficetimeoutMinutes so Java and unoserver agree. +get_unoserver_conversion_timeout_seconds() { + local minutes="" + if [ -n "${PROCESS_EXECUTOR_TIMEOUT_MINUTES_LIBRE_OFFICETIMEOUT_MINUTES:-}" ]; then + minutes="$PROCESS_EXECUTOR_TIMEOUT_MINUTES_LIBRE_OFFICETIMEOUT_MINUTES" + elif [ -n "${UNO_SERVER_CONVERSION_TIMEOUT_MINUTES:-}" ]; then + minutes="$UNO_SERVER_CONVERSION_TIMEOUT_MINUTES" + else + minutes="$(read_setting_value "libreOfficetimeoutMinutes")" + fi + case "$minutes" in + ''|*[!0-9]*) minutes=30 ;; + esac + if [ "$minutes" -le 0 ]; then + minutes=30 + fi + echo $((minutes * 60)) +} + start_unoserver_instance() { local port=$1 local uno_port=$2 - # Suppress repetitive POST /RPC2 access logs from health checks + local conversion_timeout + conversion_timeout="$(get_unoserver_conversion_timeout_seconds)" + # Per-instance profile dir avoids LibreOffice lock-file contention. + local profile_dir="${LIBREOFFICE_PROFILE}/instance_${port}" + run_as_runtime_user mkdir -p "$profile_dir" + # --user-installation is a plain path; unoserver 3.6 crashes if pre-wrapped as file://. run_as_runtime_user "$UNOSERVER_BIN" \ --interface 127.0.0.1 \ --port "$port" \ --uno-port "$uno_port" \ + --user-installation "$profile_dir" \ + --conversion-timeout "$conversion_timeout" \ 2> >(grep --line-buffered -v "POST /RPC2" >&2) \ & LAST_UNOSERVER_PID=$! } start_unoserver_watchdog() { - local interval=${UNO_SERVER_HEALTH_INTERVAL:-120} + local interval=${UNO_SERVER_HEALTH_INTERVAL:-30} case "$interval" in - ''|*[!0-9]*) interval=120 ;; + ''|*[!0-9]*) interval=30 ;; esac ( while true; do