diff --git a/CI-CD.md b/CI-CD.md index 3074e6b..860f826 100644 --- a/CI-CD.md +++ b/CI-CD.md @@ -4,7 +4,7 @@ Two pipelines: one that **validates** a pull request, one that **publishes** a c ## Publishing -- **`publish-.yml`** — one thin workflow per chart (24 of them). It triggers on pushes to `main` touching `/**`, or manually, and calls the reusable workflow below. +- **`publish-.yml`** — one thin workflow per chart (26 of them). It triggers on pushes to `main` touching `/**`, or manually, and calls the reusable workflow below. - **A push to a chart directory on `main` publishes that chart.** The chart version must be bumped in the same change; the PR pipeline enforces it. - **`publish-helm-chart.yml`** — the reusable workflow: fetch dependencies, `helm package`, then a single HTTP `POST` of the `.tgz` to the registry. @@ -154,7 +154,7 @@ Both matrices follow the changed **chart directories**, so a PR that only change All measured on this instance, not taken from documentation, and the reason for every deviation in `validate-charts.yml`. -**Reusable workflows with `secrets: inherit` work**, so the 24 thin publish hulls run unchanged. One quirk: a *missing* secret does not fail the job, it fails job **creation** — the run then shows no steps at all. +**Reusable workflows with `secrets: inherit` work**, so the 26 thin publish hulls run unchanged. One quirk: a *missing* secret does not fail the job, it fails job **creation** — the run then shows no steps at all. **Runner labels mean different machines.** `ubuntu-latest` has **no Docker socket** here; `ubuntu-docker` has one. Exactly one job cares — the install-test, because k3d is a Docker client — so its `runs-on` is an expression rather than a fixed label, and the same file still works on a GitHub runner where `ubuntu-docker` does not exist. diff --git a/Charts.md b/Charts.md index 2c998c4..61748aa 100644 --- a/Charts.md +++ b/Charts.md @@ -1,6 +1,6 @@ # Chart index -26 directories: 25 installable charts plus the `template-chart` scaffold. Each page holds prerequisites, an installation walkthrough, the complete list of configurable values, and the chart's operating traps. +27 directories: 26 installable charts plus the `template-chart` scaffold. Each page holds prerequisites, an installation walkthrough, the complete list of configurable values, and the chart's operating traps. **Versions are not listed here on purpose** — and not on the chart pages either. Renovate moves app versions continuously; a table of numbers in a wiki is wrong within days. Read the current pair off the chart: @@ -13,6 +13,7 @@ grep -E '^(version|appVersion):' /Chart.yaml | [apache-tika](apache-tika) | Apache Tika | Deployment + Service | no | content extraction for [paperless-ngx](paperless-ngx) | | [bentopdf](bentopdf) | BentoPDF | Deployment + ConfigMap | yes | `config.json` always rendered, `subPath`-mounted into the web root | | [calibre-web-automated](calibre-web-automated) | Calibre-Web Automated | StatefulSet + extra consume PVC | yes | keeps `readOnlyRootFilesystem: false` on purpose — see [§12.4](Chart-Style-Guide#12-values-that-must-not-lie) | +| [convertx](convertx) | ConvertX | StatefulSet + data PVC | yes | **the only chart that overrides `command` to bypass its image entrypoint** — that is what keeps the hardening baseline intact on a rootful image, at the price of re-reading the entrypoint at every app-version bump; probes use `/healthcheck` because `/` is a 302 | | [cyberchef](cyberchef) | CyberChef | Deployment | yes | stateless; the simplest chart in the repo and a good first read | | [freesailarr](freesailarr) | gluetun VPN media stack, ten containers | single-pod StatefulSet | per app | the exception to many rules: no icon, no app-version-driven tags, two ingress shapes | | [gotenberg](gotenberg) | Gotenberg | Deployment + Service | no | PDF conversion for [paperless-ngx](paperless-ngx); sets every limit explicitly | diff --git a/Lessons-Learned.md b/Lessons-Learned.md index de7d422..569304f 100644 --- a/Lessons-Learned.md +++ b/Lessons-Learned.md @@ -194,6 +194,22 @@ On 2026-09-27 an agent reported a "contradiction" in `CLAUDE.md` — the remote **Now:** worktrees are cut from a freshly fetched `origin/main`, diffs are read against `origin/main` and never against the local `main`, and an agent checks a rule against the **file in its own worktree** rather than against its context block. The spawning session's directory has to be current *before* it starts, because a fetch afterwards refreshes nothing that was already handed out. See [Forge and tooling](Forge-and-Tooling#cut-every-worktree-from-a-freshly-fetched-originmain). Generally: **a document delivered as context is a copy with an age, and the copy shows no sign of it** — so what a rule says has to be settled against the source, not against the copy. +## The probe that would have passed on a redirect, and meant something else next week + +Not an outage — the third entry on this page about reading a path off the running service rather than off its name, and the first where the wrong probe would have been **green** rather than red. + +`/` is the obvious probe target for [convertx](convertx), and the kubelet would have accepted it: `httpGet` counts **3xx as success**. But `/` in ConvertX is never a 200. It answers `302` to `/setup` while the instance has no users, and `302` to `/login` from the moment the first account exists. So the probe would have passed on day one by proving that the setup form was reachable, and on day two by proving something entirely different — with no event, no restart and no diff to mark the switch. A redirect target that is a function of application **state** is not a health check; it is a health check per state, and only one of them was ever considered. + +**Now:** all three probes use `/healthcheck`, a static 200 the application exempts from authentication, and the chart's `values.yaml` carries the reason next to the probes so the "inconsistency" is not tidied away later. Generally: **a 3xx makes a probe pass, so a redirecting path hides the question instead of answering it** — and the answer has to hold for every state the application can be in, not just the one it is in while you are testing. Compare [the sure entry above](#the-probe-that-followed-a-redirect-into-the-wrong-scheme): there a redirect turned a correct answer into a failure, here it would have turned an irrelevant answer into a success. + +## The entrypoint we skipped, and the obligation that created + +[convertx](convertx)'s image carries no `USER` and starts as root; its `/entrypoint.sh` exists solely to drop to uid 1000 with `gosu`. Rather than relax the hardening baseline for it, the chart overrides `command` with the image's own `CMD` and lets `runAsUser` and `fsGroup` do that work. Read at v0.19.0 the entrypoint does nothing else — no migration, no config generation — and with `PUID` unset its first branch is already a plain `exec "$@"`. The bypass costs nothing today. + +That is exactly the problem. **The cost is not in the version you verified; it is in the next one.** If upstream ever moves real work into that entrypoint — a data migration is the obvious candidate — the chart skips it with no error, no log line and no failed probe: the application simply starts against an unmigrated volume. Nothing in an app-version bump would surface it, because the bump changes one line in `Chart.yaml` and the entrypoint is not in the diff anyone reads. + +**Now:** the obligation is written **at the `command:` line in the StatefulSet**, not only in the chart page and not only in the pull request — re-read the upstream `entrypoint.sh` at every app-version update, and if it has grown beyond uid/gid handling, stop. Generally: **overriding a container's entrypoint forks it silently, and a fork with no diff is a fork nobody reviews.** The mitigation for a decision whose risk is entirely in the future is to leave the instruction where the future change will be made, not where the decision was argued. + ## The recurring shape - **Green is a claim about the assertions that ran, not about the system.** @@ -203,4 +219,5 @@ On 2026-09-27 an agent reported a "contradiction" in `CLAUDE.md` — the remote - **A safeguard is only a safeguard if it can fail.** Both the missing `required()` and the `required` array that replaced it read as protection; neither could ever have fired. - **Write down what was measured, and say when something was not.** Half of this page exists because someone recorded a number instead of an impression. - **A document handed to you as context is a copy, not the source.** Correcting the file does not correct the copies already delivered, and a stale copy looks exactly as authoritative as a current one — only the file itself, and its diff against `origin/main`, settle what a rule says. +- **Put the warning where the next change will be made.** A risk that only materializes at the next upgrade belongs at the line that upgrade will touch — a note in the pull request that argued the decision is read once, by people who already know. - **An answer that arrived by accident is worth making repeatable.** The all-charts install-test exists because one unplanned run produced a result nobody could have asked for; the fix is a way to ask the question on purpose, not a note to remember the accident. diff --git a/_Sidebar.md b/_Sidebar.md index dbc9892..74512d6 100644 --- a/_Sidebar.md +++ b/_Sidebar.md @@ -22,6 +22,7 @@ - [apache-tika](apache-tika) - [bentopdf](bentopdf) - [calibre-web-automated](calibre-web-automated) +- [convertx](convertx) - [cyberchef](cyberchef) - [freesailarr](freesailarr) - [gotenberg](gotenberg) diff --git a/convertx.md b/convertx.md new file mode 100644 index 0000000..172b783 --- /dev/null +++ b/convertx.md @@ -0,0 +1,254 @@ +# convertx + +Deploys [ConvertX](https://github.com/C4illin/ConvertX), a self-hosted file converter with a web UI, as a single-replica StatefulSet. Everything it persists — the SQLite database, the uploaded files and the converted results — lives on one PVC. There is no external database, cache or object storage. + +| | | +|---|---| +| Image | `ghcr.io/c4illin/convertx` | +| Versions | not listed on purpose — read the current pair off `convertx/Chart.yaml`, see [Chart index](Charts) | +| Workloads | StatefulSet (1 replica) | +| Storage | one RWO PVC at `/app/data`, plus two emptyDirs (`/tmp`, `/home/convertx`) | +| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required **while enabled** | +| External dependencies | none | +| Secrets | 1 × `OnePasswordItem` | +| Install-test | `ci/convertx/` (test values, the JWT Secret fixture, raised `INSTALL_TIMEOUT`) | + +## Prerequisites + +- **1Password operator** in the cluster, plus one 1Password item — see [the fields below](#what-the-1password-item-must-contain). +- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller. +- **Disk and patience on the node.** The image is ~1.4 GiB compressed (one layer alone is 1354 MiB) because LibreOffice, Calibre, ImageMagick, ffmpeg, Inkscape and a TeX Live installation are inside it. On a node that has not seen it, the first pod sits in `ContainerCreating` for minutes before the startup probe budget even begins. +- **Nothing else.** No Postgres, no Redis, no S3 — which is why one PVC snapshot is a complete backup of a release. + +## Installation + +The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file. + +```bash +helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS" +helm repo update + +helm install convertx jlab/convertx \ + --namespace convertx --create-namespace \ + -f my-values.yaml +``` + +Minimal `my-values.yaml`: + +```yaml +secrets: + jwtSecretRef: op://vault/convertx + +ingress: + domain: convert.example.com + clusterIssuer: letsencrypt +``` + +`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page. + +**Then open the URL yourself, immediately.** See [Whoever opens the page first becomes the administrator](#whoever-opens-the-page-first-becomes-the-administrator) — this is the one step of a ConvertX install that cannot be repaired afterwards. + +### What the 1Password item must contain + +The chart renders a `OnePasswordItem` CR; the operator turns it into a Kubernetes Secret whose **keys are the field names of the 1Password item**. A missing field does not fail the install: the release goes through and the pod then hangs in `CreateContainerConfigError` naming the key it could not find. + +| Values key | Kubernetes Secret | Field | +|---|---|---| +| `secrets.jwtSecretRef` | `-convertx-secret` | `jwt-secret` — a long random string, `openssl rand -hex 32` | + +**Why this is required rather than optional.** ConvertX signs its session cookies with `JWT_SECRET` and falls back to `randomUUID()` when the variable is unset — a fresh secret **per process**, not per install. Every restart (a node drain, an image update, an OOM kill) would then invalidate every session, with no log line and nothing in the UI to say so. A value that has to survive a restart is a secret, so the chart makes it one and guards it with `required()`. + +## Configurable values + +### Global + +| Key | Type | Default | Description | +|---|---|---|---| +| `scaleDown` | bool | `false` | Renders the StatefulSet with `replicas: 0` without uninstalling. Takes precedence over `convertx.replicas`. PVC, Secret, Service and Ingress stay. | + +### `secrets` + +| Key | Type | Default | Description | +|---|---|---|---| +| `secrets.jwtSecretRef` | string | `null` | **Required.** `op://` path of the item carrying `jwt-secret`. | + +### `convertx` + +| Key | Type | Default | Description | +|---|---|---|---| +| `convertx.replicas` | int | `1` | Replica count; `0` is allowed. See [One replica, one volume](#one-replica-one-volume) — anything above 1 does not work. | +| `convertx.accountRegistration` | bool | `false` | `ACCOUNT_REGISTRATION`. Lets anyone with the URL create an account. Does **not** gate the first one. See [Two switches that open the instance up](#two-switches-that-open-the-instance-up). | +| `convertx.allowUnauthenticated` | bool | `false` | `ALLOW_UNAUTHENTICATED`. Removes the login entirely. Upstream describes this as local-only. | +| `convertx.httpAllowed` | bool | `false` | `HTTP_ALLOWED`. Clears the `secure` flag on the session cookies. Only needed without TLS in front; behind this chart's Ingress the browser speaks https and the default is correct. | +| `convertx.autoDeleteEveryNHours` | int | `24` | `AUTO_DELETE_EVERY_N_HOURS`. Deletes jobs older than this many hours — uploads **and** output. `0` disables it and is honoured as such. This is the retention policy for the PVC. | +| `convertx.securityContext.pod` | object | non-root **1000**, `fsGroup: 1000`, `fsGroupChangePolicy: Always`, `seccompProfile: RuntimeDefault` | Passed verbatim into the pod spec. The uid is pinned deliberately — see [Why uid 1000 and not 20000](#why-uid-1000-and-not-20000). | +| `convertx.securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation | Passed verbatim into the container spec. See [What has to be writable](#what-has-to-be-writable). | +| `convertx.livenessProbe` | object | `GET /healthcheck` on port `http`, period 10s, timeout 5s, 3 failures | See [Why the probes use `/healthcheck` and not `/`](#why-the-probes-use-healthcheck-and-not-). | +| `convertx.readinessProbe` | object | same | | +| `convertx.startupProbe` | object | `GET /healthcheck`, period 10s, 30 failures (= 300s) | Gates liveness and readiness. Generous because ConvertX shells out to ~20 converter binaries at startup just to log their versions. | +| `convertx.env` | list | `[]` | Extra environment variables appended to the chart's own. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables), and [What does not belong in `convertx.env`](#what-does-not-belong-in-convertxenv). | +| `convertx.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. | +| `convertx.resources.requests` | object | `cpu: 0.25`, `memory: 1Gi`, `ephemeralStorage: 1Gi` | Sized for conversions, not for an idle web server — see [Sizing](#sizing-the-numbers-are-for-conversions). | +| `convertx.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. | + +The three booleans also accept the **strings** `"true"` / `"false"` (any case). Anything else — `yes`, `1`, `on` — **aborts the render** with a message naming the values path; see [Why `yes` is a render error](#why-yes-is-a-render-error). + +### `storage` + +| Key | Type | Default | Description | +|---|---|---|---| +| `storage.amount` | string | `10Gi` | Size of the data PVC. | +| `storage.storageClass` | string | `null` | Storage class; `null` uses the cluster default. | + +Deliberately **not** null-safe, following the documented exception ([§12.2](Chart-Style-Guide#12-values-that-must-not-lie)): an erased block yields a PVC without a size, which the API server rejects at apply time. That is louder than a volume quietly sized by the chart. + +### `ingress` + +| Key | Type | Default | Description | +|---|---|---|---| +| `ingress.enabled` | bool | `true` | Switches the Ingress object off. Unlike outline or searxng, this frees **both** `domain` and `clusterIssuer` — ConvertX has no base-URL, public-URL or redirect-URI variable, so nothing outside the Ingress reads the domain. Checked in both directions, as [§12.1](Chart-Style-Guide#12-values-that-must-not-lie) requires. | +| `ingress.domain` | string | `null` | **Required while the Ingress is enabled.** | +| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. | +| `ingress.className` | string | `nginx` | Ingress class. | +| `ingress.maxUploadFileSize` | string | `1024m` | `proxy-body-size` annotation — and **the only upload ceiling that exists**. See [The ingress is the only upload ceiling](#the-ingress-is-the-only-upload-ceiling). | +| `ingress.annotations` | object | `{}` | Merged with the cert-manager and body-size annotations the chart always sets. | + +TLS is stored in `tls--convertx-ingress`. + +## Operating notes + +### Whoever opens the page first becomes the administrator + +A ConvertX with no users serves a **setup form to every visitor** — no password, no invitation, nothing to bypass. The first account created that way owns the instance. + +`convertx.accountRegistration` does **not** gate this. That value decides whether accounts two-and-onwards can be created; the first one is always creatable, because otherwise the instance could never be set up at all. + +So register yourself before the DNS name is reachable by anyone else. There is no "reset admin" command: the repair is deleting the `users` row from the SQLite database on the PVC. + +### This chart bypasses the image entrypoint + +The image carries **no `USER`**, so it starts as root, and `/entrypoint.sh` exists solely to drop to uid 1000 with `gosu`. The chart sets `command` to the image's own `CMD` (`bun run dist/src/index.js`), which replaces the entrypoint, and lets Kubernetes do the privilege work instead: `runAsUser`/`runAsGroup: 1000`, and `fsGroup: 1000` in place of the entrypoint's recursive `chown` of `/app/data`. The hardening baseline is therefore **kept**, not relaxed — this is not a documented deviation from §5, it is the opposite. + +**What the entrypoint actually does, read rather than assumed:** nothing but uid/gid plumbing. No migration, no config generation, no secret generation. ConvertX creates and migrates its SQLite schema **in-process at import time**, so starting it directly is migration-safe. At v0.19.0 its first branch is already a plain pass-through when `PUID` is unset (`exec "$@"`), so today the bypass costs nothing at all. + +**The price, and it is in the future tense:** if upstream ever moves real work into that entrypoint — a data migration is the obvious candidate — this chart skips it **silently**. No error, no log line, no failed probe; the application simply starts against an unmigrated volume. So: + +> **Re-read the upstream `entrypoint.sh` at every app-version update, not only at major jumps.** If it has grown anything beyond uid/gid handling, stop and reconsider the override. + +That instruction is also a comment at the `command:` line in the StatefulSet, which is where somebody bumping the version will actually be looking. + +The one genuinely load-bearing line the bypass loses is `export HOME=/home/convertx`. The chart sets `HOME` itself rather than relying on the container runtime's `/etc/passwd` lookup — that lookup does resolve correctly in this image, but "happens to" is not a basis for the directory LibreOffice writes its profile into. + +### Why uid 1000 and not 20000 + +The repo default is an arbitrary uid (20000). This chart pins 1000, and the deviation is about the **number only** — no part of the hardening baseline is relaxed. Two reasons, and both have to hold: + +- `/home/convertx` belongs to `1000:1000` in the image (`useradd -m`), and that is `HOME`, where LibreOffice, Calibre and fontconfig put their profiles. An arbitrary uid gets a `HOME` of `/` from the runtime's passwd lookup instead. +- The uid **owns data on a PersistentVolume**. Changing it later makes existing conversions inaccessible, so the number is not free to tidy. + +The fallback defaults in `_helpers.tpl` reproduce the 1000, per [§12.4](Chart-Style-Guide#12-values-that-must-not-lie) — restoring the "clean" repo standard there would be an outage, not housekeeping. + +### What has to be writable + +`readOnlyRootFilesystem: true` holds, with two emptyDirs. Neither is a precaution; both were derived from what the code actually does. + +| Path | Kind | Why | +|---|---|---| +| `/app/data` | PVC | SQLite database, uploads, converted output — every write the **application** makes | +| `/home/convertx` | emptyDir | `HOME`. LibreOffice is invoked **without** `-env:UserInstallation`, so its user profile can only be created under `$HOME`; Calibre, Inkscape, ImageMagick and fontconfig do the same with their config and cache directories | +| `/tmp` | emptyDir | scratch space for Ghostscript, TeX Live and Calibre during a conversion | + +The application itself writes nowhere outside `/app/data`. The emptyDirs exist for the **converter subprocesses**: all twenty converters call `execFile(cmd, args, cb)` without a `cwd` or `env` option, so every one of them inherits the app's `HOME` and working directory. + +`/app` itself stays read-only, and `workingDir: /app` is pinned in the StatefulSet for a reason that is easy to miss: `uploadsDir` and `outputDir` are the relative literals `./data/uploads/` and `./data/output/`, configurable by no environment variable. A different working directory would put every upload somewhere other than the PVC — and the application would report no error at all. + +### Why the probes use `/healthcheck` and not `/` + +ConvertX serves `GET /healthcheck`: a static `200` with `{"status":"ok"}`, explicitly exempt from authentication. All three probes use it. + +`/` would have been the obvious default and is the wrong one. With this chart's defaults it is **never** a 200: it answers `302` to `/setup` while no account exists and `302` to `/login` afterwards. `httpGet` counts 3xx as success, so a probe against `/` would have *passed* — on a redirect whose target changes the moment somebody registers. That is the quiet kind of wrong, which is why the reason is written into the chart rather than only fixed. + +**What a green pod proves, and what it does not.** The handler touches neither the database nor the volume, so it cannot report a broken PVC — a green pod means the process is serving, not that a conversion works. It is still the right readiness target here: the SQLite database is opened at module import, **before** `app.listen()` is reached, so a process that answers at all has already opened its database. There is no external dependency in this chart to hang readiness on ([§12.3](Chart-Style-Guide#12-values-that-must-not-lie)); the "database" is a file on the pod's own volume. + +### Two switches that open the instance up + +`convertx.accountRegistration` and `convertx.allowUnauthenticated`, both `false` by default. **Upstream warns explicitly against enabling either on an instance reachable from the internet**, and this chart puts a public DNS name in front by default, so that warning applies to the standard install here. + +- `accountRegistration: true` — anyone with the URL can sign up. +- `allowUnauthenticated: true` — there is no login at all. Anyone who reaches the Service can upload files and read **every** conversion on the volume. + +`NOTES.txt` prints a block warning if either is on, naming which. + +### Why `yes` is a render error + +ConvertX parses each of these variables as `process.env.X?.toLowerCase() === "true"`, so anything that is not the literal `true` means **false** — `yes`, `1`, `on`, a trailing space. Two of the three are the switches above, and a value written to turn one **on** that silently lands on **off** is a failure nobody goes looking for, because the symptom is the safe state. + +So the chart renders these through a helper that emits the literal `"true"`/`"false"` and **aborts the render** on anything else, naming the values path. This is [§12.6](Chart-Style-Guide#12-values-that-must-not-lie) applied to a pass-through value: a value that cannot be read as either boolean belongs in a `fail`, not in an implicit branch. + +### The ingress is the only upload ceiling + +ConvertX enforces **no size limit of its own** — it starts its HTTP server with `maxRequestBodySize: Number.MAX_SAFE_INTEGER`, and v0.19.0 has no upload variable of any kind. So unlike [outline](outline#two-upload-ceilings-the-smaller-one-wins), where an app ceiling and an ingress ceiling sat a factor of ten apart, there is exactly one number here: `ingress.maxUploadFileSize`, defaulted generously to `1024m`. + +Two consequences worth knowing before changing it: + +- A file above the limit **never reaches ConvertX**. The user gets the ingress controller's 413 page and nothing appears in the pod log, which looks like a broken upload button rather than a limit. +- With `ingress.enabled: false` the chart sets no ceiling anywhere, and whatever proxy is in front brings its own — ingress-nginx defaults to `1m`. +- Raising it has a second cost: the upload lands on the PVC, so a ceiling far beyond `storage.amount` only moves the failure from a 413 to a full volume. + +### Retention is a value + +`convertx.autoDeleteEveryNHours` (default 24) is what empties the volume, and it is the **only** thing that does. It is a hard delete of the job's uploads and output, not an archive: a conversion nobody downloaded in time is gone. + +`0` switches the cleanup off, and it is honoured as such — the chart uses an explicit nil check, because Helm's `default` swallows a `0` and would restore 24-hour deletion on the one install that asked for none. With the cleanup off, size `storage.amount` for unbounded growth. + +### One replica, one volume + +The SQLite database, the uploads and the output all live on the single **ReadWriteOnce** PVC, which is a plain chart object rather than a `volumeClaimTemplate`. A second pod normally never schedules (`Multi-Attach error for volume`); on a genuinely RWX storage class it *would* schedule and then corrupt the WAL-mode SQLite file two processes are now writing. `convertx.replicas` above 1 does not work, and `NOTES.txt` says so if it is set. + +### Sizing: the numbers are for conversions + +- `cpu: 0.25` — an idle Bun process uses almost nothing, but a conversion saturates whatever it can get. Per repo convention **no CPU limit** is computed, so this is the scheduling floor and the throttling share under contention, not a cap. +- `memory: 1Gi` (limit 3Gi) — `soffice --headless` is a few hundred MB on its own, `ebook-convert` pulls in Qt WebEngine, and ImageMagick on a large raster can want more than either. Memory is not compressible: a conversion crossing the limit is an **OOM kill of the whole pod**, so raise the request before feeding the instance large video or raw-image work. +- `ephemeral-storage: 1Gi` (limit 3Gi) — and this is the one that is easy to get wrong: **emptyDir volumes count against it.** The chart mounts two, and Ghostscript, TeX and Calibre all scratch to `/tmp`. Uploads and output do *not* count — those are on the PVC — so the figure covers scratch space only. Crossing the limit **evicts** the pod. + +### What does not belong in `convertx.env` + +The block is the right place for the upstream variables the chart does not expose: `BRANDING`, `LANGUAGE`, `HIDE_HISTORY`, `MAX_CONVERT_PROCESS`, `UNAUTHENTICATED_USER_SHARING`, `TZ`, `FFMPEG_ARGS`, `FFMPEG_OUTPUT_ARGS`. Three variables do **not** belong there, and they fail in three different ways: + +| Variable | What happens | +|---|---| +| `WEBROOT` | Moves **every** route of the application, `/healthcheck` included, so all three probes start failing against a 404 while the app is perfectly healthy — and this chart's Ingress still maps `/`. Serving under a sub-path needs the probe paths and the Ingress path changed together, which the chart does not do for you. | +| `PUID` / `PGID` / `UMASK` | Read by the image entrypoint, which this chart bypasses. They reach nothing and configure nothing. Set the uid through `convertx.securityContext.pod`. | +| `PORT` | The app would listen on it, but `containerPort`, the Service's `targetPort` and the three probes all still name 3000. Moving the port means moving all four, so the chart pins it. | + +### Rotating the secret does not restart the pod by itself + +Helm never sees a `OnePasswordItem`'s content, so it cannot hash it ([§9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). What the chart *can* cover it does: the pod template carries `checksum/secret-ref` over the rendered `OnePasswordItem`, so pointing `secrets.jwtSecretRef` at a **different** item rolls the pod. Editing the value **inside** the same item does not. After such a rotation: + +```bash +kubectl -n rollout restart statefulset/-convertx-sfs +``` + +Rotating `jwt-secret` invalidates every existing session — which is the intended behaviour of a rotation, but worth announcing first. + +`operator.1password.io/auto-restart` is deliberately **not** set: the operator only restarts Deployments, so on a StatefulSet it is a silent no-op that advertises a guarantee it does not give. + +## Upgrades and backup + +- **One PVC snapshot is a complete backup.** No external database, no object storage — which also means losing the volume loses the accounts along with the files. +- **`helm uninstall` deletes the PVC** (it is a chart object), unless the storage class reclaim policy says otherwise. +- **Re-read the upstream `entrypoint.sh` at every app-version bump.** See [the bypass section](#this-chart-bypasses-the-image-entrypoint) — this is the one recurring obligation this chart creates. +- **Expect a slow first pull after every bump.** ~1.4 GiB, so the node re-pulls a large layer whenever the tag moves. + +## CI + +`ci/convertx/` holds `test-values.yaml` (an `op://` ref against the stub CRD, `convertx.ci.local`, a 1Gi volume), `fixtures.yaml` (the `jwt-secret` Secret the stub `OnePasswordItem` CRD cannot materialize — without it the pod hangs in `CreateContainerConfigError`) and `settings.env` with a raised `INSTALL_TIMEOUT`, because the rollout wait has to cover the ~1.4 GiB **pull** and not just the start. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD). + +Renovate tracks `appVersion` with a regex anchored on the `v` prefix. The registry carries the versioned family next to three moving tags — `latest`, `main` and one `pr-` per open pull request — and `v0.19.0` currently shares a digest with `latest`, which is exactly the trap the anchor exists for. See [Renovate](Renovate). + +## See also + +- [Chart style guide](Chart-Style-Guide) — the rules this chart implements +- [Versioning](Versioning) — what a bump of `1.0.0+upv0.19.0` means +- [Lessons learned](Lessons-Learned) +- [Chart index](Charts)