Publish scanservjs-helm chart for upstream v3.3.0
Validate, publish and promote ScanservJS / chart (push) Successful in 50s
Validate, publish and promote ScanservJS / chart (push) Successful in 50s
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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});
|
||||
}
|
||||
},
|
||||
}],
|
||||
};
|
||||
@@ -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 }}
|
||||
@@ -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}
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: scanner
|
||||
spec:
|
||||
selector: {app: scanner}
|
||||
ports: [{name: http, port: 8080, targetPort: http}]
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: {}
|
||||
Executable
+6
@@ -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
|
||||
Executable
+7
@@ -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"
|
||||
@@ -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"))
|
||||
'
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user