commit 54c01271c58dd57f514222c48fdff9b0e005ce20 Author: 0xFEEDC0DE64 Date: Mon Oct 19 21:22:12 2026 +0200 Publish scanservjs-helm chart for upstream v3.3.0 diff --git a/.gitea/workflows/chart.yml b/.gitea/workflows/chart.yml new file mode 100644 index 0000000..478436a --- /dev/null +++ b/.gitea/workflows/chart.yml @@ -0,0 +1,74 @@ +name: Validate, publish and promote ScanservJS +on: + push: + pull_request: +jobs: + chart: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: azure/setup-helm@v4 + with: + version: v4.3.0 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Validate chart and scanner handoff + run: | + python3 -m venv "${RUNNER_TEMP}/chart-test" + "${RUNNER_TEMP}/chart-test/bin/pip" install PyYAML + PATH="${RUNNER_TEMP}/chart-test/bin:${PATH}" ./test.sh + - name: Select immutable chart version + if: gitea.ref == 'refs/heads/main' + run: printf 'CHART_VERSION=%s\n' "$(./scripts/chart-version.sh)" >> "${GITHUB_ENV}" + - name: Package and publish chart + if: gitea.ref == 'refs/heads/main' + env: + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "${PACKAGE_USERNAME}" && test -n "${PACKAGE_TOKEN}" + mkdir -p .chart-packages + helm package chart/scanservjs --destination .chart-packages --version "${CHART_VERSION}" + curl --fail --silent --show-error --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \ + --request POST --upload-file ".chart-packages/scanservjs-${CHART_VERSION}.tgz" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + - name: Check current main + id: current-main + if: gitea.ref == 'refs/heads/main' + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + git fetch --no-tags origin main + if test "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}"; then + echo 'promote=true' >> "${GITEA_OUTPUT}" + else + echo 'promote=false' >> "${GITEA_OUTPUT}" + fi + - uses: actions/checkout@v4 + if: steps.current-main.outputs.promote == 'true' + with: + repository: feedc0de/homelab-gitops + ref: main + token: ${{ secrets.GITOPS_TOKEN }} + path: homelab-gitops + - name: Promote chart + if: steps.current-main.outputs.promote == 'true' + working-directory: homelab-gitops + run: | + set -euo pipefail + ./scripts/update-chart-version.sh scanner "${CHART_VERSION}" + git config user.name feedc0de + git config user.email feedc0de@users.noreply.code.brunner.ninja + git add bootstrap/scanner.yaml + if git diff --cached --quiet; then exit 0; fi + git commit -m "Deploy scanner chart ${CHART_VERSION}" + for attempt in 1 2 3; do + git push origin HEAD:main && exit 0 + test "${attempt}" = 3 && exit 1 + git fetch origin main + git rebase origin/main + done diff --git a/README.md b/README.md new file mode 100644 index 0000000..63e2e4c --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# ScanservJS Helm chart + +This independent chart uses the upstream ScanservJS image and creates the +scanner Deployment, Service, ConfigMap, and optional Ingress. Its Git tag tracks +the tested upstream release (`v3.3.0`). The chart owns the `Send to Paperless` +action in `files/send-to-paperless.js`; users select the shared PVC, output and +consume subpaths, scanner eSCL address, and ingress host through Helm values. +No site-specific address or recipient is built into the chart. + +The action is a filesystem handoff, **not email**: it validates staged PDFs, +images, or image ZIPs, converts a ZIP to one PDF, atomically places the result +in the configured consume directory, and only then removes the staged file. +Give ScanservJS and Paperless access to the same claim. The homelab values are +in [homelab-gitops/apps/scanner](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/scanner). + +```sh +helm repo add brunner.ninja https://brunner.ninja/charts +helm repo update brunner.ninja +helm upgrade --install scanner brunner.ninja/scanservjs \ + --namespace paperless-ngx -f values.yaml --wait --timeout 10m +``` + +`./test.sh` checks the rendered topology and runs the real action with sample +PDF/JPEG files. `./install.sh values.yaml` installs from this checkout. CI +publishes and promotes the chart using `PACKAGE_USERNAME`, `PACKAGE_TOKEN`, +and `GITOPS_TOKEN` Actions secrets. diff --git a/chart/scanservjs/Chart.yaml b/chart/scanservjs/Chart.yaml new file mode 100644 index 0000000..999ca80 --- /dev/null +++ b/chart/scanservjs/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: scanservjs +description: ScanservJS with an optional filesystem handoff to Paperless-ngx +type: application +version: 0.0.0 +appVersion: 3.3.0 diff --git a/chart/scanservjs/files/send-to-paperless.js b/chart/scanservjs/files/send-to-paperless.js new file mode 100644 index 0000000..798787e --- /dev/null +++ b/chart/scanservjs/files/send-to-paperless.js @@ -0,0 +1,57 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +const output = process.env.SCAN_OUTPUT_DIR || '/var/lib/scanservjs/output'; +const consume = process.env.PAPERLESS_CONSUME_DIR || '/paperless-consume'; + +module.exports = { + actions: [{ + name: 'Send to Paperless', + async execute(fileInfo) { + const source = await fs.realpath(fileInfo.fullname); + const images = new Set(['.jpg', '.jpeg', '.png', '.tif', '.tiff', '.gif', '.webp']); + const extension = path.extname(source).toLowerCase(); + if (!source.startsWith(output + '/') || !(['.pdf', '.zip'].includes(extension) || images.has(extension))) { + throw new Error('Only staged PDFs, images, and image ZIPs can be sent'); + } + let upload = source; + let temp; + let name = path.basename(source); + let pending; + try { + if (extension === '.zip') { + const AdmZip = require(require.resolve('adm-zip', {paths: ['/usr/lib/scanservjs']})); + const entries = new AdmZip(source).getEntries(); + if (entries.length === 0 || entries.some(entry => + entry.isDirectory || entry.entryName !== path.basename(entry.entryName) || + !images.has(path.extname(entry.entryName).toLowerCase()))) { + throw new Error('ZIP must contain only scanned image pages'); + } + temp = await fs.mkdtemp('/tmp/paperless-scan-'); + const inputs = []; + for (const [index, entry] of entries.entries()) { + const input = path.join(temp, `${String(index).padStart(4, '0')}${path.extname(entry.entryName)}`); + await fs.writeFile(input, entry.getData()); + inputs.push(input); + } + upload = path.join(temp, 'merged.pdf'); + const {execFile} = require('node:child_process'); + const {promisify} = require('node:util'); + await promisify(execFile)('convert', [...inputs, upload]); + name = path.basename(source, path.extname(source)) + '.pdf'; + } + const destination = path.join(consume, `${Date.now()}-${crypto.randomBytes(6).toString('hex')}-${name}`); + pending = destination + '.part'; + await fs.copyFile(upload, pending); + await fs.rename(pending, destination); + await fs.unlink(source); + } catch (error) { + if (pending) await fs.rm(pending, {force: true}); + throw error; + } finally { + if (temp) await fs.rm(temp, {recursive: true, force: true}); + } + }, + }], +}; diff --git a/chart/scanservjs/templates/configmap.yaml b/chart/scanservjs/templates/configmap.yaml new file mode 100644 index 0000000..b17665e --- /dev/null +++ b/chart/scanservjs/templates/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: scanner-config +data: + config.local.js: | +{{ .Files.Get "files/send-to-paperless.js" | indent 4 }} diff --git a/chart/scanservjs/templates/deployment.yaml b/chart/scanservjs/templates/deployment.yaml new file mode 100644 index 0000000..d6bb8cc --- /dev/null +++ b/chart/scanservjs/templates/deployment.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: scanner +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: Recreate + selector: + matchLabels: {app: scanner} + template: + metadata: + labels: {app: scanner} + annotations: + checksum/config: {{ .Files.Get "files/send-to-paperless.js" | sha256sum }} + spec: + automountServiceAccountToken: false + securityContext: + supplementalGroups: [1000] + initContainers: + - name: prepare-output + image: {{ printf "%s:%s" .Values.initImage.repository .Values.initImage.tag | quote }} + command: [sh, -c, 'mkdir -p /volume/{{ .Values.scanner.outputSubPath }} && chown 102:100 /volume/{{ .Values.scanner.outputSubPath }} && chmod 775 /volume/{{ .Values.scanner.outputSubPath }} && chmod g+w /volume/{{ .Values.scanner.consumeSubPath }}'] + volumeMounts: + - {name: data, mountPath: /volume} + containers: + - name: scanner + image: {{ printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: [sh, -c, "sed -i '/^\\[options\\]/a discovery = disable' /etc/sane.d/airscan.conf && exec /entrypoint.sh"] + env: + - {name: AIRSCAN_DEVICES, value: {{ .Values.scanner.airscanDevices | quote }}} + - {name: SCAN_OUTPUT_DIR, value: {{ .Values.scanner.outputDirectory | quote }}} + - {name: PAPERLESS_CONSUME_DIR, value: {{ .Values.scanner.consumeDirectory | quote }}} + ports: [{name: http, containerPort: 8080}] + volumeMounts: + - {name: data, mountPath: {{ .Values.scanner.outputDirectory }}, subPath: {{ .Values.scanner.outputSubPath }}} + - {name: data, mountPath: {{ .Values.scanner.consumeDirectory }}, subPath: {{ .Values.scanner.consumeSubPath }}} + - {name: config, mountPath: /etc/scanservjs/config.local.js, subPath: config.local.js, readOnly: true} + startupProbe: + httpGet: {path: /, port: http} + periodSeconds: 5 + failureThreshold: 60 + readinessProbe: + httpGet: {path: /, port: http} + periodSeconds: 10 + livenessProbe: + httpGet: {path: /, port: http} + periodSeconds: 30 + resources: +{{ toYaml .Values.resources | indent 12 }} + volumes: + - name: data + persistentVolumeClaim: {claimName: {{ .Values.persistence.existingClaim }}} + - name: config + configMap: {name: scanner-config} diff --git a/chart/scanservjs/templates/ingress.yaml b/chart/scanservjs/templates/ingress.yaml new file mode 100644 index 0000000..ed6e991 --- /dev/null +++ b/chart/scanservjs/templates/ingress.yaml @@ -0,0 +1,24 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: scanner + {{- with .Values.ingress.annotations }} + annotations: +{{ toYaml . | indent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: scanner + port: {name: http} +{{- end }} diff --git a/chart/scanservjs/templates/service.yaml b/chart/scanservjs/templates/service.yaml new file mode 100644 index 0000000..eec374c --- /dev/null +++ b/chart/scanservjs/templates/service.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Service +metadata: + name: scanner +spec: + selector: {app: scanner} + ports: [{name: http, port: 8080, targetPort: http}] diff --git a/chart/scanservjs/values.schema.json b/chart/scanservjs/values.schema.json new file mode 100644 index 0000000..a8161db --- /dev/null +++ b/chart/scanservjs/values.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "persistence": { + "type": "object", + "required": ["existingClaim"], + "properties": {"existingClaim": {"type": "string", "minLength": 1}} + }, + "scanner": { + "type": "object", + "properties": { + "airscanDevices": {"type": "string"}, + "outputSubPath": {"type": "string", "minLength": 1}, + "consumeSubPath": {"type": "string", "minLength": 1} + } + } + } +} diff --git a/chart/scanservjs/values.yaml b/chart/scanservjs/values.yaml new file mode 100644 index 0000000..0b3aae4 --- /dev/null +++ b/chart/scanservjs/values.yaml @@ -0,0 +1,22 @@ +image: + repository: sbs20/scanservjs + tag: "" + pullPolicy: IfNotPresent +initImage: + repository: docker.io/library/busybox + tag: 1.38.0 +replicaCount: 1 +scanner: + airscanDevices: "" + outputSubPath: scanner-staging + consumeSubPath: consume + outputDirectory: /var/lib/scanservjs/output + consumeDirectory: /paperless-consume +persistence: + existingClaim: "" +ingress: + enabled: false + className: "" + host: "" + annotations: {} +resources: {} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8da52f4 --- /dev/null +++ b/install.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")" +values=${1:?usage: ./install.sh VALUES_FILE} +./test.sh +helm upgrade --install scanner chart/scanservjs --namespace paperless-ngx --create-namespace --values "$values" --wait --timeout 10m diff --git a/scripts/chart-version.sh b/scripts/chart-version.sh new file mode 100755 index 0000000..5875433 --- /dev/null +++ b/scripts/chart-version.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +description=$(git describe --tags --long --match 'v[0-9]*' --dirty) +case "$description" in *-dirty) echo 'Refusing to package a dirty tree' >&2; exit 1 ;; esac +version=${description#v} +case "$version" in *-0-g*) version=${version%%-0-g*} ;; esac +printf '%s\n' "$version" diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..c44c479 --- /dev/null +++ b/test.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")" +helm lint --strict chart/scanservjs -f values.example.yaml +helm template scanner chart/scanservjs -n example -f values.example.yaml | python3 -c ' +import os, pathlib, subprocess, sys, tempfile, yaml +objects=[x for x in yaml.safe_load_all(sys.stdin) if x] +one=lambda kind,name: next(x for x in objects if x["kind"]==kind and x["metadata"]["name"]==name) +d=one("Deployment","scanner") +s=one("Service","scanner") +assert d["spec"]["selector"]["matchLabels"]==s["spec"]["selector"] +assert d["spec"]["template"]["spec"]["volumes"][0]["persistentVolumeClaim"]["claimName"]=="document-storage" +assert all(k in d["spec"]["template"]["spec"]["containers"][0] for k in ("startupProbe","readinessProbe","livenessProbe")) +code=one("ConfigMap","scanner-config")["data"]["config.local.js"] +with tempfile.TemporaryDirectory() as temp: + root=pathlib.Path(temp); script=root/"config.local.js"; script.write_text(code) + output=root/"staged"; output.mkdir(); consume=root/"consume"; consume.mkdir() + env={**os.environ,"SCAN_OUTPUT_DIR":str(output),"PAPERLESS_CONSUME_DIR":str(consume)} + subprocess.run(["node","--check",str(script)],check=True) + for name,contents in (("test.pdf",b"%PDF-1.4\nscan\n"),("test.jpg",b"\xff\xd8\xffscan\xff\xd9")): + source=output/name; source.write_bytes(contents) + subprocess.run(["node","-e","require(process.argv[1]).actions[0].execute({fullname:process.argv[2]}).catch(e=>{console.error(e);process.exit(1)})",str(script),str(source)],check=True,env=env) + delivered=list(consume.glob("*"+name)) + assert len(delivered)==1 and delivered[0].read_bytes()==contents + assert not source.exists() and not list(consume.glob("*.part")) +' diff --git a/values.example.yaml b/values.example.yaml new file mode 100644 index 0000000..c82db6d --- /dev/null +++ b/values.example.yaml @@ -0,0 +1,12 @@ +persistence: + existingClaim: document-storage +scanner: + airscanDevices: '"Office Scanner" = http://192.0.2.10:8080/eSCL, eSCL' +ingress: + enabled: true + className: traefik + host: scanner.example.com +resources: + requests: + cpu: 100m + memory: 256Mi