mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge branch 'main' into feat/auto-form-detection
This commit is contained in:
@@ -103,6 +103,14 @@ app/core/pipeline/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
|
||||
# Python virtualenvs. Large, platform-specific, and their symlinks break the build.
|
||||
.venv/
|
||||
**/.venv/
|
||||
venv/
|
||||
**/venv/
|
||||
*.egg-info/
|
||||
**/*.egg-info/
|
||||
|
||||
# Local env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -475,7 +475,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -482,7 +482,7 @@ jobs:
|
||||
issues: write # add/remove labels, delete the command comment
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
is_fork: ${{ steps.decide.outputs.is_fork }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
issues: write # labels are applied through the issues API
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
flavor: [core, proprietary, saas]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -351,7 +351,7 @@ jobs:
|
||||
MN_COMPOSE: docker-compose-multinode.yml
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
proprietary: ${{ steps.changes.outputs.proprietary }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -298,7 +298,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
project: stubbed-webkit
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -334,7 +334,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
@@ -715,7 +715,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
pull-requests: write # pulls.get/list plus add/remove the label on PRs
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,6 +18,16 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
build_engine:
|
||||
description: "Build & push the standalone stirling-engine image."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
force_engine_rebuild:
|
||||
description: "Rebuild stirling-engine even if its source hash is unchanged."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
@@ -50,9 +60,10 @@ jobs:
|
||||
env:
|
||||
RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }}
|
||||
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
|
||||
RUN_ENGINE: ${{ github.event_name != 'workflow_dispatch' || inputs.build_engine }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -387,3 +398,119 @@ jobs:
|
||||
else
|
||||
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing"
|
||||
fi
|
||||
|
||||
# Standalone AI engine image, same shape as the unoserver image above.
|
||||
- name: Compute engine image source hash
|
||||
id: engineHash
|
||||
if: env.RUN_ENGINE == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
hash=$( { cat engine/Dockerfile engine/pyproject.toml engine/uv.lock engine/.env; \
|
||||
find engine/src -type f -print0 | sort -z | xargs -0 cat; } \
|
||||
| sha256sum | cut -d' ' -f1)
|
||||
echo "hash=${hash}" >> "$GITHUB_OUTPUT"
|
||||
echo "Engine source hash: ${hash}"
|
||||
|
||||
- name: Decide whether to publish engine image
|
||||
id: engineDecision
|
||||
if: env.RUN_ENGINE == 'true'
|
||||
env:
|
||||
ENGINE_VERSION: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
ENGINE_HASH: ${{ steps.engineHash.outputs.hash }}
|
||||
ENGINE_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-engine
|
||||
ENGINE_HASH_ANNOTATION: org.stirlingpdf.engine-source-hash
|
||||
FORCE_REBUILD: ${{ inputs.force_engine_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 "$ENGINE_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/release)
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_engine_rebuild=true — building stable regardless"
|
||||
mode="stable"
|
||||
tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest"
|
||||
elif docker manifest inspect "${ENGINE_IMAGE}:${ENGINE_VERSION}" >/dev/null 2>&1; then
|
||||
echo "stirling-engine:${ENGINE_VERSION} already on GHCR — skipping"
|
||||
else
|
||||
echo "stirling-engine:${ENGINE_VERSION} is new — will publish"
|
||||
mode="stable"
|
||||
tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest"
|
||||
fi
|
||||
;;
|
||||
refs/heads/main|refs/heads/testMain)
|
||||
published_hash=$(read_published_hash "${ENGINE_IMAGE}:alpha")
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_engine_rebuild=true — rebuilding :alpha regardless"
|
||||
mode="alpha"
|
||||
tags="${ENGINE_IMAGE}:alpha"
|
||||
elif [ -n "$published_hash" ] && [ "$published_hash" = "$ENGINE_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) — will publish"
|
||||
else
|
||||
echo "Source hash changed (was ${published_hash}, now ${ENGINE_HASH}) — will publish"
|
||||
fi
|
||||
mode="alpha"
|
||||
tags="${ENGINE_IMAGE}:alpha"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Branch ${GH_REF} does not publish engine image"
|
||||
;;
|
||||
esac
|
||||
echo "mode=${mode}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push engine image
|
||||
id: build-push-engine
|
||||
if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode != 'skip'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./engine/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-engine
|
||||
cache-to: type=gha,mode=max,scope=stirling-engine
|
||||
tags: ${{ steps.engineDecision.outputs.tags }}
|
||||
# Manifest annotation read by the decision step above to detect drift.
|
||||
annotations: |
|
||||
index:org.stirlingpdf.engine-source-hash=${{ steps.engineHash.outputs.hash }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign engine image
|
||||
if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode == 'stable'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-engine.outputs.digest }}
|
||||
TAGS: ${{ steps.engineDecision.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 engine image signing"
|
||||
fi
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -703,7 +703,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -795,7 +795,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
+16
-17
@@ -91,24 +91,32 @@ public class ControllerAuditAspect {
|
||||
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = sig.getMethod();
|
||||
|
||||
// Fast path: check if auditing is enabled before doing any work
|
||||
// This avoids all data collection if auditing is disabled
|
||||
if (!auditService.shouldAudit(method, auditConfig)) {
|
||||
// Resolve the event type up front so the enterprise gate can be type-aware: document
|
||||
// processing events (the Documents tab's data source) are audited without an Enterprise
|
||||
// license, while the rest of the audit log stays Enterprise-only. resolveEventType is cheap
|
||||
// (annotation / class / path checks), so it's safe on the pre-record fast path.
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
String path = getRequestPath(method, httpMethod);
|
||||
AuditEventType eventType =
|
||||
auditService.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Fast path: skip all data collection when this event won't be recorded.
|
||||
if (!auditService.shouldAudit(eventType, method, auditConfig)) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
// Check if method is explicitly annotated with @Audited
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel level = auditConfig.getAuditLevel();
|
||||
|
||||
// If @Audited annotation is present, respect its level setting
|
||||
if (auditedAnnotation != null) {
|
||||
// Use the level from annotation if it's stricter than global level
|
||||
level = auditedAnnotation.level();
|
||||
}
|
||||
|
||||
String path = getRequestPath(method, httpMethod);
|
||||
|
||||
// Skip static GET resources
|
||||
if ("GET".equals(httpMethod)) {
|
||||
HttpServletRequest maybe = auditService.getCurrentRequest();
|
||||
@@ -209,15 +217,6 @@ public class ControllerAuditAspect {
|
||||
// the body ran, so it must happen here rather than with the pre-proceed HTTP data).
|
||||
auditService.addAutomationContext(data, req);
|
||||
|
||||
// Resolve the event type using the unified method
|
||||
AuditEventType eventType =
|
||||
auditService.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Add result only if operation result capture is explicitly enabled
|
||||
// Skip result for UI_DATA events to avoid storing large response bodies
|
||||
if (auditService.shouldCaptureOperationResults()
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Self-hosted default: any portal user sees the whole-server documents queue. */
|
||||
@Component
|
||||
public class DefaultPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver {
|
||||
|
||||
@Override
|
||||
public PortalAuditScope resolve() {
|
||||
return PortalAuditScope.server();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
/** Resolves which slice of the documents queue a portal user may see. */
|
||||
public interface PortalDocumentsScopeResolver {
|
||||
|
||||
PortalAuditScope resolve();
|
||||
}
|
||||
+14
-6
@@ -2,6 +2,7 @@ package stirling.software.proprietary.controller.api;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@@ -11,19 +12,25 @@ import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.audit.PortalDocumentsScopeResolver;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.service.PortalDocumentsService;
|
||||
|
||||
/** Serves the portal Documents review queue, derived from real audit data and scoped per caller. */
|
||||
/**
|
||||
* Serves the portal Documents review queue, derived from real audit data and scoped per caller.
|
||||
*
|
||||
* <p>Open to every portal user (not Enterprise-gated): the Documents tab is a core Processor
|
||||
* feature. Access is enforced by {@code @resourceAccess.canUsePortal()}; visibility is then
|
||||
* resolved per deployment - self-hosted portal users see the whole server, SaaS users see their
|
||||
* team (see {@link PortalDocumentsScopeResolver}).
|
||||
*/
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
@PreAuthorize("@resourceAccess.canUsePortal()")
|
||||
public class PortalDocumentsController {
|
||||
|
||||
private final PortalDocumentsService portalDocumentsService;
|
||||
private final PortalAuditScopeResolver auditScopeResolver;
|
||||
private final PortalDocumentsScopeResolver documentsScopeResolver;
|
||||
|
||||
// tier accepted for mock-seam symmetry; ignored (queue isn't tier-scoped).
|
||||
@GetMapping("/documents")
|
||||
@@ -32,8 +39,9 @@ public class PortalDocumentsController {
|
||||
description = "Files processed through the org, derived from the audit trail.")
|
||||
public ResponseEntity<PortalDocumentsResponseDto> getDocuments(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
PortalAuditScope scope = auditScopeResolver.resolve();
|
||||
PortalAuditScope scope = documentsScopeResolver.resolve();
|
||||
if (!scope.allowed()) {
|
||||
// SaaS caller with no team has nothing to show; surface an empty tab, not a 500.
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
PortalDocumentsResponseDto body =
|
||||
|
||||
+33
-3
@@ -5,13 +5,13 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
@@ -20,15 +20,31 @@ import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
/** Service to periodically clean up old audit events based on retention policy. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditCleanupService {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
private final boolean runningEE;
|
||||
|
||||
// Default batch size for deletions
|
||||
private static final int BATCH_SIZE = 10000;
|
||||
|
||||
/**
|
||||
* Maximum audit retention on non-Enterprise instances. Audit events feed the Documents tab on
|
||||
* every instance, but longer history is an Enterprise feature - so non-EE deployments keep a
|
||||
* shorter window ("infinite" included), bounding the always-on trail off-license.
|
||||
*/
|
||||
private static final int NON_EE_MAX_RETENTION_DAYS = 30;
|
||||
|
||||
public AuditCleanupService(
|
||||
PersistentAuditEventRepository auditRepository,
|
||||
AuditConfigurationProperties auditConfig,
|
||||
@Qualifier("runningEE") boolean runningEE) {
|
||||
this.auditRepository = auditRepository;
|
||||
this.auditConfig = auditConfig;
|
||||
this.runningEE = runningEE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled task that runs daily to clean up old audit events. The retention period is
|
||||
* configurable in settings.yml.
|
||||
@@ -39,7 +55,7 @@ public class AuditCleanupService {
|
||||
return;
|
||||
}
|
||||
|
||||
int retentionDays = auditConfig.getRetentionDays();
|
||||
int retentionDays = effectiveRetentionDays();
|
||||
if (retentionDays <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -58,6 +74,20 @@ public class AuditCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The retention window actually applied. Enterprise uses the configured value (0 = infinite);
|
||||
* non-Enterprise is clamped to {@link #NON_EE_MAX_RETENTION_DAYS}.
|
||||
*/
|
||||
int effectiveRetentionDays() {
|
||||
int configured = auditConfig.getRetentionDays();
|
||||
if (runningEE) {
|
||||
return configured;
|
||||
}
|
||||
return configured <= 0
|
||||
? NON_EE_MAX_RETENTION_DAYS
|
||||
: Math.min(configured, NON_EE_MAX_RETENTION_DAYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs batch deletion of events to prevent long-running transactions and potential database
|
||||
* locks.
|
||||
|
||||
+55
-16
@@ -87,10 +87,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(AuditEventType type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,8 +123,7 @@ public class AuditService {
|
||||
*/
|
||||
public void audit(
|
||||
String principal, AuditEventType type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,8 +152,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(String type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,8 +187,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(String principal, String type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,9 +217,7 @@ public class AuditService {
|
||||
AuditEventType type,
|
||||
Map<String, Object> data,
|
||||
AuditLevel level) {
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,9 +242,7 @@ public class AuditService {
|
||||
String type,
|
||||
Map<String, Object> data,
|
||||
AuditLevel level) {
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -626,6 +616,55 @@ public class AuditService {
|
||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-aware variant used by the controller aspect, which resolves the event type before
|
||||
* deciding whether to record. Document-processing events feed the Documents tab (available to
|
||||
* every Processor user), so they audit without an Enterprise license; the rest of the audit log
|
||||
* stays Enterprise-only.
|
||||
*/
|
||||
public boolean shouldAudit(
|
||||
AuditEventType eventType, Method method, AuditConfigurationProperties auditConfig) {
|
||||
if (!auditConfig.isEnabled() || !isLicensedToRecord(eventType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel requiredLevel =
|
||||
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
|
||||
|
||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event of this type and level should be persisted: the configured audit level must
|
||||
* include it and the current license must permit recording it.
|
||||
*/
|
||||
private boolean shouldRecord(AuditEventType type, AuditLevel level) {
|
||||
return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type);
|
||||
}
|
||||
|
||||
private boolean shouldRecord(String type, AuditLevel level) {
|
||||
return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current license permits recording this event type. Enterprise records everything;
|
||||
* without it only document-processing events (PDF_PROCESS, FILE_OPERATION) are captured,
|
||||
* because they back the Documents tab that is open to every Processor user (still subject to
|
||||
* audit being enabled at a level that includes them). Everything else stays Enterprise-only.
|
||||
*/
|
||||
private boolean isLicensedToRecord(AuditEventType type) {
|
||||
return runningEE
|
||||
|| type == AuditEventType.PDF_PROCESS
|
||||
|| type == AuditEventType.FILE_OPERATION;
|
||||
}
|
||||
|
||||
private boolean isLicensedToRecord(String type) {
|
||||
return runningEE
|
||||
|| AuditEventType.PDF_PROCESS.name().equals(type)
|
||||
|| AuditEventType.FILE_OPERATION.name().equals(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add timing and response status data to the audit record
|
||||
*
|
||||
|
||||
+14
-7
@@ -76,7 +76,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("shouldAudit false proceeds without recording")
|
||||
void skipsWhenShouldAuditFalse() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("getEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(false);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(false);
|
||||
when(jp.proceed()).thenReturn("ok");
|
||||
|
||||
Object result = aspect.auditGetMethod(jp);
|
||||
@@ -102,7 +103,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("records success outcome and returns result")
|
||||
void recordsSuccess() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -134,7 +136,8 @@ class ControllerAuditAspectTest {
|
||||
MDC.put("auditPrincipal", "fromMdc");
|
||||
MDC.put("auditOrigin", "API");
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
.thenReturn(new HashMap<>());
|
||||
when(auditService.resolveEventType(
|
||||
@@ -166,7 +169,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("records failure outcome and rethrows")
|
||||
void recordsFailureAndRethrows() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -204,7 +208,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("annotated method proceeds without double-auditing")
|
||||
void annotatedMethodSkips() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("annotatedEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(jp.proceed()).thenReturn("ok");
|
||||
@@ -232,7 +237,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("captures result when enabled and non-UI type")
|
||||
void capturesResult() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -262,7 +268,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("UI_DATA result is not captured")
|
||||
void uiDataResultSkipped() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("getEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
|
||||
class AuditCleanupServiceTest {
|
||||
|
||||
private final PersistentAuditEventRepository repository =
|
||||
mock(PersistentAuditEventRepository.class);
|
||||
|
||||
private AuditCleanupService service(boolean runningEE, int retentionDays) {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
var audit = props.getPremium().getEnterpriseFeatures().getAudit();
|
||||
audit.setEnabled(true);
|
||||
audit.setRetentionDays(retentionDays);
|
||||
return new AuditCleanupService(
|
||||
repository, new AuditConfigurationProperties(props), runningEE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Enterprise keeps the configured retention, including infinite")
|
||||
void enterpriseUsesConfigured() {
|
||||
assertThat(service(true, 90).effectiveRetentionDays()).isEqualTo(90);
|
||||
assertThat(service(true, 365).effectiveRetentionDays()).isEqualTo(365);
|
||||
assertThat(service(true, 0).effectiveRetentionDays()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise caps retention at 30 days")
|
||||
void nonEnterpriseCapsHigherValues() {
|
||||
assertThat(service(false, 90).effectiveRetentionDays()).isEqualTo(30);
|
||||
assertThat(service(false, 365).effectiveRetentionDays()).isEqualTo(30);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise respects a shorter configured retention")
|
||||
void nonEnterpriseRespectsLower() {
|
||||
assertThat(service(false, 14).effectiveRetentionDays()).isEqualTo(14);
|
||||
assertThat(service(false, 7).effectiveRetentionDays()).isEqualTo(7);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise cannot retain forever (<= 0 becomes the cap)")
|
||||
void nonEnterpriseNoInfinite() {
|
||||
assertThat(service(false, 0).effectiveRetentionDays()).isEqualTo(30);
|
||||
assertThat(service(false, -1).effectiveRetentionDays()).isEqualTo(30);
|
||||
}
|
||||
}
|
||||
+36
@@ -166,6 +166,42 @@ class AuditServiceTest {
|
||||
verify(repository, never()).add(any(AuditEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("records document-processing events even without EE (Documents feed)")
|
||||
void recordsDocumentEventsWithoutEE() {
|
||||
AuditService nonEe =
|
||||
new AuditService(
|
||||
repository, auditConfig, false, pdfDocumentFactory, jwtService);
|
||||
authenticateAs("alice");
|
||||
|
||||
nonEe.audit(AuditEventType.PDF_PROCESS, new HashMap<>(), AuditLevel.BASIC);
|
||||
nonEe.audit(AuditEventType.FILE_OPERATION, new HashMap<>(), AuditLevel.BASIC);
|
||||
|
||||
org.mockito.ArgumentCaptor<AuditEvent> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(AuditEvent.class);
|
||||
verify(repository, org.mockito.Mockito.times(2)).add(captor.capture());
|
||||
assertThat(captor.getAllValues())
|
||||
.extracting(AuditEvent::getType)
|
||||
.containsExactly(
|
||||
AuditEventType.PDF_PROCESS.name(),
|
||||
AuditEventType.FILE_OPERATION.name());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("type-aware shouldAudit lets doc events through without EE, blocks others")
|
||||
void typeAwareShouldAuditWithoutEE() throws Exception {
|
||||
AuditService nonEe =
|
||||
new AuditService(
|
||||
repository, auditConfig, false, pdfDocumentFactory, jwtService);
|
||||
Method m = Object.class.getMethod("toString");
|
||||
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.PDF_PROCESS, m, auditConfig)).isTrue();
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.FILE_OPERATION, m, auditConfig)).isTrue();
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isFalse();
|
||||
// With EE, non-doc events at/under the configured level audit too.
|
||||
assertThat(service.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips when audit disabled")
|
||||
void skipsWhenDisabled() {
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.audit.PortalDocumentsScopeResolver;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
|
||||
/**
|
||||
* SaaS documents visibility: platform admins see the whole server; every other portal user sees
|
||||
* their own team's documents (by member email).
|
||||
*/
|
||||
@Component
|
||||
@Primary
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class SaasPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver {
|
||||
|
||||
private final TeamSecurityExpressions teamSecurity;
|
||||
private final TeamMembershipRepository membershipRepository;
|
||||
|
||||
@Override
|
||||
public PortalAuditScope resolve() {
|
||||
if (PortalAuditScopeResolver.hasAdminAuthority()) {
|
||||
return PortalAuditScope.server();
|
||||
}
|
||||
Long teamId = teamSecurity.currentUserTeamId();
|
||||
if (teamId == null) {
|
||||
return PortalAuditScope.denied();
|
||||
}
|
||||
List<String> memberEmails =
|
||||
membershipRepository.findByTeamId(teamId).stream()
|
||||
.map(m -> m.getUser() == null ? null : m.getUser().getEmail())
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
return PortalAuditScope.team("team:" + teamId, memberEmails);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,30 @@ RUN apt-get update \
|
||||
&& sh /tmp/slim-onnxruntime.sh /layers/dependencies
|
||||
|
||||
|
||||
# Stage 2b: AI engine. Built at its final path so the venv resolves after the copy, on uv's
|
||||
# managed CPython because the runtime base ships Python 3.12.
|
||||
FROM ghcr.io/astral-sh/uv:bookworm-slim@sha256:22334efe746f1b69217d455049b484d7b8cacfb2d5f42555580b62415a98e0a3 AS engine-build
|
||||
ENV UV_PYTHON_INSTALL_DIR=/opt/stirling-engine/python
|
||||
WORKDIR /opt/stirling-engine
|
||||
COPY engine/pyproject.toml engine/uv.lock ./
|
||||
# One layer: trimming in a second RUN would cache the untrimmed copy too, and this build
|
||||
# exports every layer to a GHA cache that is capped repo-wide. Trimming saves ~20MB.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
set -eux; \
|
||||
apt-get update && apt-get install -y --no-install-recommends binutils; \
|
||||
uv python install 3.13; \
|
||||
uv sync --frozen --no-dev --no-install-project --group engine --python-preference only-managed; \
|
||||
P="$(ls -d /opt/stirling-engine/python/cpython-*)"; \
|
||||
rm -rf "$P/share" "$P/include" \
|
||||
"$P/lib/python3.13/idlelib" "$P/lib/python3.13/tkinter" \
|
||||
"$P/lib/python3.13/ensurepip" "$P/lib/python3.13/pydoc_data" \
|
||||
"$P/lib/python3.13/test" "$P/lib/python3.13/lib2to3"; \
|
||||
find /opt/stirling-engine -name '__pycache__' -type d -prune -exec rm -rf {} + ; \
|
||||
find /opt/stirling-engine \( -name '*.so' -o -name '*.so.*' \) -print0 \
|
||||
| xargs -0 -r strip --strip-unneeded 2>/dev/null || true; \
|
||||
apt-get purge -y binutils; apt-get autoremove -y; rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# Stage 3: Final runtime image on top of pre-built base
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
@@ -113,6 +137,12 @@ COPY --link --from=app-build --chown=1000:1000 \
|
||||
/app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
COPY --link --chown=1000:1000 scripts/ /scripts/
|
||||
|
||||
# init-without-ocr.sh starts the engine when this directory exists, so other images are unaffected.
|
||||
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/python /opt/stirling-engine/python
|
||||
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/.venv /opt/stirling-engine/.venv
|
||||
COPY --link --chown=1000:1000 engine/.env /opt/stirling-engine/.env
|
||||
COPY --link --chown=1000:1000 engine/src/ /opt/stirling-engine/src/
|
||||
|
||||
# Pre-bundled default detection model; FormDetectionModelManager seeds it into /configs on startup.
|
||||
COPY --link --from=app-build --chown=1000:1000 /preinstalled-models/ /opt/stirling/preinstalled-models/
|
||||
|
||||
@@ -129,6 +159,8 @@ RUN set -eux; \
|
||||
ln -s /storage /app/storage; \
|
||||
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app; \
|
||||
mkdir -p /opt/stirling-engine/data; \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup /opt/stirling-engine/data; \
|
||||
chmod 750 /tmp/stirling-pdf; \
|
||||
chmod 750 /tmp/stirling-pdf/heap_dumps; \
|
||||
fc-cache -f
|
||||
@@ -149,6 +181,10 @@ ENV VERSION_TAG=$VERSION_TAG \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
FAT_DOCKER=true \
|
||||
AIENGINE_ENABLED=true \
|
||||
STIRLING_ENGINE_HOME=/opt/stirling-engine \
|
||||
STIRLING_ENGINE_PORT=5001 \
|
||||
STIRLING_ENGINE_WORKERS=2 \
|
||||
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
|
||||
+29
-21
@@ -1,35 +1,43 @@
|
||||
# syntax=docker/dockerfile:1.5
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca
|
||||
|
||||
ARG TASK_VERSION=3.52.0
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& ARCH=$(dpkg --print-architecture) \
|
||||
&& curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \
|
||||
&& dpkg -i /tmp/task.deb \
|
||||
&& rm /tmp/task.deb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# uv resolves the venv here so its ~52MB binary stays out of the runtime image.
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca AS builder
|
||||
|
||||
# Source under /app/engine/ to match root Taskfile's `includes.engine.dir: engine`.
|
||||
WORKDIR /app/engine
|
||||
COPY engine/pyproject.toml engine/uv.lock engine/.env ./
|
||||
COPY engine/scripts/ ./scripts/
|
||||
COPY engine/pyproject.toml engine/uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --group engine
|
||||
uv sync --frozen --no-dev --no-install-project --group engine
|
||||
|
||||
COPY engine/src/ ./src/
|
||||
WORKDIR /app
|
||||
COPY Taskfile.yml ./
|
||||
COPY .taskfiles/ ./.taskfiles/
|
||||
FROM python:3.13-slim-bookworm@sha256:00faa2debb87529f9f0764e9491d8ba400a3678976616c3bd7cb193745ac20d1 AS runtime
|
||||
|
||||
# Created before the COPYs so they land owned; a later chown -R duplicates the venv layer.
|
||||
RUN set -eux; \
|
||||
groupadd --system --gid 1000 stirling; \
|
||||
useradd --system --uid 1000 --gid 1000 --home /app/engine stirling; \
|
||||
mkdir -p /app/engine/data; \
|
||||
chown stirling:stirling /app/engine /app/engine/data
|
||||
|
||||
WORKDIR /app/engine
|
||||
COPY --from=builder --chown=stirling:stirling /app/engine/.venv ./.venv
|
||||
# settings.py resolves ENGINE_ROOT to /app/engine, so .env must sit here.
|
||||
COPY --chown=stirling:stirling engine/.env ./
|
||||
COPY --chown=stirling:stirling engine/src/ ./src/
|
||||
|
||||
ENV PATH="/app/engine/.venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV STIRLING_ENGINE_WORKERS=4
|
||||
# Container runs on a fixed port; skip the host-only free-port probe (its script
|
||||
# is not shipped in the image). engine:run honours these.
|
||||
ENV ENGINE_PORT_PROBE=false
|
||||
ENV STIRLING_ENGINE_PORT=5001
|
||||
# Fail closed: without a secret the document routes trust caller-supplied X-User-Id.
|
||||
# Set STIRLING_ENGINE_SHARED_SECRET (the backend sends it as X-Engine-Auth), or false to opt out.
|
||||
ENV STIRLING_ENGINE_REQUIRE_AUTH=true
|
||||
|
||||
# `stirling` resolves from the working directory.
|
||||
WORKDIR /app/engine/src
|
||||
USER stirling
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
CMD ["task", "engine:run"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD ["python", "-c", "import os,sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:%s/health' % os.environ.get('STIRLING_ENGINE_PORT','5001'), timeout=4).status==200 else 1)"]
|
||||
|
||||
CMD ["sh", "-c", "exec uvicorn stirling.api.app:app --host 0.0.0.0 --port ${STIRLING_ENGINE_PORT:-5001} --workers ${STIRLING_ENGINE_WORKERS:-4}"]
|
||||
|
||||
@@ -16,8 +16,9 @@ engine = [
|
||||
"psycopg[binary,pool]>=3.3.4",
|
||||
"pydantic>=2.13.4",
|
||||
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration.
|
||||
"pydantic-ai>=1.107.2,<2.0.0",
|
||||
"pydantic-ai-slim[voyageai]>=1.107.2,<2.0.0",
|
||||
# Explicit extras: the `pydantic-ai` meta-package pulls all 20 providers (~230MB).
|
||||
# No `voyageai` extra either; stirling.documents.voyage speaks its API directly.
|
||||
"pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0",
|
||||
"pydantic-settings>=2.15.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
"sqlite-vec>=0.1.9",
|
||||
|
||||
@@ -70,6 +70,19 @@ Provider credentials (and any local overrides) go in the uncommitted
|
||||
VOYAGE_API_KEY=your-key
|
||||
```
|
||||
|
||||
### Embedding providers
|
||||
|
||||
`STIRLING_RAG_EMBEDDING_MODEL` is a `provider:model` string. Any OpenAI-compatible
|
||||
`/v1/embeddings` endpoint (vLLM, Ollama, TEI, llama.cpp) works by pointing a base URL
|
||||
at it. Note `OPENAI_BASE_URL` is global and also redirects chat completions; push
|
||||
`provider`/`api_key`/`base_url` through admin AI settings to move embeddings only.
|
||||
Ollama reads `OLLAMA_BASE_URL`, and omitting it fails the first embed call, not startup.
|
||||
|
||||
```
|
||||
STIRLING_RAG_EMBEDDING_MODEL=ollama:nomic-embed-text
|
||||
OLLAMA_BASE_URL=http://ollama:11434/v1
|
||||
```
|
||||
|
||||
## Backends
|
||||
|
||||
**`sqlite`** - Embedded sqlite-vec. Single `.db` file, zero ops. Ideal for dev
|
||||
|
||||
@@ -6,6 +6,7 @@ from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
from stirling.documents.chunker import chunk_text
|
||||
from stirling.documents.store import Document
|
||||
from stirling.documents.voyage import build_voyage_model
|
||||
|
||||
# Keep each upstream embed request under every major provider's per-call limit while
|
||||
# still batching large enough that a book-sized document ingests in a reasonable number
|
||||
@@ -14,6 +15,9 @@ from stirling.documents.store import Document
|
||||
DEFAULT_EMBED_BATCH_SIZE = 256
|
||||
|
||||
|
||||
VOYAGE_PROVIDER = "voyageai"
|
||||
|
||||
|
||||
def _build_embedder(
|
||||
model_name: str,
|
||||
*,
|
||||
@@ -23,11 +27,17 @@ def _build_embedder(
|
||||
) -> Embedder:
|
||||
"""Construct an :class:`Embedder`; explicit provider/api_key/base_url is the config-push path, else env form."""
|
||||
if not provider and not api_key and not base_url:
|
||||
# Env form is a "provider:model" string; Voyage needs the SDK-free adapter.
|
||||
env_provider, sep, env_model = model_name.partition(":")
|
||||
if sep and env_provider.lower() == VOYAGE_PROVIDER:
|
||||
return Embedder(build_voyage_model(env_model))
|
||||
return Embedder(model_name)
|
||||
|
||||
provider_name = (provider or "").lower()
|
||||
key = api_key or None
|
||||
if provider_name in ("voyageai", "openai"):
|
||||
if provider_name == VOYAGE_PROVIDER:
|
||||
return Embedder(build_voyage_model(model_name, api_key=key, base_url=base_url or None))
|
||||
if provider_name == "openai":
|
||||
return Embedder(f"{provider_name}:{model_name}")
|
||||
if provider_name in ("ollama", "custom"):
|
||||
openai_provider = OpenAIProvider(base_url=base_url or None, api_key=key or "ollama")
|
||||
|
||||
@@ -2,9 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,11 +21,31 @@ _READ_PERMISSION = "read"
|
||||
# write lock. With multiple worker processes opening the same file, they collide on
|
||||
# startup schema-init and get "database is locked". Wait for the lock instead.
|
||||
_BUSY_TIMEOUT_MS = 5000
|
||||
# journal_mode answers SQLITE_BUSY without consulting the busy handler, so it needs its own retry.
|
||||
_WAL_SWITCH_ATTEMPTS = 10
|
||||
_WAL_RETRY_DELAY_S = 0.1
|
||||
# sqlite stores TIMESTAMP as TEXT. We normalise to UTC ISO 8601 ``YYYY-MM-DD HH:MM:SS``
|
||||
# so lexicographic comparison against ``datetime('now')`` matches chronological order.
|
||||
_SQLITE_DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _enable_wal(conn: sqlite3.Connection) -> None:
|
||||
"""Switch the connection to WAL, tolerating workers racing to do the same."""
|
||||
for _ in range(_WAL_SWITCH_ATTEMPTS):
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return
|
||||
except sqlite3.OperationalError:
|
||||
row = conn.execute("PRAGMA journal_mode").fetchone()
|
||||
if row is not None and str(row[0]).lower() == "wal":
|
||||
return # another worker won the race and already switched it
|
||||
time.sleep(_WAL_RETRY_DELAY_S)
|
||||
logger.warning("Could not switch the document store to WAL; continuing on the default journal mode.")
|
||||
|
||||
|
||||
def _to_sqlite_utc(dt: datetime | None) -> str | None:
|
||||
if dt is None:
|
||||
return None
|
||||
@@ -58,7 +80,7 @@ class SqliteVecStore(DocumentStore):
|
||||
if self._db_path is not None:
|
||||
# Set before the WAL switch below: that pragma also takes the lock.
|
||||
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
_enable_wal(conn)
|
||||
|
||||
self._conn = conn
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""VoyageAI embeddings over its OpenAI-shaped REST API.
|
||||
|
||||
The `voyageai` SDK pulls PIL, numpy, tokenizers and langchain at import for multimodal,
|
||||
chunking and local-inference features the engine never uses (~207MB).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
|
||||
from pydantic_ai.embeddings import EmbeddingResult, EmbeddingSettings
|
||||
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
|
||||
from pydantic_ai.embeddings.result import EmbedInputType
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
VOYAGE_BASE_URL = "https://api.voyageai.com/v1"
|
||||
VOYAGE_API_KEY_ENV = "VOYAGE_API_KEY"
|
||||
|
||||
# Keeps a keyless engine bootable, and stops the client falling back to OPENAI_API_KEY.
|
||||
_MISSING_API_KEY = "stirling-voyage-api-key-not-configured"
|
||||
|
||||
|
||||
class VoyageEmbeddingModel(OpenAIEmbeddingModel):
|
||||
"""Voyage embeddings spoken over the OpenAI wire format."""
|
||||
|
||||
async def embed(
|
||||
self,
|
||||
inputs: str | Sequence[str],
|
||||
*,
|
||||
input_type: EmbedInputType,
|
||||
settings: EmbeddingSettings | None = None,
|
||||
) -> EmbeddingResult:
|
||||
"""Embed `inputs`, forwarding Voyage's `input_type` that the OpenAI model drops."""
|
||||
if self._client.api_key == _MISSING_API_KEY:
|
||||
raise ValueError(
|
||||
f"VoyageAI embeddings need an API key: set {VOYAGE_API_KEY_ENV} or push one via admin AI settings."
|
||||
)
|
||||
merged: EmbeddingSettings = {**(settings or {})}
|
||||
# extra_body is declared `object`, so narrow rather than assume a mapping.
|
||||
current = merged.get("extra_body")
|
||||
extra_body: dict[str, object] = dict(current) if isinstance(current, dict) else {}
|
||||
extra_body.setdefault("input_type", input_type)
|
||||
merged["extra_body"] = extra_body
|
||||
return await super().embed(inputs, input_type=input_type, settings=merged)
|
||||
|
||||
|
||||
def build_voyage_model(
|
||||
model_name: str,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> VoyageEmbeddingModel:
|
||||
"""Build a Voyage embedding model; a missing key only fails once an embed is attempted."""
|
||||
key = api_key or os.environ.get(VOYAGE_API_KEY_ENV) or _MISSING_API_KEY
|
||||
provider = OpenAIProvider(base_url=base_url or VOYAGE_BASE_URL, api_key=key)
|
||||
return VoyageEmbeddingModel(model_name, provider=provider)
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from stirling.contracts import PageText
|
||||
@@ -647,3 +649,19 @@ def _dummy_tool_def() -> object:
|
||||
"""Sentinel passed to ``_prepare_search_knowledge``. The callback only inspects
|
||||
``_search_count``; it doesn't read anything off the tool_def or context."""
|
||||
return object()
|
||||
|
||||
|
||||
# concurrent store startup
|
||||
|
||||
|
||||
def test_many_stores_open_the_same_file_without_locking_out(tmp_path: Path) -> None:
|
||||
"""Workers all construct a store against one file on boot; the WAL switch races."""
|
||||
import concurrent.futures
|
||||
|
||||
db_path = tmp_path / "rag.db"
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
stores = list(pool.map(lambda _: SqliteVecStore(db_path), range(8)))
|
||||
|
||||
assert len(stores) == 8
|
||||
mode = stores[0]._conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
assert str(mode).lower() == "wal"
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic_ai import Embedder
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
from stirling.documents.embedder import _build_embedder
|
||||
from stirling.documents.voyage import VOYAGE_BASE_URL, VoyageEmbeddingModel, build_voyage_model
|
||||
|
||||
# Voyage's documented response body: OpenAI's shape, minus prompt_tokens.
|
||||
VOYAGE_RESPONSE = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "embedding": [0.1, 0.2, 0.3], "index": 0},
|
||||
{"object": "embedding", "embedding": [0.4, 0.5, 0.6], "index": 1},
|
||||
],
|
||||
"model": "voyage-4",
|
||||
"usage": {"total_tokens": 7},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SentRequest:
|
||||
url: str
|
||||
auth: str | None
|
||||
body: dict[str, Any]
|
||||
|
||||
|
||||
def _recording_model(sent: list[SentRequest]) -> VoyageEmbeddingModel:
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
sent.append(
|
||||
SentRequest(
|
||||
url=str(request.url),
|
||||
auth=request.headers.get("authorization"),
|
||||
body=json.loads(request.content),
|
||||
)
|
||||
)
|
||||
return httpx.Response(200, json=VOYAGE_RESPONSE)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
provider = OpenAIProvider(base_url=VOYAGE_BASE_URL, api_key="pa-test-key", http_client=client)
|
||||
return VoyageEmbeddingModel("voyage-4", provider=provider)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_posts_to_voyage_embeddings_endpoint_with_bearer_auth() -> None:
|
||||
sent: list[SentRequest] = []
|
||||
await Embedder(_recording_model(sent)).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert sent[0].url == f"{VOYAGE_BASE_URL}/embeddings"
|
||||
assert sent[0].auth == "Bearer pa-test-key"
|
||||
assert sent[0].body["model"] == "voyage-4"
|
||||
assert sent[0].body["input"] == ["alpha", "beta"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("call", "expected"),
|
||||
[("embed_query", "query"), ("embed_documents", "document")],
|
||||
)
|
||||
async def test_forwards_voyage_input_type(call: str, expected: str) -> None:
|
||||
"""The stock OpenAI model drops this field; Voyage needs it."""
|
||||
sent: list[SentRequest] = []
|
||||
embedder = Embedder(_recording_model(sent))
|
||||
await getattr(embedder, call)(["text"])
|
||||
|
||||
assert sent[0].body["input_type"] == expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_caller_settings_win_over_the_default_input_type() -> None:
|
||||
sent: list[SentRequest] = []
|
||||
await Embedder(_recording_model(sent)).embed_documents(
|
||||
["text"], settings={"extra_body": {"input_type": "query", "output_dimension": 512}}
|
||||
)
|
||||
|
||||
assert sent[0].body["input_type"] == "query"
|
||||
assert sent[0].body["output_dimension"] == 512
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_parses_voyage_response_into_embeddings() -> None:
|
||||
result = await Embedder(_recording_model([])).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert result.embeddings == [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
|
||||
|
||||
|
||||
def test_build_voyage_model_reads_the_api_key_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
|
||||
|
||||
assert build_voyage_model("voyage-4").model_name == "voyage-4"
|
||||
|
||||
|
||||
def test_build_voyage_model_without_a_key_still_constructs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
|
||||
assert build_voyage_model("voyage-4").model_name == "voyage-4"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_embedding_without_a_key_fails_with_a_clear_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
model = build_voyage_model("voyage-4")
|
||||
|
||||
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
|
||||
await Embedder(model).embed_documents(["text"])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_an_openai_key_is_never_sent_to_voyage(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-secret")
|
||||
model = build_voyage_model("voyage-4")
|
||||
|
||||
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
|
||||
await Embedder(model).embed_documents(["text"])
|
||||
|
||||
|
||||
def test_env_form_routes_voyageai_through_the_adapter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
|
||||
|
||||
embedder = _build_embedder("voyageai:voyage-4")
|
||||
|
||||
assert isinstance(embedder.model, VoyageEmbeddingModel)
|
||||
assert embedder.model.model_name == "voyage-4"
|
||||
|
||||
|
||||
def test_config_push_form_routes_voyageai_through_the_adapter() -> None:
|
||||
embedder = _build_embedder("voyage-4", provider="voyageai", api_key="pa-pushed-key")
|
||||
|
||||
assert isinstance(embedder.model, VoyageEmbeddingModel)
|
||||
|
||||
|
||||
def test_the_voyageai_sdk_is_not_installed() -> None:
|
||||
"""Guards the ~207MB the SDK would add back."""
|
||||
with pytest.raises(ImportError):
|
||||
__import__("voyageai")
|
||||
|
||||
|
||||
# Live checks, skipped unless VOYAGE_API_KEY is set so CI stays offline.
|
||||
live_only = pytest.mark.skipif(
|
||||
not os.environ.get("VOYAGE_API_KEY"),
|
||||
reason="set VOYAGE_API_KEY to run the live VoyageAI checks",
|
||||
)
|
||||
|
||||
|
||||
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b, strict=True))
|
||||
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_returns_usable_embeddings() -> None:
|
||||
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert len(result.embeddings) == 2
|
||||
assert len(result.embeddings[0]) == 1024
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_honours_input_type_server_side() -> None:
|
||||
"""Voyage embeds the same text differently per input_type."""
|
||||
embedder = Embedder(build_voyage_model("voyage-4"))
|
||||
text = "How do I combine two PDFs?"
|
||||
|
||||
as_query = await embedder.embed_query(text)
|
||||
as_document = await embedder.embed_documents([text])
|
||||
|
||||
assert _cosine(as_query.embeddings[0], as_document.embeddings[0]) < 0.999
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_ranks_the_relevant_document_first() -> None:
|
||||
embedder = Embedder(build_voyage_model("voyage-4"))
|
||||
docs = await embedder.embed_documents(
|
||||
["Stirling PDF merges and splits PDF files.", "The capital of France is Paris."]
|
||||
)
|
||||
query = await embedder.embed_query("How do I combine two PDFs?")
|
||||
|
||||
relevant = _cosine(query.embeddings[0], docs.embeddings[0])
|
||||
irrelevant = _cosine(query.embeddings[0], docs.embeddings[1])
|
||||
assert relevant > irrelevant
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_accepts_voyage_only_parameters() -> None:
|
||||
"""output_dimension has no OpenAI equivalent, so this proves extra_body lands."""
|
||||
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(
|
||||
["dimension test"], settings={"extra_body": {"output_dimension": 256}}
|
||||
)
|
||||
|
||||
assert len(result.embeddings[0]) == 256
|
||||
Generated
+2
-1777
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,16 @@ import { QueryClient, type DefaultOptions } from "@tanstack/react-query";
|
||||
export const baseQueryOptions: DefaultOptions["queries"] = {
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
// Retry once on transient failures, but never on a 4xx: an auth/forbidden/not-found
|
||||
// response won't change on a second identical request, so retrying just doubles the
|
||||
// wait (e.g. a 403 firing twice) before the UI settles.
|
||||
retry: (failureCount, error) => {
|
||||
const status = (error as { status?: number } | null)?.status;
|
||||
if (typeof status === "number" && status >= 400 && status < 500) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 1;
|
||||
},
|
||||
networkMode: "always",
|
||||
refetchOnWindowFocus: false,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// SaaS enterprise flag: derived from the plan tier (wallet-backed), not a local
|
||||
// license bean. Shadows the self-hosted app-config version so Enterprise-only
|
||||
// surfaces (e.g. Infrastructure > Audit) unlock for enterprise-plan tenants.
|
||||
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import type { EnterpriseState } from "@portal-proprietary/hooks/useEnterpriseEnabled";
|
||||
|
||||
export type { EnterpriseState };
|
||||
|
||||
export function useEnterpriseEnabled(): EnterpriseState {
|
||||
const { tier } = useTier();
|
||||
return { enabled: tier === "enterprise", loading: false };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
@@ -7,6 +7,12 @@ vi.mock("react-i18next", () => ({
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
const enterprise = { enabled: true, loading: false };
|
||||
vi.mock("@portal/hooks/useEnterpriseEnabled", () => ({
|
||||
useEnterpriseEnabled: () => enterprise,
|
||||
}));
|
||||
|
||||
// Stub the live tab panels so the test doesn't pull their data dependencies.
|
||||
vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({
|
||||
ApiKeysTab: () => <div data-testid="api-keys-tab" />,
|
||||
@@ -18,6 +24,11 @@ vi.mock("@portal/components/infrastructure/AuditTab", () => ({
|
||||
import { Infrastructure } from "@portal/views/Infrastructure";
|
||||
|
||||
describe("Infrastructure (SaaS)", () => {
|
||||
beforeEach(() => {
|
||||
enterprise.enabled = true;
|
||||
enterprise.loading = false;
|
||||
});
|
||||
|
||||
it("defaults to the live API keys tab and drops the manage-editor button", () => {
|
||||
render(<Infrastructure />);
|
||||
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
|
||||
@@ -41,4 +52,14 @@ describe("Infrastructure (SaaS)", () => {
|
||||
}),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("disables the audit tab for non-enterprise tenants", () => {
|
||||
enterprise.enabled = false;
|
||||
render(<Infrastructure />);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /portal.infrastructure.tabs.audit/,
|
||||
}),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tabs, type TabItem } from "@app/ui";
|
||||
import { useEnterpriseEnabled } from "@portal/hooks/useEnterpriseEnabled";
|
||||
import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab";
|
||||
import { AuditTab } from "@portal/components/infrastructure/AuditTab";
|
||||
import "@portal/views/Infrastructure.css";
|
||||
@@ -21,6 +22,8 @@ type InfraTab =
|
||||
export function Infrastructure() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<InfraTab>("api-keys");
|
||||
// Audit is Enterprise-only; disabled (greyed, inert) for non-enterprise tenants.
|
||||
const auditEnabled = useEnterpriseEnabled().enabled;
|
||||
|
||||
const comingSoon = (labelKey: string) => (
|
||||
<>
|
||||
@@ -33,7 +36,11 @@ export function Infrastructure() {
|
||||
|
||||
const tabs: TabItem<InfraTab>[] = [
|
||||
{ key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") },
|
||||
{ key: "audit", label: t("portal.infrastructure.tabs.audit") },
|
||||
{
|
||||
key: "audit",
|
||||
label: t("portal.infrastructure.tabs.audit"),
|
||||
disabled: !auditEnabled,
|
||||
},
|
||||
{
|
||||
key: "deployments",
|
||||
label: comingSoon("portal.infrastructure.tabs.deployments"),
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
|
||||
type AuditFilter = "all" | AuditCategory;
|
||||
|
||||
// Enterprise-only: the Infrastructure view disables this tab for non-enterprise
|
||||
// instances, so this component only ever renders when entitled. The backend still
|
||||
// scopes the log to admins / team leads (403 -> forbidden state below).
|
||||
export function AuditTab() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Enterprise-license flag for the portal, from the backend app-config (`runningEE`).
|
||||
// Gates Enterprise-only surfaces (e.g. Infrastructure > Audit) so they show a locked
|
||||
// upsell instead of firing a doomed 403 request. The SaaS build shadows this file to
|
||||
// derive enterprise from the plan tier (wallet-backed) - see portal-saas.
|
||||
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
export interface EnterpriseState {
|
||||
enabled: boolean;
|
||||
// True while the flag is still resolving, so callers can hold rather than flash a lock.
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useEnterpriseEnabled(): EnterpriseState {
|
||||
const { config, loading } = useAppConfig();
|
||||
return { enabled: Boolean(config?.runningEE), loading };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
@@ -14,6 +14,11 @@ vi.mock("@portal/contexts/ViewContext", () => ({
|
||||
useView: () => ({ setActiveView: vi.fn() }),
|
||||
}));
|
||||
|
||||
const enterprise = { enabled: true, loading: false };
|
||||
vi.mock("@portal/hooks/useEnterpriseEnabled", () => ({
|
||||
useEnterpriseEnabled: () => enterprise,
|
||||
}));
|
||||
|
||||
vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({
|
||||
ApiKeysTab: () => <div data-testid="api-keys-tab" />,
|
||||
}));
|
||||
@@ -42,6 +47,11 @@ function tabButtons() {
|
||||
}
|
||||
|
||||
describe("Infrastructure view", () => {
|
||||
beforeEach(() => {
|
||||
enterprise.enabled = true;
|
||||
enterprise.loading = false;
|
||||
});
|
||||
|
||||
it("orders the working tabs first and defaults to API keys", () => {
|
||||
renderView();
|
||||
|
||||
@@ -91,4 +101,24 @@ describe("Infrastructure view", () => {
|
||||
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables the audit tab (inert, never opens) for non-enterprise users", () => {
|
||||
enterprise.enabled = false;
|
||||
renderView();
|
||||
|
||||
const auditBtn = screen.getByRole("button", { name: `${T}.audit` });
|
||||
expect(auditBtn).toBeDisabled();
|
||||
|
||||
fireEvent.click(auditBtn);
|
||||
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores a ?tab=audit deep link when not enterprise", () => {
|
||||
enterprise.enabled = false;
|
||||
renderView("/infrastructure?tab=audit");
|
||||
|
||||
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Tabs, type TabItem } from "@app/ui";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useEnterpriseEnabled } from "@portal/hooks/useEnterpriseEnabled";
|
||||
import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab";
|
||||
import { AuditTab } from "@portal/components/infrastructure/AuditTab";
|
||||
import "@portal/views/Infrastructure.css";
|
||||
@@ -12,30 +13,39 @@ type InfraTab = "api-keys" | "audit";
|
||||
/** Shown but inert: no backend behind these screens yet. */
|
||||
type DisabledInfraTab = "deployments" | "security" | "models" | "storage";
|
||||
|
||||
const ENABLED_TABS: InfraTab[] = ["api-keys", "audit"];
|
||||
|
||||
export function Infrastructure() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<InfraTab>("api-keys");
|
||||
const { setActiveView } = useView();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Audit is Enterprise-only; disabled (greyed, inert) on non-enterprise instances.
|
||||
const auditEnabled = useEnterpriseEnabled().enabled;
|
||||
|
||||
const canOpenTab = useCallback(
|
||||
(key: string) => key === "api-keys" || (key === "audit" && auditEnabled),
|
||||
[auditEnabled],
|
||||
);
|
||||
|
||||
// Deep-link (?tab=<key>) from elsewhere (e.g. the home visualiser's outcome
|
||||
// cards → audit log): open that tab, then drop the param.
|
||||
useEffect(() => {
|
||||
const requested = searchParams.get("tab");
|
||||
if (!requested) return;
|
||||
if ((ENABLED_TABS as string[]).includes(requested)) {
|
||||
if (canOpenTab(requested)) {
|
||||
setTab(requested as InfraTab);
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("tab");
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
}, [searchParams, setSearchParams, canOpenTab]);
|
||||
|
||||
const tabs: TabItem<InfraTab | DisabledInfraTab>[] = [
|
||||
{ key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") },
|
||||
{ key: "audit", label: t("portal.infrastructure.tabs.audit") },
|
||||
{
|
||||
key: "audit",
|
||||
label: t("portal.infrastructure.tabs.audit"),
|
||||
disabled: !auditEnabled,
|
||||
},
|
||||
{
|
||||
key: "deployments",
|
||||
label: t("portal.infrastructure.tabs.deployments"),
|
||||
@@ -78,7 +88,7 @@ export function Infrastructure() {
|
||||
items={tabs}
|
||||
activeKey={tab}
|
||||
onChange={(key) => {
|
||||
if ((ENABLED_TABS as string[]).includes(key)) setTab(key as InfraTab);
|
||||
if (canOpenTab(key)) setTab(key as InfraTab);
|
||||
}}
|
||||
variant="underline"
|
||||
ariaLabel={t("portal.infrastructure.sectionsAriaLabel")}
|
||||
|
||||
@@ -890,6 +890,11 @@ log "Setting permissions..."
|
||||
mkdir -p /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps /logs /configs /configs/heap_dumps /configs/cache /customFiles /pipeline /storage || true
|
||||
CHOWN_PATHS=("$HOME" "/logs" "/scripts" "/configs" "/customFiles" "/pipeline" "/storage" "/tmp/stirling-pdf" "/app.jar")
|
||||
[ -d /usr/share/fonts/truetype ] && CHOWN_PATHS+=("/usr/share/fonts/truetype")
|
||||
# Chowned here rather than at build time so it follows PUID/PGID remapping.
|
||||
if [ -d "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}" ]; then
|
||||
mkdir -p "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data" || true
|
||||
CHOWN_PATHS+=("${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data")
|
||||
fi
|
||||
CHOWN_OK=true
|
||||
for p in "${CHOWN_PATHS[@]}"; do
|
||||
if [ -e "$p" ]; then
|
||||
@@ -962,6 +967,38 @@ else
|
||||
JAVA_CMD+=("org.springframework.boot.loader.launch.JarLauncher")
|
||||
fi
|
||||
|
||||
# ---------- AI engine ----------
|
||||
# Only the fat image ships it. The backend already defaults to http://localhost:5001.
|
||||
STIRLING_ENGINE_HOME="${STIRLING_ENGINE_HOME:-/opt/stirling-engine}"
|
||||
ENGINE_PID=""
|
||||
if [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ] && [ "${AIENGINE_ENABLED:-true}" = "false" ]; then
|
||||
log "AI engine bundled but AIENGINE_ENABLED=false; not starting it."
|
||||
elif [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ]; then
|
||||
log "Starting bundled AI engine on port ${STIRLING_ENGINE_PORT:-5001}..."
|
||||
ENGINE_CMD=(
|
||||
"$STIRLING_ENGINE_HOME/.venv/bin/python" -m uvicorn
|
||||
stirling.api.app:app
|
||||
--host 127.0.0.1
|
||||
--port "${STIRLING_ENGINE_PORT:-5001}"
|
||||
--workers "${STIRLING_ENGINE_WORKERS:-2}"
|
||||
--app-dir "$STIRLING_ENGINE_HOME/src"
|
||||
)
|
||||
# init.sh exports PYTHONPATH for unoserver's 3.12 venv; inheriting it breaks the 3.13 engine.
|
||||
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
|
||||
env -u PYTHONPATH "${ENGINE_CMD[@]}" &
|
||||
elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then
|
||||
env -u PYTHONPATH \
|
||||
HOME="$(getent passwd "$RUNTIME_USER" | cut -d: -f6)" \
|
||||
USER="$RUNTIME_USER" \
|
||||
LOGNAME="$RUNTIME_USER" \
|
||||
setpriv --reuid="$RUNTIME_USER" --regid="$(id -gn "$RUNTIME_USER")" --init-groups -- "${ENGINE_CMD[@]}" &
|
||||
else
|
||||
env -u PYTHONPATH "${ENGINE_CMD[@]}" &
|
||||
fi
|
||||
ENGINE_PID=$!
|
||||
log "AI engine started (PID $ENGINE_PID)"
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
|
||||
"${JAVA_CMD[@]}" &
|
||||
elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then
|
||||
@@ -1074,6 +1111,12 @@ fi
|
||||
|
||||
wait "$JAVA_PID" || true
|
||||
exit_code=$?
|
||||
|
||||
if [ -n "$ENGINE_PID" ] && kill -0 "$ENGINE_PID" 2>/dev/null; then
|
||||
log "Stopping AI engine (PID $ENGINE_PID)..."
|
||||
kill "$ENGINE_PID" 2>/dev/null || true
|
||||
wait "$ENGINE_PID" 2>/dev/null || true
|
||||
fi
|
||||
case "$exit_code" in
|
||||
0) log "Stirling PDF exited normally." ;;
|
||||
137) log "Stirling PDF was OOM-killed (exit 137). Check container memory limits." ;;
|
||||
|
||||
Reference in New Issue
Block a user