Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b659c965 | ||
|
|
19824a176a | ||
|
|
eec1f36c44 | ||
|
|
7ef86e3948 | ||
|
|
4c723015ed | ||
|
|
a467e2925c | ||
|
|
6b6258aa5e | ||
|
|
d071be1c8a | ||
|
|
47e3d30870 | ||
|
|
b3278290c2 | ||
|
|
66a9e5ff52 | ||
|
|
b0b1527247 | ||
|
|
aa1af65807 | ||
|
|
da11c1cb5e | ||
|
|
784c0d460a | ||
|
|
19dbd05783 | ||
|
|
a358e8cfd5 | ||
|
|
b8116a1cf0 | ||
|
|
3563a9e67e | ||
|
|
088fce90a4 | ||
|
|
9ee8cfb4bc | ||
|
|
325baf7047 | ||
|
|
a667920bb0 | ||
|
|
53fe8efa02 | ||
|
|
e0a00f6b05 | ||
|
|
1ab901d746 | ||
|
|
18d3f3c850 |
@@ -120,7 +120,14 @@ spec:
|
||||
server:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
# 500m, not 200m: the HPA below targets 70% of *this* request, so at
|
||||
# 200m the scale-up threshold sat at 140m — below the pod's own
|
||||
# ordinary peak. Measured over 7d, a warm server pod p99s at ~108m
|
||||
# and peaks at ~190m, and a freshly-started one burns ~300m booting
|
||||
# Django. That made the HPA oscillate 2<->3<->4 all day and pin at
|
||||
# maxReplicas (KubeHpaMaxedOut, issue #761). At 500m the threshold
|
||||
# is 350m/pod, above the boot burst, so only real load scales out.
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
@@ -154,7 +161,11 @@ spec:
|
||||
worker:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
# Same reasoning as server above, and the worker is burstier still:
|
||||
# over 7d its per-pod p99 reaches ~336m and peaks hit ~630m while
|
||||
# running blueprint applies and scheduled tasks. 200m understated
|
||||
# that badly enough that routine task batches read as saturation.
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
|
||||
@@ -95,7 +95,7 @@ spec:
|
||||
memory: 32Mi
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.9.0 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.9.1 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
|
||||
@@ -60,7 +60,7 @@ spec:
|
||||
# Two processes rather than one: socat carries a single address pair per
|
||||
# invocation, and the families need different receive and send addresses.
|
||||
# They are independent — one crashing does not blind the other — and each
|
||||
# is restarted by the kubelet on its own.
|
||||
# carries its own supervisor loop.
|
||||
containers:
|
||||
# -u is load-bearing on both: unidirectional, left to right. Without it
|
||||
# socat would also carry HA's replies back out, and this must stay a
|
||||
@@ -77,17 +77,29 @@ spec:
|
||||
# UDP4-SENDTO takes its A record and UDP6-SENDTO its AAAA. See
|
||||
# mdns-relay-service.yaml for why that indirection is required and why
|
||||
# the Service is dual-stack.
|
||||
#
|
||||
# Supervisor loop: socat exits 1 on the first datagram it cannot
|
||||
# deliver, and the ClusterIP has no backend while HA restarts. The
|
||||
# trap forwards SIGTERM so the pod still terminates promptly.
|
||||
- name: socat-ipv4
|
||||
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- -u
|
||||
- "UDP4-RECV:5353,ip-add-membership=224.0.0.251:net1,so-bindtodevice=net1,reuseaddr"
|
||||
- "UDP4-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
|
||||
# No probes. socat is a single process with no health surface: if it
|
||||
# exits, the container exits and the kubelet restarts it, which is the
|
||||
# entire liveness story. A probe here could only re-assert that PID 1
|
||||
# is running.
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
trap 'kill "$pid"; exit 0' TERM INT
|
||||
while :; do
|
||||
socat -u \
|
||||
"UDP4-RECV:5353,ip-add-membership=224.0.0.251:net1,so-bindtodevice=net1,reuseaddr" \
|
||||
"UDP4-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353" &
|
||||
pid=$!
|
||||
wait "$pid"
|
||||
echo "mdns-relay: socat-ipv4 exited ($?); restarting in 2s" >&2
|
||||
sleep 2
|
||||
done
|
||||
# No probes. The supervisor loop is the liveness story; a probe here
|
||||
# could only re-assert that PID 1 is running.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
@@ -114,10 +126,20 @@ spec:
|
||||
- name: socat-ipv6
|
||||
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- -u
|
||||
- "UDP6-RECV:5353,ipv6-join-group=[ff02::fb]:net1,so-bindtodevice=net1,ipv6-v6only=1,reuseaddr"
|
||||
- "UDP6-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
trap 'kill "$pid"; exit 0' TERM INT
|
||||
while :; do
|
||||
socat -u \
|
||||
"UDP6-RECV:5353,ipv6-join-group=[ff02::fb]:net1,so-bindtodevice=net1,ipv6-v6only=1,reuseaddr" \
|
||||
"UDP6-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353" &
|
||||
pid=$!
|
||||
wait "$pid"
|
||||
echo "mdns-relay: socat-ipv6 exited ($?); restarting in 2s" >&2
|
||||
sleep 2
|
||||
done
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
|
||||
@@ -25,6 +25,13 @@ metadata:
|
||||
app.kubernetes.io/name: llama-server
|
||||
spec:
|
||||
replicas: 1
|
||||
# The startupProbe below sanctions a 40 min cold download, but the default
|
||||
# progressDeadlineSeconds is 600. Left implicit the Deployment declares
|
||||
# ProgressDeadlineExceeded at 10 minutes while the probe is still legitimately
|
||||
# waiting, and KubeDeploymentRolloutStuck fires 15 min after that on a rollout
|
||||
# that is fine. Kept above the startup budget, with room for the image pull
|
||||
# and scheduling that happen before the first probe.
|
||||
progressDeadlineSeconds: 3000
|
||||
# A rolling update would load a second copy of the weights onto the same
|
||||
# iGPU and into the same node's RAM before releasing the first.
|
||||
strategy:
|
||||
|
||||
@@ -19,6 +19,11 @@ metadata:
|
||||
app.kubernetes.io/name: llama-embed
|
||||
spec:
|
||||
replicas: 1
|
||||
# Same reasoning as llama-server: the startupProbe budget (600s) must stay
|
||||
# below progressDeadlineSeconds, whose default is also 600 — exactly equal, so
|
||||
# any cold download that uses most of its budget trips
|
||||
# KubeDeploymentRolloutStuck on a rollout that is still progressing normally.
|
||||
progressDeadlineSeconds: 900
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
|
||||
@@ -58,10 +58,30 @@ spec:
|
||||
# VeleroBackupFailed alert fires on the first failure regardless — so
|
||||
# nothing is hidden, only the escalation to critical is deferred
|
||||
# until a second cycle confirms it.
|
||||
#
|
||||
# The `and on(schedule) velero_backup_last_status` gate exists because
|
||||
# velero_backup_last_successful_timestamp is an in-process gauge that
|
||||
# velero does NOT retract when a Schedule is deleted: the series just
|
||||
# stops being updated, ages past 60h and pages critical forever, for a
|
||||
# schedule that no longer exists. That is what decommissioning
|
||||
# netbird-backup-daily did on 2026-09-01.
|
||||
#
|
||||
# velero_backup_last_status is the same shape but IS retracted on
|
||||
# Schedule deletion, so it works as a liveness set for "this schedule
|
||||
# still exists". It hides nothing: velero repopulates it for every
|
||||
# existing Schedule on its first scrape after a restart (verified
|
||||
# across the three pods of the 2026-08-27 rollout), and it is written
|
||||
# on failed runs too (0) as well as successful ones (1) — so a
|
||||
# schedule that exists and is failing, or that exists and has stopped
|
||||
# firing, still carries the series and still alerts.
|
||||
expr: |
|
||||
time() - velero_backup_last_successful_timestamp{schedule!=""} > 60 * 3600
|
||||
and
|
||||
velero_backup_last_successful_timestamp{schedule!=""} > 0
|
||||
(
|
||||
time() - velero_backup_last_successful_timestamp{schedule!=""} > 60 * 3600
|
||||
and
|
||||
velero_backup_last_successful_timestamp{schedule!=""} > 0
|
||||
)
|
||||
and on(schedule)
|
||||
velero_backup_last_status
|
||||
for: 10m
|
||||
labels:
|
||||
severity: critical
|
||||
|
||||
@@ -55,7 +55,7 @@ spec:
|
||||
# stdlib only; Flux keeps the tag in lockstep via the shared image
|
||||
# policy) — no extra dependency.
|
||||
- name: reconcile-publish-ip
|
||||
image: ghcr.io/music-assistant/server:2.10.1 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.2 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
@@ -65,7 +65,7 @@ spec:
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: music-assistant
|
||||
image: ghcr.io/music-assistant/server:2.10.1 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.2 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
env:
|
||||
- name: LOG_LEVEL
|
||||
value: info
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
# A read-only view of paperless's backup store, used as a recovery source and
|
||||
# nothing else.
|
||||
#
|
||||
# It points at the production prefix, which is the whole point — the eval
|
||||
# instance is a copy of the real archive's metadata — and that makes what is
|
||||
# ABSENT here the safety-critical part:
|
||||
#
|
||||
# no retentionPolicy The production store has "30d". Without one here
|
||||
# there is no policy to enforce, and enforcement is not
|
||||
# tied to taking a backup: the plugin runs it from the
|
||||
# instance sidecar on a timer
|
||||
# (instanceSidecarConfiguration.retentionPolicyIntervalSeconds,
|
||||
# default 1800). So "this cluster never backs up" would
|
||||
# not have been enough on its own.
|
||||
#
|
||||
# A second property makes it belt-and-braces. Retention runs
|
||||
# barman-cloud-backup-delete, which takes a server name and works inside
|
||||
# <destinationPath>/<serverName> — the store's own status keys its recovery
|
||||
# window by "pprl-pg", confirming that layout. This cluster is pprleval-pg, so
|
||||
# even an accidental retention policy would address a prefix that holds nothing
|
||||
# rather than production's backups.
|
||||
#
|
||||
# The cluster below completes the guarantee with isWALArchiver: false, so
|
||||
# nothing is written back either. It does load the plugin — that is required
|
||||
# for the restore to work at all — and read-only comes from the flag rather
|
||||
# than from leaving the plugin out.
|
||||
apiVersion: barmancloud.cnpg.io/v1
|
||||
kind: ObjectStore
|
||||
metadata:
|
||||
name: pprleval-pg-source
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
configuration:
|
||||
destinationPath: s3://renner-uno-k8s-cnpg/pprl-pg
|
||||
endpointURL: https://fsn1.your-objectstorage.com
|
||||
s3Credentials:
|
||||
accessKeyId:
|
||||
name: hetzner-s3-creds
|
||||
key: ACCESS_KEY_ID
|
||||
secretAccessKey:
|
||||
name: hetzner-s3-creds
|
||||
key: ACCESS_SECRET_KEY
|
||||
region:
|
||||
name: hetzner-s3-creds
|
||||
key: REGION
|
||||
wal:
|
||||
compression: gzip
|
||||
data:
|
||||
compression: gzip
|
||||
---
|
||||
# A disposable clone of the paperless database, for measuring how well the
|
||||
# local LLM classifies documents without running the experiment against the
|
||||
# live archive.
|
||||
#
|
||||
# Losing this cluster costs a re-restore and nothing else, which is the intent
|
||||
# — it is rebuilt whenever a test needs a clean starting point.
|
||||
#
|
||||
# One instance, not two. Replication buys availability, and an eval instance
|
||||
# that is down is simply an eval that waits.
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
name: pprleval-pg
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
instances: 1
|
||||
imageName: ghcr.io/cloudnative-pg/postgresql:16
|
||||
enableSuperuserAccess: true
|
||||
storage:
|
||||
# Delete-reclaim, unlike the Retain default: this volume is a copy that
|
||||
# gets rebuilt, so it should vanish with the cluster rather than leave a
|
||||
# Released PV and an orphaned RBD image behind (issue #262).
|
||||
storageClass: ceph-block-ephemeral
|
||||
size: 10Gi
|
||||
# Declared with isWALArchiver: false, which is what kubernetes/RECOVERY.md
|
||||
# arrived at after an earlier version of that runbook said to omit it: the
|
||||
# instance manager loads this plugin to replay WAL during the restore, and
|
||||
# without it the instance never starts ("Unknown plugin:
|
||||
# barman-cloud.cloudnative-pg.io").
|
||||
#
|
||||
# externalClusters below only says WHERE the backup is; it does not get the
|
||||
# plugin injected into the pod. So "no plugin" is not the way to keep this
|
||||
# read-only — "plugin, not archiving" is, and it is the combination verified
|
||||
# on im-pg on 2026-07-25.
|
||||
plugins:
|
||||
- name: barman-cloud.cloudnative-pg.io
|
||||
enabled: true
|
||||
isWALArchiver: false
|
||||
parameters:
|
||||
barmanObjectName: pprleval-pg-source
|
||||
bootstrap:
|
||||
recovery:
|
||||
source: pprl-pg
|
||||
database: paperless
|
||||
owner: paperless
|
||||
externalClusters:
|
||||
- name: pprl-pg
|
||||
plugin:
|
||||
name: barman-cloud.cloudnative-pg.io
|
||||
parameters:
|
||||
barmanObjectName: pprleval-pg-source
|
||||
serverName: pprl-pg
|
||||
postgresql:
|
||||
parameters:
|
||||
shared_buffers: "256MB"
|
||||
max_connections: "100"
|
||||
# The production tuning for write reduction is deliberately not copied.
|
||||
# It exists to spare the SSDs under a database that runs for years; this
|
||||
# one is read-only for hours at a time and then deleted.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
@@ -1,13 +0,0 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: paperless-eval
|
||||
# The instance only. The measurement harness lives in scripts/paperless-eval/
|
||||
# and is applied by hand — a Job is immutable, re-running one means deleting and
|
||||
# recreating it, and Flux would just put the old one back. Nothing here should
|
||||
# converge on a finished measurement.
|
||||
resources:
|
||||
- secrets.yaml
|
||||
- cluster.yaml
|
||||
- pvcs.yaml
|
||||
- redis.yaml
|
||||
- paperless.yaml
|
||||
@@ -1,203 +0,0 @@
|
||||
---
|
||||
# Deliberately much smaller than the production ConfigMap. Everything that
|
||||
# exists to serve people is gone: no remote-user auth, no CSRF origins, no
|
||||
# barcode or mail consumption, no Tika/Gotenberg. Nothing consumes documents
|
||||
# here, and nothing signs in.
|
||||
#
|
||||
# What remains is the database connection and the AI settings, because those
|
||||
# are what the evaluation measures.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: paperless-eval-config
|
||||
namespace: paperless-eval
|
||||
data:
|
||||
PAPERLESS_URL: "http://paperless.paperless-eval.svc.cluster.local"
|
||||
PAPERLESS_ALLOWED_HOSTS: "*"
|
||||
PAPERLESS_TIME_ZONE: "Europe/Berlin"
|
||||
PAPERLESS_REDIS: "redis://redis:6379"
|
||||
PAPERLESS_DBENGINE: "postgresql"
|
||||
PAPERLESS_DBHOST: "pprleval-pg-rw"
|
||||
PAPERLESS_DBNAME: "paperless"
|
||||
# Polling off, so a pod that finds an empty consume volume does not spend a
|
||||
# thread looking at it every ten seconds for the length of a run.
|
||||
PAPERLESS_CONSUMER_POLLING_INTERVAL: "0"
|
||||
|
||||
# The AI settings under test. The endpoints are placeholders that each eval
|
||||
# job overrides for the model it is measuring — a job runs its own
|
||||
# llama-server beside paperless in the same pod, so the value that matters at
|
||||
# run time is the one in the job, not the one here.
|
||||
#
|
||||
# Same caveat as production, and sharper here: paperless reads these from an
|
||||
# ApplicationConfiguration row and falls back to the environment only when
|
||||
# that row is empty. The row comes across in the restored database, so
|
||||
# anything configured in the production admin UI arrives with it and silently
|
||||
# wins over this ConfigMap — an eval would then measure production's settings
|
||||
# while appearing to measure the job's.
|
||||
#
|
||||
# Check it before trusting any result, and after every fresh restore:
|
||||
#
|
||||
# select llm_backend, llm_model, llm_embedding_backend, llm_embedding_model
|
||||
# from paperless_applicationconfiguration;
|
||||
#
|
||||
# Every column must be null for the environment to apply.
|
||||
PAPERLESS_AI_ENABLED: "true"
|
||||
PAPERLESS_AI_LLM_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_ENDPOINT: "http://127.0.0.1:8080/v1"
|
||||
PAPERLESS_AI_LLM_MODEL: "qwen3.6-35b-a3b"
|
||||
PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS: "true"
|
||||
PAPERLESS_AI_LLM_CONTEXT_SIZE: "12288"
|
||||
PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "900"
|
||||
PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: "de-de"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: "http://llama-embed.ai.svc.cluster.local:8080/v1"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_MODEL: "bge-m3"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
|
||||
---
|
||||
# A base instance that proves the restored database is usable and that the
|
||||
# search index and classifier build against it. The evaluation itself does not
|
||||
# run here — each job brings its own paperless container so it can point at its
|
||||
# own model — but this is what makes a broken clone visible before a job spends
|
||||
# hours on it.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: paperless
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
app.kubernetes.io/name: paperless
|
||||
annotations:
|
||||
configmap.reloader.stakater.com/reload: paperless-eval-config
|
||||
# Both, like production: the secret refreshes on the same 10m cycle, and
|
||||
# without this the pod would keep a rotated key until something else
|
||||
# restarted it — which defeats the point of matching the interval.
|
||||
secret.reloader.stakater.com/reload: paperless-eval-secrets
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperless
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: paperless
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: paperless
|
||||
# No $imagepolicy marker, unlike apps/paperless: a measurement is
|
||||
# only comparable against a fixed version, so this tag moves when
|
||||
# somebody decides to re-measure, not when a release lands.
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.2
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: paperless-eval-config
|
||||
env:
|
||||
- name: PAPERLESS_SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: paperless-eval-secrets
|
||||
key: secret-key
|
||||
- name: PAPERLESS_DBUSER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pprleval-pg-app
|
||||
key: username
|
||||
- name: PAPERLESS_DBPASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pprleval-pg-app
|
||||
key: password
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /usr/src/paperless/data
|
||||
- name: media
|
||||
mountPath: /usr/src/paperless/media
|
||||
- name: consume
|
||||
mountPath: /usr/src/paperless/consume
|
||||
# 60 minutes, against production's 10, and matching this app's Flux
|
||||
# Kustomization timeout.
|
||||
#
|
||||
# Production's budget was sized for a from-scratch boot on this same
|
||||
# corpus and held, so 10 would probably do — but there the volume is
|
||||
# Retain and the index survives, making that a one-off. Here the
|
||||
# volume is ephemeral and the index is rebuilt on every re-test, so
|
||||
# the expensive path is the normal one. A killed pod starts the work
|
||||
# over rather than resuming, which turns a slow boot into a
|
||||
# crash-loop that stalls Flux behind wait: true.
|
||||
#
|
||||
# The LLM index is not in this budget: it is a celery task, and boot
|
||||
# only runs init-llmindex-migrate, a migration check.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 180
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
initialDelaySeconds: 120
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
# Production's context verbatim, and it has to be: the paperless-ngx
|
||||
# entrypoint starts as root, chowns the mounted data/media/consume
|
||||
# volumes and then drops to uid 1000 via setgid/setuid. Without these
|
||||
# the same image fails that sequence with EPERM.
|
||||
#
|
||||
# It bites harder here than in production, where those volumes were
|
||||
# chowned once years ago and keep their ownership. These are fresh on
|
||||
# every rebuild, so the chown is not a no-op.
|
||||
securityContext:
|
||||
runAsNonRoot: false
|
||||
allowPrivilegeEscalation: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-eval-golden
|
||||
- name: media
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-eval-media
|
||||
- name: consume
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-eval-consume
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: paperless
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: paperless
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: http
|
||||
@@ -1,129 +0,0 @@
|
||||
---
|
||||
# All four use ceph-block-ephemeral rather than the Retain default. Everything
|
||||
# here is derived — a restored database, an index built from it, a cache — so
|
||||
# the volumes should go when the namespace does instead of leaving Released PVs
|
||||
# and orphaned RBD images to reclaim by hand (issue #262).
|
||||
#
|
||||
# /data holds the Tantivy search index and the classifier pickle, both rebuilt
|
||||
# from the database, and the LLM index — which is the expensive one at roughly
|
||||
# three hours for the full corpus.
|
||||
#
|
||||
# Measured on production for the same ~3900 documents: 36 MiB search index,
|
||||
# 125 MiB LLM index. 10Gi is far more than that needs, and deliberately so —
|
||||
# an embedding model with wider vectors than bge-m3's 1024 dimensions is
|
||||
# exactly what phase two varies, and running out of space mid-run would cost
|
||||
# the whole rebuild.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
# Named for its role rather than its mount point: the smoke-test deployment
|
||||
# builds /data here, and every eval job copies it out. Replaces the earlier
|
||||
# RWO paperless-eval-data, whose access mode could not be widened in place —
|
||||
# a bound claim's spec is immutable.
|
||||
name: paperless-eval-golden
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
# RWX, because the eval jobs read it too — each copies it into its own
|
||||
# emptyDir rather than mounting it live. The vector store is SQLite, and
|
||||
# SQLite locking across pods over CephFS is not something to rely on.
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem-ephemeral
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
# Empty, and stays that way. Paperless refuses to start without the path, but
|
||||
# the AI path reads title, content and filename out of the database and never
|
||||
# opens a file — so the documents themselves are not copied here. That is what
|
||||
# keeps this instance cheap and keeps a second copy of the archive off the
|
||||
# cluster.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperless-eval-media
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block-ephemeral
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
# Also empty: nothing is consumed here, but the path has to exist.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperless-eval-consume
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block-ephemeral
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
# The eval jobs' model cache, RWX so several jobs can read the same weights at
|
||||
# once — that is the whole point of measuring two candidates in parallel.
|
||||
#
|
||||
# Separate from the ai namespace's llama-server-models despite holding some of
|
||||
# the same files: PVCs do not cross namespaces. A model therefore downloads
|
||||
# once here and is then shared by every job, which is the cost a new candidate
|
||||
# carries anyway.
|
||||
#
|
||||
# NOT ephemeral, unlike everything else in this namespace: re-downloading tens
|
||||
# of gigabytes on a slow uplink is a worse outcome than an orphaned volume.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: llama-models
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 120Gi
|
||||
---
|
||||
# Results outlive the jobs that produce them: a Job is deleted and recreated to
|
||||
# re-run a configuration, and comparing configurations is the entire exercise.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperless-eval-results
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperless-eval-redis
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block-ephemeral
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
app.kubernetes.io/name: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: redis
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
fsGroup: 999
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: redis
|
||||
# No $imagepolicy marker; see the note on the paperless image.
|
||||
image: redis:8.10.1-alpine
|
||||
# No --save: this holds the celery queue and the suggestion cache,
|
||||
# both of which are regenerated. Persisting them across a restart of
|
||||
# a throwaway instance would only preserve stale suggestions.
|
||||
args:
|
||||
- redis-server
|
||||
- --save
|
||||
- ""
|
||||
- --loglevel
|
||||
- warning
|
||||
ports:
|
||||
- name: redis
|
||||
containerPort: 6379
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: redis
|
||||
initialDelaySeconds: 10
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 20m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-eval-redis
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: redis
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
# The same Hetzner S3 key every CNPG-using namespace pulls, needed here only so
|
||||
# the recovery source can be read. See apps/paperless for why one shared key
|
||||
# rather than per-app paths.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hetzner-s3-creds
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: hetzner-s3-creds
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: infrastructure/hetzner-s3
|
||||
---
|
||||
# Django's secret key, shared with the production instance rather than given
|
||||
# its own vault entry.
|
||||
#
|
||||
# It signs sessions and CSRF tokens, and this instance has no ingress and no
|
||||
# users — nothing signs in, so nothing is at stake in sharing it. A separate
|
||||
# entry would mean a vault write before the namespace can start, for a
|
||||
# throwaway instance that gets rebuilt.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: paperless-eval-secrets
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
# 10m, matching apps/paperless rather than the 1h default: the point of
|
||||
# sharing the key is that the two hold the same value, and a slower refresh
|
||||
# here would let them diverge for up to an hour after a rotation.
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: paperless-eval-secrets
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/paperless
|
||||
@@ -170,7 +170,7 @@ spec:
|
||||
drop: ["ALL"]
|
||||
containers:
|
||||
- name: paperless
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.2 # {"$imagepolicy": "flux-system:paperless-ngx"}
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.3 # {"$imagepolicy": "flux-system:paperless-ngx"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8000
|
||||
@@ -325,7 +325,6 @@ spec:
|
||||
- port: 80
|
||||
targetPort: http
|
||||
---
|
||||
---
|
||||
# ExternalName mirror so the paperless-namespace Ingress can backend to
|
||||
# the gate outpost in the authentik namespace (cross-namespace Service
|
||||
# refs aren't supported in standard Ingress).
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# trmnl
|
||||
|
||||
Terminus (self-hosted TRMNL BYOS server) plus the headless renderer that
|
||||
screenshots the Home Assistant entrance dashboard and pushes it in. One
|
||||
device: a TRMNL X at the entrance, 1872x1404, 16 greys.
|
||||
|
||||
## Device firmware
|
||||
|
||||
Terminus mirrors one model-agnostic binary and it is the **ESP32-C3** (OG)
|
||||
build. This panel is a TRMNL X, i.e. ESP32-S3, so that file is not the
|
||||
artefact for a manual flash here. Use
|
||||
`https://trmnl-fw.s3.us-east-2.amazonaws.com/trmnl_x/flash/FW<version>.bin`
|
||||
— a merged image at offset `0x0` — and run `esptool image-info` on
|
||||
anything before writing it.
|
||||
|
||||
The panel runs 1.8.16, flashed over USB on 2026-09-05. It shipped on
|
||||
1.8.6, where turning on `firmware_update` froze the screen for as long as
|
||||
an update was offered (fixed upstream in 1.8.7, commit `9b7a0f7`).
|
||||
|
||||
## Image automation
|
||||
|
||||
Both images are enrolled (`infrastructure/image-automation/terminus.yaml`,
|
||||
`trmnl-renderer.yaml`) and track the latest `0.x`. A bad bump on either
|
||||
surfaces as a **blank or corrupted panel**, not as a failed rollout — no
|
||||
readiness probe can tell that a rendered screen became unreadable. Look at
|
||||
the panel after a bump lands; revert the tag if it is wrong.
|
||||
|
||||
## Device endpoint
|
||||
|
||||
`trmnl-device.renner.uno` is the device-facing name, TLS via Traefik
|
||||
(`device-ingress.yaml`). `API_URI` must carry the same scheme the device
|
||||
polls with, because Terminus bakes it into the absolute `image_url` it
|
||||
returns. The LoadBalancer in `device-service.yaml` keeps `10.2.99.89`
|
||||
without a hostname as a cleartext fallback that depends on neither
|
||||
Traefik nor certificate renewal — the captive portal accepts a bare
|
||||
address.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Device-facing endpoint, over TLS.
|
||||
#
|
||||
# This replaces the reason the cleartext LoadBalancer in device-service.yaml
|
||||
# was given a hostname at all. That file claimed the firmware "would reject
|
||||
# the internal CA" — but nothing here is signed by an internal CA:
|
||||
# letsencrypt-cluster solves DNS-01 against the renner.uno Cloudflare zone,
|
||||
# so trmnl.renner.uno already serves a public Let's Encrypt certificate
|
||||
# (issuer CN=YR2) that any client with a normal CA bundle trusts. A
|
||||
# LAN-only name gets the same treatment — DNS-01 never needs the host to be
|
||||
# publicly reachable — so the device can speak HTTPS like everything else.
|
||||
#
|
||||
# The hostname moves here from the Service's external-dns annotation rather
|
||||
# than being duplicated: two sources claiming trmnl-device.renner.uno would
|
||||
# have external-dns flapping the record between the Traefik VIP and the
|
||||
# device LB. The LoadBalancer keeps its address and stays reachable at
|
||||
# http://10.2.99.89 as a nameless fallback if TLS ever turns out to be the
|
||||
# thing that breaks.
|
||||
#
|
||||
# CONSEQUENCE, and the reason API_URI in terminus.yaml changes with this:
|
||||
# Traefik's `web` entrypoint carries a global http -> websecure redirect
|
||||
# (helmrelease.yaml: `to: websecure, scheme: https, permanent: true`), so
|
||||
# once this name points at Traefik, http://trmnl-device.renner.uno answers
|
||||
# 308 rather than serving. Whether the firmware follows that is not
|
||||
# documented, so the device is provisioned with the https URL and Terminus
|
||||
# hands back https image_urls to match. No mixed-scheme fetch.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: terminus-device
|
||||
namespace: trmnl
|
||||
# No external-dns hostname annotation: external-dns-unifi runs with
|
||||
# --source=ingress, so it registers the hosts in spec.rules on its own.
|
||||
# Same as the terminus Ingress next door.
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- trmnl-device.renner.uno
|
||||
secretName: trmnl-device-tls
|
||||
rules:
|
||||
- host: trmnl-device.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: terminus
|
||||
port:
|
||||
number: 80
|
||||
@@ -1,15 +1,24 @@
|
||||
# Device-facing endpoint. Cleartext HTTP, deliberately.
|
||||
# Cleartext fallback for the device, reachable by address only.
|
||||
#
|
||||
# The TRMNL firmware initiates every request and cannot be handed the
|
||||
# internal CA, so terminating TLS here would simply break polling. Scope of
|
||||
# the exposure: this Service is LAN-only (no public DNS label, no Cloudflare
|
||||
# record), it carries no admin session — the device authenticates with its
|
||||
# MAC in the `ID` header plus a per-device api_key — and the Terminus admin
|
||||
# UI stays on the TLS route at trmnl.renner.uno.
|
||||
# This used to be THE device endpoint, on the premise that the firmware
|
||||
# "cannot be handed the internal CA". The premise was wrong: nothing here
|
||||
# hands out an internal CA for these names — letsencrypt-cluster solves
|
||||
# DNS-01 against the renner.uno Cloudflare zone and issues publicly-trusted
|
||||
# certificates for LAN-only hosts, which is why trmnl.renner.uno has always
|
||||
# served an issuer CN=YR2 certificate. The device speaks HTTPS via
|
||||
# device-ingress.yaml, which now owns the trmnl-device.renner.uno name.
|
||||
#
|
||||
# Kept, without a hostname, because it is the one path to Terminus that
|
||||
# depends on neither Traefik nor certificate renewal: http://10.2.99.89
|
||||
# still serves /api/setup and /api/display. If TLS is ever what breaks the
|
||||
# panel, the recovery is to type that address into the captive portal.
|
||||
#
|
||||
# Scope of the exposure is unchanged: LAN-only (no public DNS label, no
|
||||
# Cloudflare record), no admin session — the device authenticates with its
|
||||
# MAC in the `ID` header plus a per-device api_key.
|
||||
#
|
||||
# Same shape as home-assistant-bgp: a dedicated address on the Cilium BGP
|
||||
# pool rather than a TLS-less Ingress, which would be a new pattern for this
|
||||
# repo. The device is provisioned with the HOSTNAME, not the address.
|
||||
# pool.
|
||||
#
|
||||
# The address is requested explicitly, matching every other LB service here.
|
||||
# Left to allocate freely, Cilium IPAM handed out the first free address in
|
||||
@@ -30,8 +39,10 @@ metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: terminus
|
||||
lb-pool: bgp
|
||||
# No external-dns hostname: device-ingress.yaml claims that name. Two
|
||||
# sources claiming trmnl-device.renner.uno would flap the record between
|
||||
# the Traefik VIP and this address (external-dns runs --policy=sync).
|
||||
annotations:
|
||||
external-dns.alpha.kubernetes.io/hostname: trmnl-device.renner.uno
|
||||
lbipam.cilium.io/ips: "10.2.99.89,fd99::89"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
|
||||
@@ -6,6 +6,9 @@ resources:
|
||||
- valkey.yaml
|
||||
- terminus.yaml
|
||||
- ingress.yaml
|
||||
# TLS device endpoint (owns trmnl-device.renner.uno) plus the nameless
|
||||
# cleartext LoadBalancer it falls back to.
|
||||
- device-ingress.yaml
|
||||
- device-service.yaml
|
||||
# Headless HA screenshotter. Lives in this namespace because Terminus
|
||||
# FETCHES rendered screens back from it (uri delivery mode), so the
|
||||
|
||||
@@ -227,16 +227,12 @@ spec:
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
containers:
|
||||
# Pinned, not enrolled in image automation — same reasoning as
|
||||
# Terminus itself. This drives a wall-mounted panel and upstream
|
||||
# is pre-1.0; an unattended bump that changes the dithering
|
||||
# pipeline or the webhook payload shows up as a corrupted
|
||||
# screen, not as a failed rollout.
|
||||
#
|
||||
# Note the image is ARCH-SUFFIXED: upstream publishes no
|
||||
# multi-arch manifest, only trmnl-ha-amd64 and trmnl-ha-aarch64.
|
||||
# Enrolled in image automation (flux-system:trmnl-renderer). A bad
|
||||
# bump shows up as a corrupted screen, not a failed rollout — check
|
||||
# the panel after one lands. ARCH-SUFFIXED image: upstream publishes
|
||||
# no multi-arch manifest.
|
||||
- name: renderer
|
||||
image: ghcr.io/usetrmnl/trmnl-ha-amd64:0.9.5
|
||||
image: ghcr.io/usetrmnl/trmnl-ha-amd64:0.10.3 # {"$imagepolicy": "flux-system:trmnl-renderer"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 10000
|
||||
|
||||
@@ -7,11 +7,17 @@ data:
|
||||
TZ: "Europe/Berlin"
|
||||
HANAMI_PORT: "2345"
|
||||
# What the device is told to fetch from. Terminus bakes this into the
|
||||
# absolute `image_url` it hands back on /api/display, so it MUST be the
|
||||
# cleartext LAN name, not trmnl.renner.uno — the firmware speaks plain
|
||||
# HTTP and would reject the internal CA. No trailing slash: the same
|
||||
# string is typed into the captive portal at provisioning time.
|
||||
API_URI: "http://trmnl-device.renner.uno"
|
||||
# absolute `image_url` it hands back on /api/display, so it has to be a
|
||||
# name the DEVICE can resolve and fetch — and it has to carry the same
|
||||
# scheme the device polls with, or every screen fetch is a second,
|
||||
# cleartext connection. No trailing slash: the same string is typed into
|
||||
# the captive portal at provisioning time.
|
||||
#
|
||||
# https since device-ingress.yaml took over trmnl-device.renner.uno. The
|
||||
# earlier value was http on the theory that the firmware would reject an
|
||||
# internal CA; there is no internal CA here — letsencrypt-cluster issues
|
||||
# publicly-trusted certs for LAN-only names via DNS-01.
|
||||
API_URI: "https://trmnl-device.renner.uno"
|
||||
KEYVALUE_URL: "redis://terminus-valkey:6379"
|
||||
# Rack::Attack rate-limit bypass. Beyond upstream's RFC1918 defaults this
|
||||
# adds fc00::/7, which covers the pod CIDR (fd10:244::/48), the Cilium LB
|
||||
@@ -128,7 +134,7 @@ spec:
|
||||
# is a device-bricking class of risk. Bumps are a reviewed, manual
|
||||
# act — see kubernetes/infrastructure/image-automation/README.md.
|
||||
- name: terminus
|
||||
image: ghcr.io/usetrmnl/terminus:0.68.0
|
||||
image: ghcr.io/usetrmnl/terminus:0.72.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 2345
|
||||
@@ -191,7 +197,7 @@ spec:
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
- name: sidekiq
|
||||
image: ghcr.io/usetrmnl/terminus:0.68.0
|
||||
image: ghcr.io/usetrmnl/terminus:0.72.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
+1382
-58
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: paperless-eval
|
||||
namespace: flux-system
|
||||
spec:
|
||||
# A throwaway copy of paperless for measuring the local LLM against the real
|
||||
# corpus. Its Cluster restores from paperless's backup store and archives
|
||||
# nothing back; see kubernetes/apps/paperless-eval/cluster.yaml.
|
||||
#
|
||||
# prune: true and no prune-disabled annotations anywhere underneath — the
|
||||
# data here is a copy, and being able to delete the whole namespace by
|
||||
# removing this file is the point.
|
||||
interval: 10m
|
||||
retryInterval: 1m
|
||||
# 30m is not enough: the first start restores the database and then builds
|
||||
# the search index over ~3900 documents before the webserver answers.
|
||||
timeout: 60m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/paperless-eval
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
- name: infrastructure-ceph-cluster
|
||||
- name: infrastructure-cnpg-barman
|
||||
healthCheckExprs:
|
||||
- apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
failed: "has(status.phase) && status.phase.startsWith('Failed')"
|
||||
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
|
||||
@@ -937,7 +937,7 @@ subjects:
|
||||
# with no marker. Re-apply this block, or the skew silently returns.
|
||||
apiVersion: v1
|
||||
stringData:
|
||||
SIDECAR_IMAGE: ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar:v0.14.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin-sidecar"}
|
||||
SIDECAR_IMAGE: ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar:v0.15.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin-sidecar"}
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: plugin-barman-cloud-kg5c2795f7
|
||||
@@ -998,7 +998,7 @@ spec:
|
||||
secretKeyRef:
|
||||
key: SIDECAR_IMAGE
|
||||
name: plugin-barman-cloud-kg5c2795f7
|
||||
image: ghcr.io/cloudnative-pg/plugin-barman-cloud:v0.14.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin"}
|
||||
image: ghcr.io/cloudnative-pg/plugin-barman-cloud:v0.15.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin"}
|
||||
name: barman-cloud
|
||||
ports:
|
||||
- containerPort: 9090
|
||||
|
||||
@@ -96,28 +96,13 @@ The security-scan agent will continue to surface vuln findings on
|
||||
these as "Skipped / Flux image-automation owns this" even without
|
||||
a policy.
|
||||
|
||||
- `ghcr.io/usetrmnl/terminus` — publishes a usable `0.x.0` tag stream, so
|
||||
unlike the three above this is **not** a tag-format problem. It is left
|
||||
unenrolled because the blast radius is hardware: Terminus is pre-1.0,
|
||||
cuts no GitHub Releases and ships no changelog, and it serves the
|
||||
firmware and `/api/display` contract that the TRMNL X panel polls. An
|
||||
unattended bump that changes either can leave a wall-mounted device
|
||||
dark, with no rollback path that doesn't involve the captive portal.
|
||||
Bumps are a reviewed, manual act: read the commit range, then edit the
|
||||
tag in `kubernetes/apps/trmnl/terminus.yaml` (both containers).
|
||||
|
||||
The Valkey sidecar alongside it *is* enrolled (`valkey.yaml`) — it holds
|
||||
only the Sidekiq queue and touches no device-facing contract.
|
||||
|
||||
- `ghcr.io/usetrmnl/trmnl-ha-amd64` — the TRMNL screenshot renderer, for
|
||||
the same reason and one more. It owns the dithering pipeline and the
|
||||
webhook payload shape, so a bad bump surfaces as a *corrupted or blank
|
||||
panel*, not as a failed rollout — there is no readiness signal that
|
||||
catches "the image is now unreadable". Separately, upstream publishes
|
||||
**no multi-arch manifest**: only the arch-suffixed `trmnl-ha-amd64` and
|
||||
`trmnl-ha-aarch64` repositories exist, so an ImagePolicy here would also
|
||||
be pinning us to one architecture in a way the tag does not make
|
||||
obvious. Bump manually in `kubernetes/apps/trmnl/renderer.yaml`.
|
||||
Only a tag-format problem belongs on that list. The policy is to run the
|
||||
latest version and let image-automation hold us there wherever the tags
|
||||
allow it, so an image with a usable semver stream gets enrolled even when
|
||||
a bump carries risk. Where a bad bump is hard to detect, the answer is a
|
||||
note about what to check afterwards — see `apps/trmnl/README.md` for the
|
||||
panel, whose failure mode is a blank screen rather than a failed rollout
|
||||
— not an exclusion.
|
||||
|
||||
## Re-import hazard: cnpg-barman-plugin
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ resources:
|
||||
- mosquitto.yaml
|
||||
- redis.yaml
|
||||
- shelly-manager.yaml
|
||||
- terminus.yaml
|
||||
- tika.yaml
|
||||
- trmnl-renderer.yaml
|
||||
- valkey.yaml
|
||||
- velero-plugin-for-aws.yaml
|
||||
- vsftpd.yaml
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: terminus
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: ghcr.io/usetrmnl/terminus
|
||||
interval: 1h
|
||||
---
|
||||
# Pre-1.0, so the range is 0.x rather than a major pin: upstream's minors are
|
||||
# its release stream, and holding at a single minor is how this drifted four
|
||||
# releases behind while it was excluded from automation.
|
||||
#
|
||||
# A bad bump here surfaces as a blank or corrupted panel rather than a failed
|
||||
# rollout — no readiness probe can tell that a rendered screen became
|
||||
# unreadable. Check the panel after one lands; revert the tag if it is wrong.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: terminus
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: terminus
|
||||
filterTags:
|
||||
pattern: '^(?P<ver>\d+\.\d+\.\d+)$'
|
||||
extract: '$ver'
|
||||
policy:
|
||||
semver:
|
||||
range: "0.x"
|
||||
@@ -0,0 +1,29 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: trmnl-renderer
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: ghcr.io/usetrmnl/trmnl-ha-amd64
|
||||
interval: 1h
|
||||
---
|
||||
# Upstream ships no multi-arch manifest, only the arch-suffixed repositories.
|
||||
# That does not obstruct a policy: the image reference already names the
|
||||
# architecture, so this pins nothing the tag did not.
|
||||
#
|
||||
# The floating `0.9` / `0.10` series tags are filtered out; only fully
|
||||
# qualified patch versions are candidates. Same panel caveat as terminus.yaml.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: trmnl-renderer
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: trmnl-renderer
|
||||
filterTags:
|
||||
pattern: '^(?P<ver>\d+\.\d+\.\d+)$'
|
||||
extract: '$ver'
|
||||
policy:
|
||||
semver:
|
||||
range: "0.x"
|
||||
@@ -73,11 +73,6 @@ metadata:
|
||||
# classifies documents without experimenting on the live archive.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: paperless-eval
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: audiobookshelf
|
||||
---
|
||||
|
||||
@@ -34,22 +34,6 @@ spec:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
# The paperless-eval namespace reaches the embedding endpoint for the same
|
||||
# reason paperless does; its chat model runs inside each eval job's own pod
|
||||
# and needs no grant here.
|
||||
#
|
||||
# Scoped to the namespace rather than to an app label, unlike the grants
|
||||
# around it: that namespace exists only to run evaluations, and its
|
||||
# workload is jobs created per measurement whose labels vary with what is
|
||||
# being measured. A label match would be a trap that presents as an
|
||||
# embedding call silently dropped.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperless-eval
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
# Home Assistant's llama.cpp conversation agent calls the chat model only —
|
||||
# it has no use for the embedding endpoint, so unlike paperless above this
|
||||
# grant does not extend to llama-embed.
|
||||
|
||||
@@ -6,7 +6,6 @@ resources:
|
||||
- ingress-isolation.yaml
|
||||
- authentik-ns.yaml
|
||||
- paperless-ns.yaml
|
||||
- paperless-eval-ns.yaml
|
||||
- audiobookshelf-ns.yaml
|
||||
- home-assistant-ns.yaml
|
||||
- spegel-ns.yaml
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# Narrower than paperless-ns.yaml, because this namespace serves nobody.
|
||||
#
|
||||
# There is no ingress, no gate outpost and no hostname: the only things that
|
||||
# talk to it are its own pods. Monitoring is admitted so a run can be watched,
|
||||
# and host/remote-node for the kubelet probes.
|
||||
#
|
||||
# Egress reaches the ai namespace for the embedding endpoint, which is the one
|
||||
# dependency an eval job has outside its own pod, and world:443 so the CNPG
|
||||
# recovery can read the backup store on Hetzner.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny-ingress
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperless-eval
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
|
||||
# home-assistant-ns.yaml for the rationale.
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperless-eval
|
||||
# The embedding endpoint. The chat model runs inside each job's own pod, so
|
||||
# it needs no grant; the embedder is shared with production because it is
|
||||
# read-only and cheap, and swapping it is a phase-two concern.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: llama-embed
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": rook-ceph
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
# world:443 — the CNPG recovery reads the backup store on Hetzner Object
|
||||
# Storage, and image pulls.
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
# ICMP — egress mirror; see ingress block above.
|
||||
- toEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
---
|
||||
# The CNPG operator polls each instance's status endpoint over HTTPS
|
||||
# (https://<pod-ip>:8000/pg/status) to populate Cluster status. Without it the
|
||||
# Cluster reports "Instance Status Extraction Error: HTTP communication issue"
|
||||
# while Postgres serves normally — issue #433, where the operator's dial timed
|
||||
# out because the apps baseline never allowed the database namespace.
|
||||
#
|
||||
# It matters here even for a throwaway: the Flux Kustomization waits on
|
||||
# Cluster status, and a smoke test that cannot report its own health is not one.
|
||||
#
|
||||
# Scoped to the pprleval-pg endpoints and that one port, like the equivalent
|
||||
# rules for wp-pg and ha-pg: the barman and mariadb operators sharing the
|
||||
# database namespace have no reason to reach anything here.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: pprleval-pg-allow-cnpg-operator
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
cnpg.io/cluster: pprleval-pg
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": database
|
||||
app.kubernetes.io/name: cloudnative-pg
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8000"
|
||||
protocol: TCP
|
||||
@@ -27,7 +27,6 @@ spec:
|
||||
- ingress
|
||||
- monitoring
|
||||
- paperless
|
||||
- paperless-eval # same apps/paperless key plus the shared barman S3 creds
|
||||
- trmnl
|
||||
- velero
|
||||
- woodpecker # wp-pg barman creds; woodpecker moving out of `apps` (#433)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# First machine of the throwaway cluster on Hetzner.
|
||||
#
|
||||
# enp1s0 is the public NIC; the private networks are enp7s0 and enp8s0, in the
|
||||
# order bootstrap.sh attaches them.
|
||||
machine:
|
||||
install:
|
||||
disk: /dev/sda
|
||||
certSANs:
|
||||
- hcloud-1
|
||||
nodeLabels:
|
||||
# Holds a Ceph OSD device; the CephCluster selects OSD nodes by this label.
|
||||
storage.renner.uno/ceph-osd: "true"
|
||||
udev:
|
||||
# Renames the SAN NIC to the role name at boot. Matched by PCI bus path
|
||||
# rather than MAC: the address follows from the order bootstrap.sh attaches
|
||||
# the networks, while the MAC is assigned by Hetzner at attach time and is
|
||||
# not known when this file is written.
|
||||
rules:
|
||||
- SUBSYSTEM=="net", ACTION=="add", KERNELS=="0000:08:00.0", NAME="ceph-san"
|
||||
---
|
||||
# In the generated schema rather than machine.network.hostname: naming the host
|
||||
# in both is rejected at apply time. `auto: off` switches off the generated
|
||||
# `auto: stable`, which is mutually exclusive with a static name.
|
||||
apiVersion: v1alpha1
|
||||
kind: HostnameConfig
|
||||
auto: off
|
||||
hostname: hcloud-1
|
||||
---
|
||||
# Every interface is stated, the public one included. Talos stops running its
|
||||
# default DHCP operators for the whole machine as soon as one new-style network
|
||||
# document exists, so an omitted interface gets no address.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp1s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp1s0
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp7s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp7s0
|
||||
---
|
||||
# Ceph cluster network, renamed to the role name by the udev rule in the
|
||||
# machine section. The node holds no address here; the link exists for the OSD
|
||||
# pod. No mtu: a Hetzner network carries 1450, not production's 9000.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: ceph-san
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: RawVolumeConfig
|
||||
name: ceph-osd
|
||||
provisioning:
|
||||
diskSelector:
|
||||
# Two disks: the system disk and the attached volume. Matching on transport
|
||||
# as the qemu patches do selects nothing here — a Hetzner volume arrives
|
||||
# over virtio-scsi, and a selector matching zero disks is skipped silently.
|
||||
match: '!system_disk'
|
||||
# bootstrap.sh reads this size and creates the volume to match.
|
||||
minSize: 50GiB
|
||||
grow: true
|
||||
@@ -0,0 +1,50 @@
|
||||
# Second machine. See hcloud-1.yaml.
|
||||
machine:
|
||||
install:
|
||||
disk: /dev/sda
|
||||
certSANs:
|
||||
- hcloud-2
|
||||
nodeLabels:
|
||||
storage.renner.uno/ceph-osd: "true"
|
||||
udev:
|
||||
# See hcloud-1.yaml.
|
||||
rules:
|
||||
- SUBSYSTEM=="net", ACTION=="add", KERNELS=="0000:08:00.0", NAME="ceph-san"
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: HostnameConfig
|
||||
auto: off
|
||||
hostname: hcloud-2
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp1s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp1s0
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp7s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp7s0
|
||||
---
|
||||
# See hcloud-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: ceph-san
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: RawVolumeConfig
|
||||
name: ceph-osd
|
||||
provisioning:
|
||||
diskSelector:
|
||||
match: '!system_disk'
|
||||
minSize: 50GiB
|
||||
grow: true
|
||||
@@ -0,0 +1,50 @@
|
||||
# Third machine. See hcloud-1.yaml.
|
||||
machine:
|
||||
install:
|
||||
disk: /dev/sda
|
||||
certSANs:
|
||||
- hcloud-3
|
||||
nodeLabels:
|
||||
storage.renner.uno/ceph-osd: "true"
|
||||
udev:
|
||||
# See hcloud-1.yaml.
|
||||
rules:
|
||||
- SUBSYSTEM=="net", ACTION=="add", KERNELS=="0000:08:00.0", NAME="ceph-san"
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: HostnameConfig
|
||||
auto: off
|
||||
hostname: hcloud-3
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp1s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp1s0
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp7s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp7s0
|
||||
---
|
||||
# See hcloud-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: ceph-san
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: RawVolumeConfig
|
||||
name: ceph-osd
|
||||
provisioning:
|
||||
diskSelector:
|
||||
match: '!system_disk'
|
||||
minSize: 50GiB
|
||||
grow: true
|
||||
@@ -0,0 +1,25 @@
|
||||
# Site patch for the throwaway cluster on Hetzner Cloud.
|
||||
#
|
||||
# This site exists for one thing the other two cannot test: whether a node that
|
||||
# genuinely cannot reach gitea.renner.uno still comes up. At home the registry
|
||||
# answers, so containerd never reaches the second endpoint in
|
||||
# machine.registries.mirrors and the fallback stays unproven.
|
||||
#
|
||||
# Hetzner Cloud networks are layer 3. A macvlan child carries its own MAC and
|
||||
# frames from it do not cross between servers — measured: same-node ARP
|
||||
# resolves, cross-node does not. OSD replication therefore does not run here.
|
||||
#
|
||||
# What belongs in this file is what the cluster is. Interfaces, disks and names
|
||||
# are machine properties and live in the node patches.
|
||||
machine:
|
||||
kubelet:
|
||||
nodeIP:
|
||||
# The segment bootstrap.sh provisions for node addresses. Unpinned, the
|
||||
# kubelet can pick the public NIC.
|
||||
validSubnets:
|
||||
- 10.90.0.0/16
|
||||
cluster:
|
||||
# Three control planes that also carry workloads, which is the production
|
||||
# shape. The qemu site cannot mirror it: that provisioner gives a second disk
|
||||
# to workers only, so a Ceph OSD forces 1 control plane and 3 workers there.
|
||||
allowSchedulingOnControlPlanes: true
|
||||
@@ -11,6 +11,12 @@ machine:
|
||||
# kubelet picks the first non-loopback IP, which could land on the v30iot
|
||||
# NIC (ens19, 10.2.30.0/24). Control-plane traffic and hostNetwork pod IPs
|
||||
# must stay on mgmt LAN.
|
||||
# fd40::/64 is the mgmt LAN ULA, and it is listed so the Node object carries
|
||||
# an IPv6 InternalIP at all. Without one, Cilium falls back to detecting the
|
||||
# node's IPv6 from mgmt0 and picks the SLAAC address from the dynamic
|
||||
# provider prefix, which it then probes until the agent restarts (#789).
|
||||
# The GUA is not a control-plane address: BGP peers on fd40::/64 too.
|
||||
nodeIP:
|
||||
validSubnets:
|
||||
- 10.2.40.0/24
|
||||
- fd40::/64
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Runs `bootstrap.sh vmtest` inside the cluster instead of on a workstation.
|
||||
#
|
||||
# NOT reconciled by Flux — deliberately outside kubernetes/clusters. This is a
|
||||
# one-shot test, and a Kustomization would recreate it after every completion.
|
||||
# It is applied by the vmtest pipeline, or by hand for a single run.
|
||||
#
|
||||
# Why in-cluster at all: the test wants ~30G for four VMs (one control plane at
|
||||
# 6G, three OSD workers at 8G). The workstation has 31G with a desktop on it —
|
||||
# a single run left under 1G free and two overlapping runs OOM-killed the
|
||||
# session. The nodes have ~94G each with about 70G unused.
|
||||
#
|
||||
# The whole script runs here, flux and OpenBao included, so that what a rebuild
|
||||
# rehearses is what a real recovery would execute. The unseal key is not baked
|
||||
# in and not stored as a Secret: the operator streams all five secrets into an
|
||||
# in-memory emptyDir at the moment the run asks for them, and they die with the
|
||||
# Job without ever reaching etcd or a backup of it.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: vmtest
|
||||
labels:
|
||||
# The pod is privileged; say so rather than have the admission policy
|
||||
# reject it for reasons that look unrelated.
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: vmtest
|
||||
namespace: vmtest
|
||||
spec:
|
||||
backoffLimit: 0 # a failed rebuild test is a result, not something to retry
|
||||
ttlSecondsAfterFinished: 86400
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: vmtest
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
# Nothing here should be able to reach the cluster it runs in. The phase
|
||||
# points KUBECONFIG at its own throwaway cluster, and without a mounted
|
||||
# token a stray kubectl cannot fall back to this one.
|
||||
automountServiceAccountToken: false
|
||||
containers:
|
||||
- name: vmtest
|
||||
# Pinned by digest, not :latest. Spegel mirrors mutable tags and keeps
|
||||
# serving what it cached: a run on k8s-3 pulled a ci-vmtest:latest
|
||||
# three builds old, which predated OVMF, and died with "the required
|
||||
# flash image was not found" before the first VM booted. The tag is
|
||||
# carried alongside for readability — containerd resolves the digest.
|
||||
image: gitea.renner.uno/gitea_admin/ci-vmtest:2026-09-03@sha256:a8145a76896ef9aa6fdb58dc96b9bdc22b53f4e55f04d9e6de289f35b75dbd81
|
||||
# Privileged for /dev/kvm and for the bridges and taps the provisioner
|
||||
# builds. It needs no hostNetwork: the provisioner's network lives in
|
||||
# this pod's own namespace, and the script that talks to 10.5.0.x runs
|
||||
# in the same pod.
|
||||
securityContext:
|
||||
privileged: true
|
||||
# A hard ceiling, because the failure mode is known: an unbounded run
|
||||
# is what OOM-killed the workstation. 40G is the VMs (16G control
|
||||
# plane + 3x8G workers), the rest is qemu overhead and the tooling.
|
||||
# The workers carry an OSD, a mon and a mgr each; at 4G they ran out
|
||||
# of room for the CNI DaemonSets themselves. The control plane went
|
||||
# 6G → 8G → 16G: at both smaller sizes it ran at 99% of allocatable
|
||||
# memory and the kubelet evicted kube-apiserver to admit a
|
||||
# node-critical pod — bootstrap.sh carries the full account next to
|
||||
# the sizing flags. The hosts have ~95GiB each, so this fits.
|
||||
resources:
|
||||
requests:
|
||||
# Less than the 14 guest vCPUs the four VMs declare, on purpose:
|
||||
# the guests are never all runnable at once (one cluster booting
|
||||
# in sequence), and there is no CPU limit, so the pod may burst
|
||||
# past the request when the node has headroom. The request only
|
||||
# keeps the scheduler from stacking this next to another big
|
||||
# burst consumer.
|
||||
cpu: "6"
|
||||
memory: 42Gi
|
||||
limits:
|
||||
memory: 48Gi
|
||||
env:
|
||||
# talosctl caches the image and the CNI bundle under $HOME; point it
|
||||
# at the scratch volume rather than the container layer.
|
||||
- name: HOME
|
||||
value: /work
|
||||
# The VM disks. Short on purpose — the provisioner puts a qemu
|
||||
# monitor socket under it and sun_path caps the path at 108 bytes.
|
||||
- name: VM_STATE
|
||||
value: /work/vm
|
||||
# Which branch to test. master unless a run is checking a change.
|
||||
- name: BRANCH
|
||||
value: master
|
||||
# Secrets are streamed in here at the moment the run needs them,
|
||||
# never baked into the image and never stored as a Secret. They live
|
||||
# in this emptyDir for the length of the Job and go with it — so
|
||||
# they do not reach etcd, or any backup of it.
|
||||
- name: OPENBAO_SECRETS_DIR
|
||||
value: /secrets
|
||||
command: ["/bin/bash", "-ec"]
|
||||
args:
|
||||
- |
|
||||
# From the GitHub mirror, not in-cluster Gitea. A rebuild test
|
||||
# that clones from the cluster it is rehearsing the loss of
|
||||
# cannot start when it matters — and a run already died here on
|
||||
# resolving gitea.renner.uno.
|
||||
git clone --depth 1 --branch "$BRANCH" \
|
||||
https://github.com/konairius/Homelab.git /work/homelab
|
||||
exec /work/homelab/kubernetes/bootstrap.sh vmtest
|
||||
volumeMounts:
|
||||
- name: work
|
||||
mountPath: /work
|
||||
- name: secrets
|
||||
mountPath: /secrets
|
||||
- name: dev-kvm
|
||||
mountPath: /dev/kvm
|
||||
volumes:
|
||||
# The VM disks and the provisioner state. emptyDir on the node's disk
|
||||
# rather than a PVC: it is scratch that dies with the Job, and putting it
|
||||
# on Ceph would have the test's own writes compete with the storage it is
|
||||
# meant to leave alone.
|
||||
- name: work
|
||||
emptyDir:
|
||||
# The VM images are sparse, so this bounds what is actually written,
|
||||
# not the 232GiB the geometry declares. 60Gi was already half spent
|
||||
# by four 10GiB systems disks; the node carries this comfortably with
|
||||
# ~810GB free on the filesystem behind it.
|
||||
sizeLimit: 150Gi
|
||||
# In memory, so the material never touches a node's disk.
|
||||
- name: secrets
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 1Mi
|
||||
- name: dev-kvm
|
||||
hostPath:
|
||||
path: /dev/kvm
|
||||
type: CharDevice
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# Retrieval evaluation for an embedding model, on German documents of the kind
|
||||
# this homelab actually files.
|
||||
#
|
||||
# Companion to scripts/ai-eval.sh, but it cannot work the same way. A chat
|
||||
# answer can be read and judged; an embedding is 1024 opaque numbers. What is
|
||||
# measurable is whether the right document comes back first, so this builds a
|
||||
# small labelled corpus, embeds it, and scores real questions against it.
|
||||
#
|
||||
# Metrics:
|
||||
# Recall@1 the correct document ranked first — what Paperless's RAG actually
|
||||
# feeds to the classifier
|
||||
# Recall@3 it at least made the shortlist
|
||||
# MRR mean reciprocal rank, so near-misses are visible rather than
|
||||
# collapsing to pass/fail
|
||||
# margin cosine gap between the correct document and the best wrong one.
|
||||
# A model that is right by 0.002 is guessing; one that is right by
|
||||
# 0.15 has actually separated them.
|
||||
#
|
||||
# The corpus deliberately contains confusable pairs — two Allianz policies, four
|
||||
# documents that are all "Rechnungen", a car insurance and a vehicle
|
||||
# registration sharing a number plate. An easy corpus would rate everything 100%
|
||||
# and tell you nothing.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/embed-eval.sh # cluster default
|
||||
# scripts/embed-eval.sh -m some-model
|
||||
# scripts/embed-eval.sh -e https://host/v1/embeddings -m model -k KEY
|
||||
#
|
||||
# Env equivalents: EMBED_EVAL_ENDPOINT, EMBED_EVAL_MODEL, EMBED_EVAL_KEY.
|
||||
#
|
||||
# To compare two models on our llama-embed, -m is not enough: swap --hf-repo in
|
||||
# kubernetes/apps/llama-server/embed.yaml, let it roll out, then re-run. Note
|
||||
# that changing the model also invalidates any vectors Paperless has stored, so
|
||||
# comparisons are best made before an index exists.
|
||||
#
|
||||
# Exit code is the number of queries whose correct document did not rank first.
|
||||
set -uo pipefail
|
||||
|
||||
ENDPOINT="${EMBED_EVAL_ENDPOINT:-http://llama-embed.ai.svc.cluster.local:8080/v1/embeddings}"
|
||||
MODEL="${EMBED_EVAL_MODEL:-qwen3-embedding-0.6b}"
|
||||
APIKEY="${EMBED_EVAL_KEY:-}"
|
||||
# Run curl from inside this pod when the endpoint is a cluster-internal name.
|
||||
# llama-embed has no ingress by design, so there is nothing to reach from a
|
||||
# workstation.
|
||||
VIA_POD="${EMBED_EVAL_VIA_POD:-}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-e|--endpoint) ENDPOINT="$2"; shift 2 ;;
|
||||
-m|--model) MODEL="$2"; shift 2 ;;
|
||||
-k|--key) APIKEY="$2"; shift 2 ;;
|
||||
-p|--via-pod) VIA_POD="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,34p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $1 (try --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
CASES="$HERE/embed-eval"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
for f in "$CASES/corpus.jsonl" "$CASES/queries.jsonl"; do
|
||||
[ -r "$f" ] || { echo "missing fixture: $f" >&2; exit 2; }
|
||||
done
|
||||
|
||||
# Default to reaching the Service from a pod that is allowed to, unless the
|
||||
# caller pointed us somewhere externally reachable.
|
||||
if [ -z "$VIA_POD" ] && [ "${ENDPOINT#*svc.cluster.local}" != "$ENDPOINT" ]; then
|
||||
VIA_POD="deploy/llama-server -n ai"
|
||||
fi
|
||||
|
||||
# embed <text> -> the vector as a JSON array on stdout
|
||||
embed() {
|
||||
local payload out
|
||||
payload=$(jq -nc --arg m "$MODEL" --arg t "$1" '{model:$m,input:$t}')
|
||||
if [ -n "$VIA_POD" ]; then
|
||||
# stderr is kept and surfaced on failure rather than discarded: a
|
||||
# silently swallowed kubectl or curl error is indistinguishable from a
|
||||
# model that returned nothing.
|
||||
# shellcheck disable=SC2086
|
||||
out=$(kubectl exec $VIA_POD -- curl -sS -m 120 \
|
||||
-H 'Content-Type: application/json' \
|
||||
${APIKEY:+-H "Authorization: Bearer $APIKEY"} \
|
||||
-d "$payload" "$ENDPOINT" 2>"$WORK/err.txt")
|
||||
else
|
||||
out=$(curl -sS -m 120 -H 'Content-Type: application/json' \
|
||||
${APIKEY:+-H "Authorization: Bearer $APIKEY"} \
|
||||
-d "$payload" "$ENDPOINT")
|
||||
fi
|
||||
printf '%s' "$out" | jq -c '.data[0].embedding // empty'
|
||||
}
|
||||
|
||||
echo "endpoint: $ENDPOINT"
|
||||
echo "model: $MODEL"
|
||||
[ -n "$VIA_POD" ] && echo "via pod: $VIA_POD"
|
||||
echo ""
|
||||
|
||||
# ---- embed the corpus ------------------------------------------------------
|
||||
DOCS=$(wc -l < "$CASES/corpus.jsonl" | tr -d ' ')
|
||||
echo "embedding $DOCS documents..."
|
||||
: > "$WORK/vecs.jsonl"
|
||||
start=$(date +%s)
|
||||
while IFS= read -r line; do
|
||||
id=$(printf '%s' "$line" | jq -r '.id')
|
||||
text=$(printf '%s' "$line" | jq -r '.text')
|
||||
vec=$(embed "$text")
|
||||
if [ -z "$vec" ]; then
|
||||
echo "FAILED to embed '$id' — endpoint returned no vector." >&2
|
||||
[ -s "$WORK/err.txt" ] && { echo "stderr:" >&2; cat "$WORK/err.txt" >&2; }
|
||||
exit 2
|
||||
fi
|
||||
jq -nc --arg id "$id" --argjson v "$vec" '{id:$id, vec:$v}' >> "$WORK/vecs.jsonl"
|
||||
done < "$CASES/corpus.jsonl"
|
||||
elapsed=$(( $(date +%s) - start ))
|
||||
DIM=$(head -1 "$WORK/vecs.jsonl" | jq '.vec | length')
|
||||
echo " $DOCS documents, ${DIM} dimensions, ${elapsed}s"
|
||||
echo ""
|
||||
|
||||
# ---- score the queries -----------------------------------------------------
|
||||
# Cosine similarity in jq. The vectors llama.cpp returns are normalised, but the
|
||||
# full form costs nothing and keeps this correct against servers that do not.
|
||||
rank() { # queryvec -> "id score" lines, best first
|
||||
jq -s -r --argjson q "$1" '
|
||||
def dot($a; $b): reduce range(0; $a|length) as $i (0; . + ($a[$i] * $b[$i]));
|
||||
def norm($a): (reduce ($a[]) as $x (0; . + ($x * $x))) | sqrt;
|
||||
map({id: .id, s: (dot(.vec; $q) / ((norm(.vec) * norm($q)) + 1e-12))})
|
||||
| sort_by(-.s)[]
|
||||
| "\(.id) \(.s)"
|
||||
' "$WORK/vecs.jsonl"
|
||||
}
|
||||
|
||||
MISS=0; TOTAL=0; HIT1=0; HIT3=0; MRRSUM=0
|
||||
printf '%-46s %-22s %5s %8s\n' QUERY EXPECTED RANK MARGIN
|
||||
printf '%s\n' "------------------------------------------------------------------------------------"
|
||||
while IFS= read -r line; do
|
||||
q=$(printf '%s' "$line" | jq -r '.q')
|
||||
want=$(printf '%s' "$line" | jq -r '.want')
|
||||
qvec=$(embed "$q")
|
||||
if [ -z "$qvec" ]; then
|
||||
echo "FAILED to embed query: $q" >&2
|
||||
[ -s "$WORK/err.txt" ] && { echo "stderr:" >&2; cat "$WORK/err.txt" >&2; }
|
||||
exit 2
|
||||
fi
|
||||
rank "$qvec" > "$WORK/ranked.txt"
|
||||
|
||||
pos=$(grep -n "^$want " "$WORK/ranked.txt" | cut -d: -f1)
|
||||
pos=${pos:-999}
|
||||
TOTAL=$((TOTAL + 1))
|
||||
[ "$pos" -eq 1 ] && HIT1=$((HIT1 + 1)) || MISS=$((MISS + 1))
|
||||
[ "$pos" -le 3 ] && HIT3=$((HIT3 + 1))
|
||||
MRRSUM=$(awk -v s="$MRRSUM" -v p="$pos" 'BEGIN{printf "%.6f", s + 1/p}')
|
||||
|
||||
# Margin: the correct document's score minus the best incorrect one. On a
|
||||
# miss this is negative, which says how badly it lost.
|
||||
want_s=$(awk -v w="$want" '$1==w{print $2; exit}' "$WORK/ranked.txt")
|
||||
best_other=$(awk -v w="$want" '$1!=w{print $2; exit}' "$WORK/ranked.txt")
|
||||
margin=$(awk -v a="${want_s:-0}" -v b="${best_other:-0}" 'BEGIN{printf "%+.4f", a-b}')
|
||||
|
||||
if [ "$pos" -eq 1 ]; then mark=" "; else mark="!"; fi
|
||||
printf '%s%-45.45s %-22s %5s %8s\n' "$mark" "$q" "$want" "$pos" "$margin"
|
||||
if [ "$pos" -ne 1 ]; then
|
||||
printf ' got instead: %s\n' "$(head -1 "$WORK/ranked.txt" | cut -d' ' -f1)"
|
||||
fi
|
||||
done < "$CASES/queries.jsonl"
|
||||
|
||||
R1=$(awk -v h="$HIT1" -v t="$TOTAL" 'BEGIN{printf "%.0f", 100*h/t}')
|
||||
R3=$(awk -v h="$HIT3" -v t="$TOTAL" 'BEGIN{printf "%.0f", 100*h/t}')
|
||||
MRR=$(awk -v s="$MRRSUM" -v t="$TOTAL" 'BEGIN{printf "%.3f", s/t}')
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
printf ' RESULT model=%s dim=%s recall@1=%s%% recall@3=%s%% mrr=%s\n' \
|
||||
"$MODEL" "$DIM" "$R1" "$R3" "$MRR"
|
||||
echo "========================================"
|
||||
exit "$MISS"
|
||||
@@ -0,0 +1,12 @@
|
||||
{"id":"stadtwerke-strom","text":"Stadtwerke Muenchen GmbH. Jahresrechnung Strom. Rechnungsnummer 4711. Abrechnungszeitraum 01.01.2025 bis 31.12.2025. Zaehlernummer 1EMH0012345678. Verbrauch 2450 kWh zum Arbeitspreis von 31,2 Cent je kWh. Grundpreis 9,90 EUR monatlich. Zu zahlender Betrag 812,40 EUR faellig am 15.02.2026."}
|
||||
{"id":"telekom-mobilfunk","text":"Telekom Deutschland GmbH. Ihre Mobilfunkrechnung fuer Januar. Kundennummer 0891234567. Rufnummer 0170 1234567. Tarif MagentaMobil M mit 20 GB Datenvolumen. Grundpreis 39,95 EUR. Verbindungen ins Ausland 4,20 EUR. Rechnungsbetrag 44,15 EUR wird per Lastschrift eingezogen."}
|
||||
{"id":"finanzamt-steuer","text":"Finanzamt Muenchen Abteilung II. Bescheid ueber Einkommensteuer und Solidaritaetszuschlag fuer 2025. Steuernummer 143/256/78901. Zu versteuerndes Einkommen 54.320 EUR. Festgesetzte Einkommensteuer 8.412 EUR. Bereits geleistete Vorauszahlungen 9.100 EUR. Erstattungsbetrag 688 EUR. Rechtsbehelfsbelehrung siehe Seite 3."}
|
||||
{"id":"allianz-hausrat","text":"Allianz Versicherungs-AG. Versicherungsschein Hausratversicherung. Policennummer HR-88231. Versicherte Wohnflaeche 96 Quadratmeter. Versicherungssumme 65.000 EUR. Eingeschlossen sind Feuer, Leitungswasser, Sturm und Einbruchdiebstahl. Fahrraddiebstahl bis 1.500 EUR mitversichert. Jahresbeitrag 214 EUR."}
|
||||
{"id":"allianz-kfz","text":"Allianz Versicherungs-AG. Beitragsrechnung Kraftfahrtversicherung. Policennummer KFZ-55190. Amtliches Kennzeichen M-AB 1234. Fahrzeug VW Golf VIII. Vollkasko mit 500 EUR Selbstbeteiligung, Teilkasko 150 EUR. Schadenfreiheitsklasse SF 12. Jahresbeitrag 638 EUR."}
|
||||
{"id":"mietvertrag","text":"Mietvertrag ueber Wohnraum. Vermieter Immobilien Wagner GbR, Mieter Konstantin Renner. Mietobjekt Dreizimmerwohnung im zweiten Obergeschoss, 78 Quadratmeter, Lindenstrasse 14. Mietbeginn 01.04.2024. Kaltmiete 1.180 EUR monatlich, Betriebskostenvorauszahlung 240 EUR. Kaution drei Nettokaltmieten."}
|
||||
{"id":"arbeitsvertrag","text":"Arbeitsvertrag zwischen der Muster Technik GmbH und dem Arbeitnehmer. Taetigkeit als Softwareentwickler ab 01.09.2024. Bruttojahresgehalt 78.000 EUR zahlbar in zwoelf Monatsraten. Woechentliche Arbeitszeit 40 Stunden. Urlaubsanspruch 30 Arbeitstage im Kalenderjahr. Probezeit sechs Monate."}
|
||||
{"id":"aok-krankenkasse","text":"AOK Bayern. Beitragsbescheid zur gesetzlichen Kranken- und Pflegeversicherung. Versichertennummer A123456789. Beitragspflichtige Einnahmen 4.500 EUR monatlich. Allgemeiner Beitragssatz 14,6 Prozent zuzueglich Zusatzbeitrag 1,7 Prozent. Pflegeversicherung 3,4 Prozent. Monatlicher Gesamtbeitrag 887,50 EUR."}
|
||||
{"id":"sparkasse-kontoauszug","text":"Stadtsparkasse Muenchen. Kontoauszug Nummer 3 zum Girokonto IBAN DE89 7015 0000 0012 3456 78. Alter Kontostand 2.340,12 EUR. Gutschrift Gehalt 4.210,00 EUR. Lastschrift Miete 1.420,00 EUR. Dauerauftrag Sparplan 300,00 EUR. Neuer Kontostand 4.830,12 EUR."}
|
||||
{"id":"amazon-bestellung","text":"Amazon EU S.a.r.l. Bestellbestaetigung. Bestellnummer 302-4455667-8899001. Artikel Logitech MX Master 3S Wireless Maus, Menge 1, Preis 89,99 EUR. Versand durch Amazon, Lieferung voraussichtlich am 04.09. Zahlungsart Kreditkarte. Rechnungsbetrag inklusive Mehrwertsteuer 89,99 EUR."}
|
||||
{"id":"handwerker-rechnung","text":"Sanitaer Bergmeister GmbH. Rechnung fuer erbrachte Leistungen. Rechnungsnummer 2026-0417. Austausch eines defekten Heizungsthermostats sowie Entlueftung von drei Heizkoerpern. Arbeitszeit 2,5 Stunden zu 68 EUR. Material 42,30 EUR. Nettobetrag 212,30 EUR, Umsatzsteuer 19 Prozent, Gesamtbetrag 252,64 EUR."}
|
||||
{"id":"kfz-zulassung","text":"Landeshauptstadt Muenchen Kreisverwaltungsreferat. Zulassungsbescheinigung Teil I. Amtliches Kennzeichen M-AB 1234. Fahrzeugklasse Personenkraftwagen. Hersteller Volkswagen, Handelsbezeichnung Golf. Erstzulassung 14.03.2022. Hubraum 1498 Kubikzentimeter, Leistung 110 kW. Naechste Hauptuntersuchung 03.2028."}
|
||||
@@ -0,0 +1,12 @@
|
||||
{"q":"Wie hoch war mein Stromverbrauch im letzten Jahr?","want":"stadtwerke-strom","note":"paraphrase: asks about consumption, the document says Verbrauch kWh"}
|
||||
{"q":"Was kostet mein Handyvertrag monatlich?","want":"telekom-mobilfunk","note":"paraphrase: Handyvertrag vs Mobilfunkrechnung/Tarif"}
|
||||
{"q":"Bescheid vom Finanzamt ueber die Einkommensteuer","want":"finanzamt-steuer","note":"near-literal, should be easy"}
|
||||
{"q":"Welchen Versicherungsschutz habe ich fuer mein Auto?","want":"allianz-kfz","note":"HARD: two Allianz policies. Must not pick the Hausrat one."}
|
||||
{"q":"Ist mein Fahrrad gegen Diebstahl versichert?","want":"allianz-hausrat","note":"HARD: same sender as kfz, and bicycles are a detail deep in the text"}
|
||||
{"q":"Wie hoch ist die Kaltmiete meiner Wohnung?","want":"mietvertrag","note":"must beat sparkasse, which also mentions Miete as a Lastschrift"}
|
||||
{"q":"Wie viele Urlaubstage stehen mir zu?","want":"arbeitsvertrag","note":"paraphrase: Urlaubsanspruch"}
|
||||
{"q":"Was zahle ich monatlich an die Krankenkasse?","want":"aok-krankenkasse","note":"Krankenkasse vs Kranken- und Pflegeversicherung"}
|
||||
{"q":"Wie viel Geld ist auf meinem Girokonto?","want":"sparkasse-kontoauszug","note":"Kontostand"}
|
||||
{"q":"Rechnung fuer die Reparatur der Heizung","want":"handwerker-rechnung","note":"HARD: several documents are Rechnungen; must pick the Sanitaer one"}
|
||||
{"q":"Wann muss mein Auto zum TUEV?","want":"kfz-zulassung","note":"HARD: TUEV never appears; the document says Hauptuntersuchung. Pure semantics, and must not be pulled to the Kfz-Versicherung by the shared plate number."}
|
||||
{"q":"Wann wurde meine Maus geliefert?","want":"amazon-bestellung","note":"colloquial vs Bestellbestaetigung/Lieferung"}
|
||||
@@ -13,7 +13,18 @@ set -euo pipefail
|
||||
ENDPOINT="https://fsn1.your-objectstorage.com"
|
||||
BUCKET="renner-uno-k8s-secrets"
|
||||
PREFIX="cluster-access"
|
||||
export AWS_PROFILE="secrets"
|
||||
# Overridable, which the unconditional `export AWS_PROFILE="secrets"` was not.
|
||||
# The default is unchanged and deliberately so: "secrets" resolves to working
|
||||
# credentials and this script has always used it. Both "secrets" and "hetzner"
|
||||
# exist in ~/.aws/credentials on the workstation.
|
||||
#
|
||||
# The defect is only the assignment. Forcing the value defeated the env-based
|
||||
# path this file's own header offers, and it also overrode a caller who had
|
||||
# already selected a profile — which is how a 403 from HeadObject can appear
|
||||
# while the credentials the caller intended were perfectly good. That error
|
||||
# reads as a bucket permissions problem and sends you looking in the wrong
|
||||
# place.
|
||||
export AWS_PROFILE="${AWS_PROFILE:-secrets}"
|
||||
|
||||
|
||||
command -v aws >/dev/null || { echo "aws-cli not found"; exit 1; }
|
||||
|
||||
Reference in New Issue
Block a user