WIP: feat(convertx): new chart for ConvertX (1.0.0+upv0.19.0) #61

Draft
jlab-bot wants to merge 2 commits from feat/convertx-chart into main
pull from: feat/convertx-chart
Member

Fixes #60

New chart for ConvertX (C4illin/ConvertX), a self-hosted file converter with a web UI. 1.0.0+upv0.19.0.

The central decision, and how it turned out

The chart runs non-root as uid/gid 1000 and bypasses the image entrypoint, as decided on the issue. command is set to the image's own CMD (bun run dist/src/index.js), runAsUser/runAsGroup replace the entrypoint's gosu, and fsGroup replaces its recursive chown of /app/data. The hardening baseline is not relaxed anywhere — non-root, readOnlyRootFilesystem: true, all capabilities dropped, allowPrivilegeEscalation: false, seccompProfile: RuntimeDefault.

The risk is written at the command: line itself, not only here: if upstream ever moves real work into the entrypoint, this chart skips it silently — no error, no log line, no failed probe. So the entrypoint is to be re-read at every app-version update.

One thing turned out better than the plan assumed, and it is worth recording. The plan's premise was that the entrypoint fails immediately as non-root. That is true only on its PUID-set path. Read at v0.19.0, its first branch is:

if [ -z "$PUID" ] || [ "$PUID" -eq 0 ]; then
  if [ -n "$UMASK" ]; then umask "$UMASK"; fi
  if [ $# -eq 0 ]; then set -- bun run dist/src/index.js; fi
  exec "$@"
fi

With PUID unset it is already a pure pass-through — no chown, no usermod, no gosu. So bypassing it costs nothing today, which does not weaken the decision but bounds it precisely: what we skip now is an exec, and the risk is entirely about what upstream might add later. That framing is in the comment.

It also confirms the bypass is migration-safe: ConvertX creates and migrates its SQLite schema in-process at import time (src/db/db.ts calls initializeDatabase on load), not in the entrypoint.

The one genuinely load-bearing line the bypass loses is export HOME=/home/convertx. The chart sets HOME explicitly rather than relying on the container runtime's /etc/passwd lookup for uid 1000 — that lookup does resolve correctly in this image, but "happens to" is not a basis for the directory LibreOffice writes its profile into.

The four things that had to be measured

1. Does it start as uid 1000 without the entrypoint? — Unverified at runtime. This workspace has no Docker daemon and no k3d (both checked), so the PR install-test is the only runtime proof; the evidence below is static. The image config of v0.19.0 (pulled from ghcr, sha256:8590a21a…) has "User" empty, WorkingDir: /app, Entrypoint: ["/entrypoint.sh"], Cmd: ["bun","run","dist/src/index.js"]. The Dockerfile creates the user as useradd -u 1000 -g convertx -d /home/convertx -m, so uid 1000 exists, /home/convertx exists and is owned by 1000:1000, and /app/data exists (RUN mkdir data). Nothing in the start path needs a privilege once HOME and the volume are handled.

2. Which paths must be writable? — Derived from the source, not scattered preventively. Grepping every filesystem write in src/ gives exactly four sites, all under ./data (db.ts, upload.tsx, convert.tsx, download.tsx): the application itself writes nowhere outside /app/data. The two emptyDirs are needed for the converter subprocesses, which all use the 3-argument execFile(cmd, args, cb) form — not one of the 20 converters passes a cwd or env, so every one of them inherits the app's HOME and working directory:

Path Why How found
/app/data (PVC) SQLite + uploads + output uploadsDir = "./data/uploads/", outputDir = "./data/output/", DB_PATH ?? "./data/mydb.sqlite"
/home/convertx (emptyDir) HOME. LibreOffice is invoked without -env:UserInstallation, so its profile can only go to $HOME/.config/libreoffice; Calibre, Inkscape, ImageMagick and fontconfig likewise libreoffice.ts argv, calibre.ts argv
/tmp (emptyDir) Ghostscript, TeX Live and Calibre scratch toolchain, not app source

/app itself stays read-only — nothing writes there, and xelatex is called with -output-directory, so even its aux files land under ./data. This is also why the chart pins workingDir: /app: uploadsDir/outputDir are relative and configurable by no environment variable, so a different working directory would put every upload somewhere other than the PVC, silently.

3. Does / answer 2xx/3xx, and is it a meaningful readiness probe? — / is a 3xx, and it is the wrong probe. There is a real health endpoint, and the plan's premise that none is documented turned out to be wrong.

src/pages/healthcheck.tsx registers GET /healthcheck returning {"status":"ok"} with auth: false — a static 200, explicitly unauthenticated. All three probes use it.

/ was read off the route, not the path name: src/pages/root.tsx returns redirect(WEBROOT + "/setup", 302) while no account exists and redirect(WEBROOT + "/login", 302) 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, and it is why the reason is written into values.yaml and NOTES.txt rather than just fixed.

What /healthcheck proves and what it does not is documented too: the handler touches neither the database nor the volume, so it cannot report a broken PVC. It is still the right readiness target here — the SQLite database is opened at module import, before app.listen(), so a process answering at all has already opened its database, and there is no external dependency in this chart to hang readiness on.

4. How long does the install-test take? — Not yet known; the number goes in a follow-up comment on this PR once the run finishes, together with a note on the all-charts expectation from Gitea #52 if it moves it. INSTALL_TIMEOUT=900 is set on the estimate: the image is 1467.7 MiB compressed in 14 layers, one of them 1354.3 MiB (measured against the ghcr manifest), which against the runner's 96 MB / 17.5s reference is around five minutes of pulling before the container is created, plus the 300s startup budget.

What is in the chart

StatefulSet (one replica) + Service + PVC at /app/data + OnePasswordItem, replicas, chart-global scaleDown, strict values.schema.json with additionalProperties: false on every chart-owned level, null-safe fallbacks for probes/contexts/resources, additive env block.

  • JWT_SECRET as a required OnePasswordItem (secrets.jwtSecretRef, field jwt-secret). Unset, the app does process.env.JWT_SECRET ?? randomUUID() — a fresh secret per process, so every restart invalidates every session, with nothing in the log. Named in NOTES.txt.
  • accountRegistration, allowUnauthenticated, httpAllowed, all false. Upstream's warning against the first two on an internet-facing instance is carried into both values.yaml and NOTES.txt, and NOTES.txt shouts if either is on. httpAllowed only clears the cookie secure flag and is unnecessary behind our TLS-terminating Ingress — the outline forceHttps shape.
  • The three booleans go through a helper that aborts the render on anything that is not true/false. The app compares process.env.X?.toLowerCase() === "true", so yes, 1 or on mean false — and for two values whose job is to open the service up, a setting that silently lands on the safe state is a setting nobody goes looking for. Implements README "Values that must not lie", point 6.
  • autoDeleteEveryNHours, default 24, 0 disables — it is the retention policy for the PVC. The 0 is honoured through an explicit kindIs "invalid" check; Helm's default would swallow it and restore 24-hour deletion on the one install that asked for none.
  • ingress.maxUploadFileSize, default 1024m. ConvertX enforces no limit at all (maxRequestBodySize: Number.MAX_SAFE_INTEGER, and v0.19.0 has no upload variable of any kind), so unlike outline's two-ceiling trap there is exactly one ceiling and this is it. NOTES.txt names the consequence: a file above it never reaches the app, the user gets the controller's 413 page and nothing appears in the pod log.
  • uid 1000, not the repo's 20000 — documented as a number-only deviation in values.yaml and reproduced in the _helpers.tpl fallbacks, because the image owns /home/convertx with it and the uid owns PV data.
  • ingress.enabled: false frees BOTH domain and clusterIssuer, checked in both directions as the style guide requires: the complete env list in src/helpers/env.ts has no base-URL, public-URL or redirect-URI variable, so nothing outside the Ingress reads the domain.
  • WEBROOT is deliberately not a value, and values.yaml/NOTES.txt say so at the env block: it shifts every route including /healthcheck, so it would break all three probes while the app stayed healthy — and this chart's Ingress still maps /. PUID/PGID/UMASK are likewise not values: they configure the bypassed entrypoint and nothing else.
  • NOTES.txt prints the access URL, the 1Password field, that whoever opens the page first becomes the administrator (a userless ConvertX serves a setup form to every visitor, and accountRegistration gates accounts two-and-onwards, not the first), the upload ceiling, the retention setting, the slow first pull, and the entrypoint bypass. No real domain anywhere.

Wiring

publish-convertx.yml; convertx in the change-app-version.yml choice list; ci/convertx/ with test-values.yaml, fixtures.yaml (the jwt-secret Secret the stub OnePasswordItem CRD cannot materialize — without it the pod hangs in CreateContainerConfigError) and settings.env; a renovate.json5 block; the README chart row.

The Renovate block is anchored on the v prefix, ^v(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)$, following the ytdlp-web-player precedent. The registry carries 107 tags: the versioned family plus latest, main and one pr-<n> per pull request. v0.19.0 and latest currently share a digest, which is exactly the trap — latest would look right today and move on the next upstream push. There is no bare x.y.z family, so the v is not optional.

Verified locally / not verified

Verified: helm lint --strict clean; render against the CI fixtures; helm package produces convertx-1.0.0+upv0.19.0.tgz; the null-safe fallbacks (every one of securityContext, the three probes and resources erased with --set …=null falls back to this chart's defaults, uid 1000 and /healthcheck included); the boolean helper aborting on yes; autoDeleteEveryNHours=0 rendering "0"; scaleDown rendering replicas: 0; the schema rejecting an unknown key; ingress.enabled=false rendering without domain/clusterIssuer; ci/run-quantity-render-test.sh convertx exercising the helper in both cases; and NOTES.txt through the define/ConfigMap trick in a throwaway copy, since helm template does not render it.

Not verified: anything at runtime. This workspace has no Docker daemon and no k3d, so the install-test in this PR is the only runtime proof — that the app starts as uid 1000 without its entrypoint, that the two emptyDirs are the right ones, and that /healthcheck answers. trivy is not installed here either, so the misconfiguration scan runs for the first time in CI.

Fixes #60 New chart for **ConvertX** (C4illin/ConvertX), a self-hosted file converter with a web UI. `1.0.0+upv0.19.0`. ## The central decision, and how it turned out The chart runs **non-root as uid/gid 1000 and bypasses the image entrypoint**, as decided on the issue. `command` is set to the image's own `CMD` (`bun run dist/src/index.js`), `runAsUser`/`runAsGroup` replace the entrypoint's `gosu`, and `fsGroup` replaces its recursive `chown` of `/app/data`. The hardening baseline is not relaxed anywhere — non-root, `readOnlyRootFilesystem: true`, all capabilities dropped, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault`. The risk is written at the `command:` line itself, not only here: if upstream ever moves real work into the entrypoint, this chart skips it silently — no error, no log line, no failed probe. So the entrypoint is to be re-read at **every** app-version update. **One thing turned out better than the plan assumed, and it is worth recording.** The plan's premise was that the entrypoint fails immediately as non-root. That is true only on its `PUID`-set path. Read at v0.19.0, its *first* branch is: ```bash if [ -z "$PUID" ] || [ "$PUID" -eq 0 ]; then if [ -n "$UMASK" ]; then umask "$UMASK"; fi if [ $# -eq 0 ]; then set -- bun run dist/src/index.js; fi exec "$@" fi ``` With `PUID` unset it is already a pure pass-through — no `chown`, no `usermod`, no `gosu`. So bypassing it costs **nothing today**, which does not weaken the decision but bounds it precisely: what we skip now is an `exec`, and the risk is entirely about what upstream might add later. That framing is in the comment. It also confirms the bypass is migration-safe: ConvertX creates and migrates its SQLite schema **in-process at import time** (`src/db/db.ts` calls `initializeDatabase` on load), not in the entrypoint. The one genuinely load-bearing line the bypass loses is `export HOME=/home/convertx`. The chart sets `HOME` explicitly rather than relying on the container runtime's `/etc/passwd` lookup for uid 1000 — that lookup *does* resolve correctly in this image, but "happens to" is not a basis for the directory LibreOffice writes its profile into. ## The four things that had to be measured **1. Does it start as uid 1000 without the entrypoint?** — **Unverified at runtime.** This workspace has no Docker daemon and no k3d (both checked), so the PR install-test is the only runtime proof; the evidence below is static. The image config of `v0.19.0` (pulled from ghcr, `sha256:8590a21a…`) has `"User"` empty, `WorkingDir: /app`, `Entrypoint: ["/entrypoint.sh"]`, `Cmd: ["bun","run","dist/src/index.js"]`. The Dockerfile creates the user as `useradd -u 1000 -g convertx -d /home/convertx -m`, so uid 1000 exists, `/home/convertx` exists and is owned by `1000:1000`, and `/app/data` exists (`RUN mkdir data`). Nothing in the start path needs a privilege once `HOME` and the volume are handled. **2. Which paths must be writable?** — Derived from the source, not scattered preventively. Grepping every filesystem write in `src/` gives exactly four sites, all under `./data` (`db.ts`, `upload.tsx`, `convert.tsx`, `download.tsx`): the application itself writes **nowhere** outside `/app/data`. The two emptyDirs are needed for the *converter subprocesses*, which all use the 3-argument `execFile(cmd, args, cb)` form — not one of the 20 converters passes a `cwd` or `env`, so every one of them inherits the app's `HOME` and working directory: | Path | Why | How found | |---|---|---| | `/app/data` (PVC) | SQLite + uploads + output | `uploadsDir = "./data/uploads/"`, `outputDir = "./data/output/"`, `DB_PATH ?? "./data/mydb.sqlite"` | | `/home/convertx` (emptyDir) | `HOME`. LibreOffice is invoked **without** `-env:UserInstallation`, so its profile can only go to `$HOME/.config/libreoffice`; Calibre, Inkscape, ImageMagick and fontconfig likewise | `libreoffice.ts` argv, `calibre.ts` argv | | `/tmp` (emptyDir) | Ghostscript, TeX Live and Calibre scratch | toolchain, not app source | `/app` itself stays read-only — nothing writes there, and `xelatex` is called with `-output-directory`, so even its aux files land under `./data`. This is also why the chart pins `workingDir: /app`: `uploadsDir`/`outputDir` are **relative and configurable by no environment variable**, so a different working directory would put every upload somewhere other than the PVC, silently. **3. Does `/` answer 2xx/3xx, and is it a meaningful readiness probe?** — **`/` is a 3xx, and it is the wrong probe. There is a real health endpoint, and the plan's premise that none is documented turned out to be wrong.** `src/pages/healthcheck.tsx` registers `GET /healthcheck` returning `{"status":"ok"}` with `auth: false` — a static 200, explicitly unauthenticated. All three probes use it. `/` was read off the route, not the path name: `src/pages/root.tsx` returns `redirect(WEBROOT + "/setup", 302)` while no account exists and `redirect(WEBROOT + "/login", 302)` 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, and it is why the reason is written into `values.yaml` and `NOTES.txt` rather than just fixed. What `/healthcheck` proves and what it does not is documented too: the handler touches neither the database nor the volume, so it cannot report a broken PVC. It is still the right readiness target here — the SQLite database is opened at module import, *before* `app.listen()`, so a process answering at all has already opened its database, and there is no external dependency in this chart to hang readiness on. **4. How long does the install-test take?** — **Not yet known; the number goes in a follow-up comment on this PR once the run finishes**, together with a note on the all-charts expectation from Gitea #52 if it moves it. `INSTALL_TIMEOUT=900` is set on the estimate: the image is **1467.7 MiB compressed in 14 layers, one of them 1354.3 MiB** (measured against the ghcr manifest), which against the runner's 96 MB / 17.5s reference is around five minutes of pulling before the container is created, plus the 300s startup budget. ## What is in the chart StatefulSet (one replica) + Service + PVC at `/app/data` + `OnePasswordItem`, `replicas`, chart-global `scaleDown`, strict `values.schema.json` with `additionalProperties: false` on every chart-owned level, null-safe fallbacks for probes/contexts/resources, additive `env` block. - **`JWT_SECRET` as a required `OnePasswordItem`** (`secrets.jwtSecretRef`, field `jwt-secret`). Unset, the app does `process.env.JWT_SECRET ?? randomUUID()` — a fresh secret **per process**, so every restart invalidates every session, with nothing in the log. Named in `NOTES.txt`. - **`accountRegistration`, `allowUnauthenticated`, `httpAllowed`, all `false`.** Upstream's warning against the first two on an internet-facing instance is carried into both `values.yaml` and `NOTES.txt`, and `NOTES.txt` shouts if either is on. `httpAllowed` only clears the cookie `secure` flag and is unnecessary behind our TLS-terminating Ingress — the outline `forceHttps` shape. - **The three booleans go through a helper that aborts the render on anything that is not `true`/`false`.** The app compares `process.env.X?.toLowerCase() === "true"`, so `yes`, `1` or `on` mean **false** — and for two values whose job is to open the service up, a setting that silently lands on the safe state is a setting nobody goes looking for. Implements README "Values that must not lie", point 6. - **`autoDeleteEveryNHours`, default 24, `0` disables** — it is the retention policy for the PVC. The `0` is honoured through an explicit `kindIs "invalid"` check; Helm's `default` would swallow it and restore 24-hour deletion on the one install that asked for none. - **`ingress.maxUploadFileSize`, default `1024m`.** ConvertX enforces no limit at all (`maxRequestBodySize: Number.MAX_SAFE_INTEGER`, and v0.19.0 has no upload variable of any kind), so unlike outline's two-ceiling trap there is exactly **one** ceiling and this is it. `NOTES.txt` names the consequence: a file above it never reaches the app, the user gets the controller's 413 page and nothing appears in the pod log. - **uid 1000, not the repo's 20000** — documented as a number-only deviation in `values.yaml` and reproduced in the `_helpers.tpl` fallbacks, because the image owns `/home/convertx` with it and the uid owns PV data. - **`ingress.enabled: false` frees BOTH `domain` and `clusterIssuer`**, checked in both directions as the style guide requires: the complete env list in `src/helpers/env.ts` has no base-URL, public-URL or redirect-URI variable, so nothing outside the Ingress reads the domain. - **`WEBROOT` is deliberately not a value**, and `values.yaml`/`NOTES.txt` say so at the `env` block: it shifts every route including `/healthcheck`, so it would break all three probes while the app stayed healthy — and this chart's Ingress still maps `/`. `PUID`/`PGID`/`UMASK` are likewise not values: they configure the bypassed entrypoint and nothing else. - `NOTES.txt` prints the access URL, the 1Password field, that **whoever opens the page first becomes the administrator** (a userless ConvertX serves a setup form to every visitor, and `accountRegistration` gates accounts two-and-onwards, not the first), the upload ceiling, the retention setting, the slow first pull, and the entrypoint bypass. No real domain anywhere. ## Wiring `publish-convertx.yml`; `convertx` in the `change-app-version.yml` choice list; `ci/convertx/` with `test-values.yaml`, `fixtures.yaml` (the `jwt-secret` Secret the stub `OnePasswordItem` CRD cannot materialize — without it the pod hangs in `CreateContainerConfigError`) and `settings.env`; a `renovate.json5` block; the README chart row. The Renovate block is anchored on the `v` prefix, `^v(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)$`, following the `ytdlp-web-player` precedent. The registry carries **107 tags**: the versioned family plus `latest`, `main` and one `pr-<n>` per pull request. `v0.19.0` and `latest` currently share a digest, which is exactly the trap — `latest` would look right today and move on the next upstream push. There is no bare `x.y.z` family, so the `v` is not optional. ## Verified locally / not verified Verified: `helm lint --strict` clean; render against the CI fixtures; `helm package` produces `convertx-1.0.0+upv0.19.0.tgz`; the null-safe fallbacks (every one of `securityContext`, the three probes and `resources` erased with `--set …=null` falls back to this chart's defaults, uid 1000 and `/healthcheck` included); the boolean helper aborting on `yes`; `autoDeleteEveryNHours=0` rendering `"0"`; `scaleDown` rendering `replicas: 0`; the schema rejecting an unknown key; `ingress.enabled=false` rendering without `domain`/`clusterIssuer`; `ci/run-quantity-render-test.sh convertx` exercising the helper in both cases; and `NOTES.txt` through the `define`/ConfigMap trick in a throwaway copy, since `helm template` does not render it. **Not verified: anything at runtime.** This workspace has no Docker daemon and no k3d, so the install-test in this PR is the only runtime proof — that the app starts as uid 1000 without its entrypoint, that the two emptyDirs are the right ones, and that `/healthcheck` answers. `trivy` is not installed here either, so the misconfiguration scan runs for the first time in CI.
jlab-bot added 1 commit 2026-09-30 17:27:39 +02:00
feat(convertx): new chart for ConvertX (1.0.0+upv0.19.0)
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 3s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 10s
Install-test / Install-test ${{ fromJSON(inputs.charts) }} (pull_request) Successful in 2m35s
Validate Helm Charts / Install-test (pull_request) Successful in 2m35s
5be10251f4
ConvertX is a self-hosted file converter with a web UI. StatefulSet with one
replica, a PVC at /app/data holding the SQLite database, the uploads and the
converted output, a ClusterIP Service and the standard Ingress. No external
database, cache or object storage.

The chart runs non-root as uid/gid 1000 and bypasses the image entrypoint.
The image carries no USER, so it starts as root, and /entrypoint.sh exists
only to drop to uid 1000 with gosu. Overriding command with the image's own
CMD lets Kubernetes do that work instead - runAsUser/runAsGroup 1000 and
fsGroup in place of the entrypoint's recursive chown - so the repo's
hardening baseline stays intact rather than being relaxed for this image.

What that costs is named at the command: line in the StatefulSet: if upstream
ever moves real work into the entrypoint, this chart skips it silently, with
no error and no failed probe. The entrypoint of v0.19.0 does nothing but
uid/gid plumbing (and with PUID unset its first branch is already a plain
exec "$@"), and ConvertX migrates its SQLite schema in-process at import, so
the bypass is safe today. It is to be re-read at every app-version update.

The one line the bypass does lose is "export HOME=/home/convertx", so the
chart sets HOME itself instead of relying on the runtime's passwd lookup, and
mounts an emptyDir there: LibreOffice, Calibre, Inkscape and fontconfig build
their profiles under HOME on the first conversion, and it sits on the
read-only root filesystem. /tmp is the second emptyDir, for the toolchain's
scratch files. Both count against the container's ephemeral-storage limit,
which is why that request is sized for them.

uid 1000 rather than the repo's arbitrary 20000, deliberately: the image owns
/home/convertx with it, and the uid owns data on a PersistentVolume, so the
number is not free to tidy later. The fallback defaults in _helpers.tpl
reproduce the deviation rather than the repo standard.

All three probes use /healthcheck, a static 200 that the application exempts
from authentication. "/" is never a 200 with this chart's defaults: it
answers 302 to /setup while no account exists and to /login afterwards, so a
probe against it would pass on a redirect whose meaning changes on day two.
The startup budget is 300s because ConvertX shells out to ~20 converter
binaries at import just to log their versions.

Values:

- secrets.jwtSecretRef, required, field "jwt-secret". Without JWT_SECRET the
  application mints a random one per process, so every restart invalidates
  every session - silently.
- accountRegistration, allowUnauthenticated and httpAllowed, all false.
  Upstream warns explicitly against the first two on an internet-facing
  instance; the warning is carried into values.yaml and NOTES.txt. They go
  through a helper that renders the literal "true"/"false" the application's
  strict comparison needs and aborts the render on anything else - "yes" would
  otherwise be filed under "off" without a word.
- autoDeleteEveryNHours, default 24, 0 disables. It governs retention on the
  PVC. The 0 is honoured through an explicit nil check; Helm's "default" would
  swallow it and restore 24-hour deletion on the install that asked for none.
- ingress.maxUploadFileSize, default 1024m. ConvertX enforces no size limit of
  its own (maxRequestBodySize is MAX_SAFE_INTEGER), so unlike outline there is
  exactly one ceiling here and this is it.

Switching the Ingress off frees BOTH domain and clusterIssuer, checked in
both directions: ConvertX has no base-URL, public-URL or redirect-URI
variable, so nothing outside the Ingress reads the domain.

WEBROOT is deliberately not a value. It shifts every route including
/healthcheck, which would break all three probes while the application stayed
healthy, and this chart's Ingress still maps "/". PUID/PGID/UMASK are not
values either - they configure the bypassed entrypoint and nothing else.

Wiring: publish-convertx.yml, the change-app-version.yml choice list,
ci/convertx/ (test-values, the JWT Secret fixture the stub OnePasswordItem
CRD cannot materialize, and INSTALL_TIMEOUT=900 for a ~1.4 GiB image), a
renovate.json5 block anchored on the v prefix so the moving latest, main and
pr-<n> tags cannot be selected, and the README chart row.

Verified locally: helm lint --strict clean, render against the CI fixtures,
the null-safe fallbacks for every erased block, the resource-quantity render
test, and NOTES.txt through the define/ConfigMap trick since helm template
does not render it. Runtime behaviour is unverified here - this workspace has
no Docker daemon and no k3d, so the PR install-test is the only runtime proof.
jklein added 1 commit 2026-09-30 17:32:34 +02:00
docs(convertx): record the measured install-test time
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 4s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 10s
Install-test / Install-test ${{ fromJSON(inputs.charts) }} (pull_request) Successful in 2m32s
Validate Helm Charts / Install-test (pull_request) Successful in 2m32s
7ff54eecf7
The first green install-test of the chart (Gitea PR #61) took 2m35s for the
whole leg - cluster creation, fixtures, pull, install and rollout wait
together. The estimate in settings.env had been around five minutes for the
pull alone, extrapolated from the runner's 96 MB/17.5s reference; that
reference does not transfer to ghcr.io on this runner.

INSTALL_TIMEOUT stays at 900s. A rollout-wait timeout is a ceiling, not a
cost - kubectl waits exactly as long as the rollout needs - so the headroom
is free on a good day and only spends time on a chart that is already
failing. The comment now states the measurement next to the reason.

README gets the other half: a 1.4 GiB image was expected to move the
all-charts expectation from Gitea #52 and does not, because the legs run
concurrently and that figure tracks the slowest leg rather than the sum of
the pulls.
Author
Member

Measurement 4, and the runtime proof for 1–3

The install-test is green: 2m35s for the whole leg — cluster creation, fixtures, the ~1.4 GiB pull, helm install and the rollout wait together (run 268). Trivy config passed in 10s with no findings, and the quantity render test in 7s.

That run is also the runtime proof for the three things this workspace could not verify, because kubectl rollout status is what the job waits on:

  1. The application starts as uid 1000 without its entrypoint. The StatefulSet rolled out and became Ready, so the bet the chart makes holds. No fallback to root with PUID=1000 was needed, and the documented fallback stays unused.
  2. The two emptyDirs are the right ones. readOnlyRootFilesystem: true with /tmp and /home/convertx produced a Ready pod — nothing further had to be added, and nothing was added preventively.
  3. /healthcheck answers. All three probes are pointed at it and the pod became Ready, so the static 200 is reachable and unauthenticated as the source said.

The estimate behind INSTALL_TIMEOUT=900 was wrong, in the safe direction. It extrapolated the documented 96 MB / 17.5s reference to roughly five minutes for the pull alone; that reference evidently does not transfer to ghcr.io on this runner. Both ci/convertx/settings.env and the wiki now state the measured number next to the reasoning rather than only the estimate.

The timeout stays at 900s anyway, and the reason is in the file so it is not "tidied" later: a rollout-wait timeout is a ceiling, not a cost. kubectl waits exactly as long as the rollout needs, so the headroom is free on a good day and only spends time on a chart that is already failing — while what it buys is the bad day, a cold registry or a node extracting the 1354 MiB layer onto slow disk. The default 300s would have no headroom for either.

It does not move the all-charts expectation from #52 as much as the issue anticipated. 2m35s sits inside the existing 12m41s envelope rather than setting a new one, because the legs run concurrently — that figure tracks the slowest leg plus scheduling, never the sum of the pulls. Both README and the CI/CD page in the wiki now say so, so the next raised INSTALL_TIMEOUT is not read as a prediction about the total.

Wiki

Updated before asking for the merge, as the workflow requires:

  • convertx — new page, following outline.md: prerequisites, installation walkthrough, the required 1Password field, the full values reference, and the operating notes that cannot be read off the values (the entrypoint bypass and the obligation it creates, uid 1000, what has to be writable and why, /healthcheck vs /, the first-visitor-is-admin trap, the single upload ceiling, what does not belong in convertx.env). No version rows.
  • Charts — index row; the count line corrected to 27 directories / 26 installable.
  • _Sidebar — entry.
  • Lessons-Learned — two entries, both preventive rather than post-mortem, in the shape the valkey entries already use: the probe that would have passed on a redirect, and meant something else next week (a 3xx makes an httpGet pass, so a redirecting path hides the question instead of answering it — the complement of the existing sure entry, where a redirect turned a correct answer into a failure), and the entrypoint we skipped, and the obligation that created (an override forks the entrypoint silently, and a fork with no diff is a fork nobody reviews, so the warning belongs at the line the next change will touch). Plus one bullet in "The recurring shape".
  • CI-CD — the measured figure, and the per-chart publish-workflow count, which read 24 and was already one behind before this chart made it 26.

Still not verified

The install-test proves the pod comes up and serves /healthcheck. It does not run a conversion, so the claim that /home/convertx and /tmp are sufficient for LibreOffice, Calibre and the rest is still only derived from the source — the emptyDirs are needed for the converter subprocesses, and no subprocess ran. That gap is inherent to an install-test and is named here rather than left implied; the first real conversion after rollout is what closes it.

## Measurement 4, and the runtime proof for 1–3 The install-test is green: **2m35s for the whole leg** — cluster creation, fixtures, the ~1.4 GiB pull, `helm install` and the rollout wait together (run 268). Trivy config passed in 10s with no findings, and the quantity render test in 7s. That run is also the runtime proof for the three things this workspace could not verify, because `kubectl rollout status` is what the job waits on: 1. **The application starts as uid 1000 without its entrypoint.** The StatefulSet rolled out and became Ready, so the bet the chart makes holds. No fallback to root with `PUID=1000` was needed, and the documented fallback stays unused. 2. **The two emptyDirs are the right ones.** `readOnlyRootFilesystem: true` with `/tmp` and `/home/convertx` produced a Ready pod — nothing further had to be added, and nothing was added preventively. 3. **`/healthcheck` answers.** All three probes are pointed at it and the pod became Ready, so the static 200 is reachable and unauthenticated as the source said. **The estimate behind `INSTALL_TIMEOUT=900` was wrong, in the safe direction.** It extrapolated the documented 96 MB / 17.5s reference to roughly five minutes for the pull alone; that reference evidently does not transfer to ghcr.io on this runner. Both `ci/convertx/settings.env` and the wiki now state the measured number next to the reasoning rather than only the estimate. **The timeout stays at 900s anyway**, and the reason is in the file so it is not "tidied" later: a rollout-wait timeout is a **ceiling, not a cost**. `kubectl` waits exactly as long as the rollout needs, so the headroom is free on a good day and only spends time on a chart that is already failing — while what it buys is the bad day, a cold registry or a node extracting the 1354 MiB layer onto slow disk. The default 300s would have no headroom for either. **It does not move the all-charts expectation from #52 as much as the issue anticipated.** 2m35s sits inside the existing 12m41s envelope rather than setting a new one, because the legs run concurrently — that figure tracks the *slowest* leg plus scheduling, never the sum of the pulls. Both README and the CI/CD page in the wiki now say so, so the next raised `INSTALL_TIMEOUT` is not read as a prediction about the total. ## Wiki Updated before asking for the merge, as the workflow requires: - **`convertx`** — new page, following `outline.md`: prerequisites, installation walkthrough, the required 1Password field, the full values reference, and the operating notes that cannot be read off the values (the entrypoint bypass and the obligation it creates, uid 1000, what has to be writable and why, `/healthcheck` vs `/`, the first-visitor-is-admin trap, the single upload ceiling, what does not belong in `convertx.env`). No version rows. - **`Charts`** — index row; the count line corrected to 27 directories / 26 installable. - **`_Sidebar`** — entry. - **`Lessons-Learned`** — two entries, both preventive rather than post-mortem, in the shape the valkey entries already use: *the probe that would have passed on a redirect, and meant something else next week* (a 3xx makes an `httpGet` pass, so a redirecting path hides the question instead of answering it — the complement of the existing `sure` entry, where a redirect turned a correct answer into a failure), and *the entrypoint we skipped, and the obligation that created* (an override forks the entrypoint silently, and a fork with no diff is a fork nobody reviews, so the warning belongs at the line the next change will touch). Plus one bullet in "The recurring shape". - **`CI-CD`** — the measured figure, and the per-chart publish-workflow count, which read `24` and was already one behind before this chart made it 26. ## Still not verified The install-test proves the pod comes up and serves `/healthcheck`. It does **not** run a conversion, so the claim that `/home/convertx` and `/tmp` are sufficient for LibreOffice, Calibre and the rest is still only derived from the source — the emptyDirs are needed for the *converter subprocesses*, and no subprocess ran. That gap is inherent to an install-test and is named here rather than left implied; the first real conversion after rollout is what closes it.
jlab-bot marked the pull request as work in progress 2026-10-02 21:00:11 +02:00
All checks were successful
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Required
Details
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Required
Details
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 4s
Required
Details
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 10s
Required
Details
Install-test / Install-test ${{ fromJSON(inputs.charts) }} (pull_request) Successful in 2m32s
Validate Helm Charts / Install-test (pull_request) Successful in 2m32s
Required
Details
Checking for merge conflicts…
This pull request is marked as a work in progress.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u jlab-git feat/convertx-chart:feat/convertx-chart
git checkout feat/convertx-chart
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: jlab-cluster/helm-charts#61