diff --git a/.github/workflows/change-app-version.yml b/.github/workflows/change-app-version.yml index 7abce76..c05c4e3 100644 --- a/.github/workflows/change-app-version.yml +++ b/.github/workflows/change-app-version.yml @@ -37,6 +37,7 @@ on: - rss-bridge - searxng - bentopdf + - ytdlp-web-player # freesailarr is deliberately absent: none of its nine images takes # its tag from appVersion (they are all pinned in its values.yaml), # so this workflow would rewrite appVersion and republish the chart diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 218fa9c..0592176 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -43,6 +43,17 @@ jobs: uses: 1password/load-secrets-action/configure@v5 with: connect-host: https://op-connect.jk-dev.de + # helm package exits 1 with "found in Chart.yaml, but missing in charts/ + # directory" while a declared dependency has not been fetched, so this + # comes first. It is a no-op for every chart without a dependencies: + # block; ci/helm-dependencies.sh explains why it resolves from Chart.yaml + # instead of a committed Chart.lock. Note the consequence for the + # published artifact: the subchart .tgz is packaged INTO ours, so the + # version pinned in Chart.yaml is the version operators get, and nothing + # downstream ever sees the dependency again. + - name: "Fetch chart dependencies" + run: ${{ github.workspace }}/ci/helm-dependencies.sh "${{ inputs.chart-name }}" + working-directory: ${{ inputs.chart-location }} - name: "Package Helm Chart" run: helm package ${{ inputs.chart-name }} working-directory: ${{ inputs.chart-location }} diff --git a/.github/workflows/publish-ytdlp-web-player.yml b/.github/workflows/publish-ytdlp-web-player.yml new file mode 100644 index 0000000..0b96767 --- /dev/null +++ b/.github/workflows/publish-ytdlp-web-player.yml @@ -0,0 +1,15 @@ +name: Publish YTDLP Web Player Helm Chart +on: + workflow_dispatch: + push: + branches: + - main + paths: + - ytdlp-web-player/** +jobs: + publish-ytdlp-web-player: + name: Publish YTDLP Web Player Helm Chart + uses: ./.github/workflows/publish-helm-chart.yml + with: + chart-name: 'ytdlp-web-player' + secrets: inherit diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index b12ee03..045206c 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -70,6 +70,15 @@ jobs: fetch-depth: 0 - name: "Install Helm" uses: azure/setup-helm@v5 + # Both steps below refuse to run while a chart declares a dependency that + # is not in its charts/ directory - lint with "[WARNING] chart directory + # is missing these dependencies", package with "found in Chart.yaml, but + # missing in charts/ directory", both exiting 1. Charts without a + # dependencies: block are skipped without a word, so this costs nothing + # for the rest of the repo. See ci/helm-dependencies.sh for why it is + # "update" and not "build". + - name: "Fetch chart dependencies" + run: ci/helm-dependencies.sh "${{ matrix.chart }}" - name: "Lint Helm Chart" run: helm lint --strict "${{ matrix.chart }}" - name: "Package Helm Chart" diff --git a/.gitignore b/.gitignore index c463770..c99f495 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,29 @@ ### Example user template template ### Example user template +# Fetched Helm subchart archives and the lock file that records them (#214). +# +# Neither is committed, on purpose. The .tgz is a third-party binary that would +# land in every diff of a dependency update and be reviewed by nobody; CI +# fetches it with ci/helm-dependencies.sh before every lint, package, render +# and install. +# +# The lock is left out for a concrete reason rather than by omission: Renovate +# rewrites the version in Chart.yaml and cannot regenerate Chart.lock, so a +# committed lock would be stale on arrival - and `helm dependency build`, the +# only command that would benefit from it, fails outright when lock and +# Chart.yaml disagree. Every dependency PR would come in red. `helm dependency +# update` resolves from Chart.yaml instead and writes the lock itself. +# +# What is given up with it: the digest pinning a committed lock would provide. +# The version range in Chart.yaml is an exact version, so what moves is only +# what the upstream repo serves under that version. +*/charts/*.tgz +*/Chart.lock + +# Local helm package output. +*.tgz + # IntelliJ project files .idea *.iml diff --git a/CLAUDE.md b/CLAUDE.md index bcb5458..e1d7cf5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,4 +68,5 @@ The binding style guide for all current and future charts lives in **README.md ( - Probes: liveness/readiness/startup on every workload (exception: paperless consume CronJob), values-configurable with calibrated defaults. - Resources: requests as values defaults; limits auto-computed by the `_helpers.tpl` helper (`limitFactor` default 3; no CPU limits unless set explicitly). - Replicas per workload (explicit `0` allowed) plus a chart-global `scaleDown` flag that overrides them. -- `icon:` mandatory in every `Chart.yaml`; `helm lint --strict` without findings; CI fixtures under `ci//` or a justified exclusion; no chart dependencies (`charts/` directories are empty). +- `icon:` mandatory in every `Chart.yaml`; `helm lint --strict` without findings; CI fixtures under `ci//` or a justified exclusion. +- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` is the only chart using one). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and it belongs in a comment at the pinned version); and every helm command exits 1 while `charts/` is unfilled, which is why `ci/helm-dependencies.sh` runs before lint, package, render and install — including the render test that covers **all** charts on **every** PR. Full rules and the rest of the cost: README, "Chart dependencies". diff --git a/README.md b/README.md index c7313cf..6bf9771 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo | `template-chart` | — | Scaffold for new charts (placeholder `xxx`), never published | | `urlaubsverwaltung` | Urlaubsverwaltung | Deployment, DB + OIDC secrets | | `voucher-vault` | VoucherVault | StatefulSet, DB + OIDC secrets | +| `ytdlp-web-player` | ytdlp_web_player | Stateless Deployment, video cache and tool cache as emptyDirs (no PVC). **The only chart with a dependency**: an optional `oauth2-proxy` subchart, off by default, wired in front of the app because the application ships no authentication at all — see "Chart dependencies" below | | `zipline` | Zipline | StatefulSet, DB secret | ## Versioning @@ -42,6 +43,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo - `publish-.yml` — one thin workflow per chart; 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**, so the chart version must be bumped in the same change. - `publish-helm-chart.yml` — reusable workflow: `helm package`, then `helm push` to the private JK-Registry. Registry credentials come from 1Password Connect (`OP_CONNECT_TOKEN` secret, items under `op://GitHub-Actions/`). The loaded values are read via **`steps..outputs.*`**, mapped into each step's own `env:` block and used as quoted shell variables — never interpolated into the command line. `load-secrets-action` **v5 no longer exports them as environment variables** for later steps the way v4 did, so after the v5 bump every `${{ env.JK_CR_* }}` reference resolved to an empty string while the loading step still logged `Populating variable` and stayed green. Each value is additionally guarded with `:?`, because an empty one used to collapse into a missing argument rather than an error: `-u` consumed the following `--password-stdin`, helm fell back to an interactive prompt and the job died with `inappropriate ioctl for device` — a terminal error for what was really an unset variable. - `change-app-version.yml` — manually dispatched app-version bump; the chart must be listed in its `chart-name` choice list. +- `ci/helm-dependencies.sh ` — not a workflow but the step they all share: fetches a chart's declared dependencies into `charts/`, and returns silently for the charts that declare none (all but one). It runs in **five** places, because every helm command this repo builds on exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in `publish-helm-chart.yml`, and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh` and `ci/run-trivy-config.sh`. See "Chart dependencies" in the style guidelines for what that costs. - `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (same changed-charts matrix) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. Both matrices follow the changed chart directories, so a PR that only changes the harness under `ci/` exercises nothing by itself; the Trivy scan has a fixed-sample fallback for that case, the install-test has none — bump the affected chart's patch version to pull it into the matrix. The **resource-quantity render test** is the exception to that rule: it runs over every chart on every PR — see below. ### Resource-quantity render test (all charts, every PR) @@ -55,7 +57,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo The two cases exist because the bug behind #157 — quantities formatted with `printf "%v"`, where the `float64` the null-safe fallback helpers produce flips to exponential notation from about `1e6` — has a loud and a silent half. Loud: the reformatted quantity carries the suffix `e+06`, the helper rejects it, `helm template` exits non-zero. Silent: the *product* of a fractional `limitFactor` and a bare number is reformatted **after** the suffix check, so the render succeeds and the manifest carries `memory: "1.4999985e+06"` — rejected by nothing until the API server sees it at apply time. A check on the exit code alone therefore proves only half the case; the second case searches the **rendered text** for a resource quantity in exponential notation. `999999` stays below `1e6` on purpose, so the fraction case isolates the second formatting site instead of tripping over the first. -Why all charts rather than the changed ones: charts here have no dependencies, so the resources helper is copied into every chart's `templates/_helpers.tpl`. The regression arrives by copy — a new chart started from an older chart, or from `template-chart` before the fix — and in that PR the copied bug is in no diff anyone reads. Running over everything also removes the list nobody would maintain: adding a chart enrolls it automatically. Two renders per chart, no cluster, a few seconds for the whole repo; the job depends on nothing else and finishes well inside the `build-chart` runtime. +Why all charts rather than the changed ones: the resources helper is copied into every chart's `templates/_helpers.tpl` rather than shared through a library chart. The regression arrives by copy — a new chart started from an older chart, or from `template-chart` before the fix — and in that PR the copied bug is in no diff anyone reads. Running over everything also removes the list nobody would maintain: adding a chart enrolls it automatically. Two renders per chart, no cluster, a few seconds for the whole repo; the job depends on nothing else and finishes well inside the `build-chart` runtime. Two details worth knowing before touching it: @@ -137,7 +139,8 @@ If the dependency dashboard reports `Failed to look up github-releases package 1. Copy `template-chart/` to `/` and replace every `xxx` placeholder: template file names (`xxx.deployment.yaml` → `.deployment.yaml`), `Chart.yaml`, the `values.yaml` keys, the `.Values.xxx.*` references inside the templates, **and both the `xxx.` helper prefixes and the values paths in `values.schema.json`**. Then walk the five points in "Values that must not lie" below — the schema and the null-safe fallbacks are copied with the template, but their content is chart-specific and wrong until you adapt it. The same goes for `templates/NOTES.txt`: it comes with the template as a worked example and is placeholder text until you replace it with what an operator of *your* app needs after the install (see "Everything else" below); its leading `{{/* … */}}` block explains the bar and is meant to be deleted. 2. Add `.github/workflows/publish-.yml` (copy an existing one, e.g. `publish-outline.yml`). 3. Add the chart name to the choice list in `.github/workflows/change-app-version.yml`. -4. Add a `customManagers` block for the chart's main image in `renovate.json5` (copy a neighbouring one, swap `managerFilePatterns` and `depNameTemplate`) — without it the chart's app version is never updated. Check the image's tag list first: if it carries a flavour, date or prerelease suffix, add a `versioningTemplate` with a comment saying why. +4. Add a `customManagers` block for the chart's main image in `renovate.json5` (copy a neighbouring one, swap `managerFilePatterns` and `depNameTemplate`) — without it the chart's app version is never updated. Check the image's tag list first: if it carries a flavour, date or prerelease suffix, add a `versioningTemplate` with a comment saying why. **Floating tags belong outside the pattern**: `ytdlp-web-player` anchors its versioning regex on the `v` prefix precisely so `latest` and `stable` — both of which move — can never be selected. +5. Only if the chart needs a **dependency**: read "Chart dependencies" in the style guidelines first, it is a bar rather than a formality. Nothing has to be added to the pipelines — `ci/helm-dependencies.sh` already runs everywhere and keys off the `dependencies:` block — but the subchart's own updates then depend entirely on Renovate's built-in `helmv3` manager plus a chart-version bump, which is the part to write down at the pinned version. Validate locally with: @@ -201,7 +204,7 @@ These guidelines are **binding for every current and future chart** in this repo ### 7. Resources (auto-limits) - Resource requests ship as chart defaults in `values.yaml` and are user-overridable. -- Limits are computed by a per-chart `_helpers.tpl` helper (reference implementation in `template-chart/templates/_helpers.tpl`): an explicitly set limit always wins, missing memory and ephemeral-storage limits default to `resources.limitFactor` × the request (factor default `3`), and a CPU limit is only rendered when set explicitly — otherwise CPU stays unlimited (it is compressible, throttling instead of OOM-killing). The helper is duplicated into every chart because charts have no dependencies. +- Limits are computed by a per-chart `_helpers.tpl` helper (reference implementation in `template-chart/templates/_helpers.tpl`): an explicitly set limit always wins, missing memory and ephemeral-storage limits default to `resources.limitFactor` × the request (factor default `3`), and a CPU limit is only rendered when set explicitly — otherwise CPU stays unlimited (it is compressible, throttling instead of OOM-killing). The helper is copied into every chart rather than shared through a library chart — which would be a second artifact to publish, version and pull for a helper that changes about once a year; the resource-quantity render test above exists because a copy can carry an old bug into a diff nobody reads. ### 8. Secrets @@ -277,4 +280,23 @@ Reference implementation: `template-chart/values.schema.json` and `template-char - `helm lint --strict` must pass without findings. - **Every chart ships a `templates/NOTES.txt`** (#43), `template-chart` included — so this is a requirement for a new chart, not a nice-to-have it can defer. No list of names here on purpose: a count goes stale the moment the next chart lands, which is exactly how the previous wording came to claim a number it never matched. Reference implementation: `template-chart/templates/NOTES.txt`, a placeholder-carrying template rather than finished prose, so a new chart adapts it instead of starting from an empty file. The bar it sets: print what an operator would otherwise have to look up in the templates (the rendered access URL, **which fields the 1Password item must carry**, the options actually active in this release, and the chart's known traps), conditional on the values really set. A `NOTES.txt` that only repeats the values is skimmed once and never read again. **No real domain anywhere** — the URL renders from `ingress.domain`, and where an example is unavoidable it is `example.com`. A `NOTES.txt` renders no Kubernetes object, so writing or reworking one is purely additive: a minor bump. - CI: every installable chart has fixtures under `ci//` (`test-values.yaml`, optionally `fixtures.yaml`/`fixtures.env.yaml`/`settings.env`) or a justified entry in `ci/excluded-charts.txt` — the install-test fails if both are missing. -- Charts have no dependencies (`charts/` directories are empty). +### 14. Chart dependencies + +Until `ytdlp-web-player` (#214) this section read "charts have no dependencies (`charts/` directories are empty)". That rule is gone, replaced by a bar rather than a ban — a dependency is **admissible, not free**, and the reason it stopped being a ban is worth keeping: the chart needed an oauth2-proxy in the data path in front of an application that ships no authentication at all, and proxy and app are only useful wired to each other. + +**When a dependency earns its place.** All three, not two of three: + +1. The dependent piece would otherwise be a second Helm release that only exists to serve this one, with the wiring between them living in neither chart. +2. Upstream is the right owner of it. We consume their chart as it comes: **we do not patch it, do not re-harden it, do not re-implement its values.** Where their defaults fall short of the guidelines above, that is **documented in our `values.yaml`**, not fixed by us — and a values-level fix stays available to whoever installs it. +3. It is **optional and off by default** (`condition:` in `Chart.yaml`), so the chart is complete without it and a default install renders none of its objects. + +**What it costs, all of it visible before the next one is added:** + +- **The subchart is invisible to everything downstream.** Its version is inside our packaged `.tgz`, so anything that watches Helm release versions — Fleet's `fleet.yaml` in `cluster-config` included — sees only *our* chart version. A new upstream release, security fix included, reaches a cluster **only** when we publish a new version of the outer chart. Nothing reports the gap. Renovate's built-in `helmv3` manager opens the PR (no `customManagers` entry needed — the repository URL and version sit together in one file) and `ci/renovate-bump-chart.sh` bumps the chart patch that ships it; that chain is the entire safety net. State this in a comment at the pinned version, not only in a review. +- **Every pipeline that touches the chart needs a network fetch first.** `helm lint`, `helm package`, `helm template` and `helm install` all exit 1 while a declared dependency is missing from `charts/`, so `ci/helm-dependencies.sh` runs before each of them (see CI/CD above). One of those call sites is the resource-quantity render test, which runs over **every** chart on **every** PR — a dependency-carrying chart can therefore turn a third-party Helm repository's bad day into a red pipeline on a PR that touched nothing related. +- **`charts/*.tgz` and `Chart.lock` are not committed** (`.gitignore` says why): the archive is a third-party binary nobody reviews in a diff, and a committed lock would arrive stale because Renovate rewrites `Chart.yaml` and cannot regenerate it — which is also why the pipelines run `helm dependency update` and not `helm dependency build`. +- **The repo's own mechanisms stop at the subchart boundary.** `checksum/config`, the 1Password auto-restart annotation and the `scaleDown` flag reach our templates only. A chart that renders a `OnePasswordItem` for its subchart documents the manual `kubectl rollout restart` that replaces the missing restart-on-change. + +Two implementation notes that cost time otherwise: a values key containing a dash is unreachable as `.Values.oauth2-proxy` (that parses as a subtraction) and needs `index .Values "oauth2-proxy"`, while `condition:` in `Chart.yaml` splits on dots only and needs no escaping. And inside a subchart `.Chart.Name` is the **subchart's** name — a value templated into it (`config.configFile` in this case) must spell the parent chart out literally; only `.Release.Name` is shared. + +Charts that need no dependency still have none, and `charts/` stays empty for all of them. `template-chart` remains dependency-free: the scaffold should not carry a `dependencies:` block a new chart has to remember to delete. diff --git a/ci/helm-dependencies.sh b/ci/helm-dependencies.sh new file mode 100755 index 0000000..b8ec5f1 --- /dev/null +++ b/ci/helm-dependencies.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Fetch a chart's declared dependencies into its charts/ directory. +# +# WHY THIS EXISTS (issue #214) +# +# Until ytdlp-web-player every chart here was dependency-free, so no pipeline +# ever ran "helm dependency update" - and none of them tolerates a chart that +# needs it. All three of the commands this repo builds on refuse to work while +# a declared dependency is missing from charts/, each with its own wording: +# +# helm lint --strict [WARNING] chart directory is missing these dependencies: +# helm package Error: found in Chart.yaml, but missing in charts/ directory: +# helm template Error: An error occurred while checking for chart dependencies. ... +# +# All three exit 1, which is why this call sits in FIVE places rather than the +# three the plan expected: the two build steps of validate-charts.yml, the +# publish workflow - and the three ci/ scripts that render or install a chart +# (install-test, quantity-render, trivy-config). The quantity-render one is the +# easiest to overlook and the one that would have gone red first: it runs over +# EVERY chart on EVERY pull request, so a dependency-carrying chart breaks it +# from a PR that does not touch that chart at all. +# +# WHY "update" AND NOT "build" +# +# "helm dependency build" installs what Chart.lock names and fails when the +# lock disagrees with Chart.yaml. Renovate rewrites the version in Chart.yaml +# and cannot regenerate the lock, so every dependency PR would arrive red. +# "update" resolves from Chart.yaml and writes the lock itself, which is also +# why neither the lock nor the fetched .tgz is committed (see .gitignore). +# +# WHAT IT COSTS, NAMED HERE SO NOBODY HAS TO REDISCOVER IT +# +# A network fetch from a third-party Helm repository in every job that renders +# the chart. That repository going away, or serving a version that no longer +# exists, turns into a red pipeline on a PR that changed nothing related. This +# is the price of the dependency, and it is the reason the README asks for one +# to be justified rather than assumed. +# +# Charts without a "dependencies:" block are skipped without a word, so this is +# safe to call unconditionally for every chart. + +set -euo pipefail + +CHART_DIR="${1:?usage: ci/helm-dependencies.sh }" + +if [ ! -f "$CHART_DIR/Chart.yaml" ]; then + echo "ci/helm-dependencies.sh: $CHART_DIR/Chart.yaml not found" >&2 + exit 1 +fi + +# Deliberately a grep for the top-level key rather than a YAML parse: the +# runners have no yq and no pyyaml (the same constraint ci/run-trivy-config.sh +# and ci/run-quantity-render-test.sh work under). A commented-out block does +# not match, because the anchor requires the key in column one. +if ! grep -q '^dependencies:' "$CHART_DIR/Chart.yaml"; then + exit 0 +fi + +echo "==> helm dependency update $CHART_DIR" +helm dependency update "$CHART_DIR" diff --git a/ci/run-install-test.sh b/ci/run-install-test.sh index efd9f3b..9415f3e 100755 --- a/ci/run-install-test.sh +++ b/ci/run-install-test.sh @@ -151,6 +151,10 @@ done echo "::endgroup::" # --- 3. Install the chart --------------------------------------------------- +# Same reason as in the lint and package steps: helm install refuses to touch a +# chart whose declared dependencies are not in charts/. No-op for a chart +# without dependencies; see ci/helm-dependencies.sh. +"$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART" echo "::group::helm install" helm install "$RELEASE" "$ROOT/$CHART" \ --namespace "$NAMESPACE" \ diff --git a/ci/run-quantity-render-test.sh b/ci/run-quantity-render-test.sh index 3e7038f..d30fd96 100755 --- a/ci/run-quantity-render-test.sh +++ b/ci/run-quantity-render-test.sh @@ -26,9 +26,9 @@ # case is not - it needs the rendered text to be searched for a quantity in # exponential notation. Both are checked here, per chart. # -# Why over *all* charts instead of the changed ones: charts in this repo have -# no dependencies, so the helper is copied into every chart's -# templates/_helpers.tpl. The regression this guards against arrives by copy - +# Why over *all* charts instead of the changed ones: the helper is copied into +# every chart's templates/_helpers.tpl rather than shared through a library +# chart. The regression this guards against arrives by copy - # a new chart started from an older chart, or from template-chart before the # fix. That chart is new to the repo, but its copy of the bug is not something # any diff of *this* PR would point at. The test is cheap enough (two renders @@ -111,6 +111,15 @@ for CHART in "${CHARTS[@]}"; do echo "ERROR: '$CHART' is not a chart directory" >&2 exit 1 fi + + # A chart with a declared dependency cannot be rendered at all while its + # charts/ directory is empty - helm template exits 1 before it reaches a + # single template. This job is the one that would notice first, because it + # runs over EVERY chart on EVERY pull request: without this line, a PR + # touching nothing but a README would go red on ytdlp-web-player. The script + # skips charts that declare none, which is still all but one of them. + "$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART" + SCHEMA="$ROOT/$CHART/values.schema.json" if [ ! -f "$SCHEMA" ]; then echo "::error title=quantity-render $CHART::$CHART has no values.schema.json - every chart in this repo ships one (README, \"Values that must not lie\"), and this test reads the resources paths from it." diff --git a/ci/run-trivy-config.sh b/ci/run-trivy-config.sh index f12b640..2de898a 100755 --- a/ci/run-trivy-config.sh +++ b/ci/run-trivy-config.sh @@ -92,6 +92,10 @@ if bad: PY # --- 3. Render --------------------------------------------------------------- +# helm template refuses to run at all while a declared dependency is missing +# from charts/, so this has to happen before the render. No-op for a chart +# without dependencies; see ci/helm-dependencies.sh. +"$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART" echo "::group::helm template $CHART ($VALUES_LABEL)" # ${a[@]+"${a[@]}"} - an empty array under `set -u` is an error on bash 3.2 # (the macOS default), where this script also runs locally. diff --git a/ci/ytdlp-web-player/test-values.yaml b/ci/ytdlp-web-player/test-values.yaml new file mode 100644 index 0000000..2b93488 --- /dev/null +++ b/ci/ytdlp-web-player/test-values.yaml @@ -0,0 +1,31 @@ +# Install-test values for ytdlp-web-player. +# +# The chart needs exactly these two values for a default install: the app +# itself has no secrets, no PVC and no external dependency to fake, so there is +# no fixtures.yaml next to this file. example.com is a placeholder - nothing in +# this repo may carry a production domain. +ingress: + domain: ytdlp-web-player.example.com + clusterIssuer: letsencrypt + +# THE PROXY STAYS OFF HERE. DO NOT "FIX" THIS BY ENABLING IT. +# +# Two independent reasons, either one sufficient: +# +# 1. It would test somebody else's chart. That the oauth2-proxy chart works +# is a precondition of using it, and providing it is the upstream's job. +# What this repo owns is the WIRING - the upstreams line that has to carry +# the rendered Service name of this release - and wiring is a render-time +# property, checked by rendering, not by installing. See the PR for #214 +# for that check. +# +# 2. It could not pass even if we wanted it to. oauth2-proxy fetches the OIDC +# discovery document of its provider at startup and never becomes Ready +# without one. There is no Authentik in the CI cluster, and standing one +# up to watch a third-party chart start is a test of the fixture, not of +# the chart. +# +# Switching it on therefore does not produce a stricter test - it produces a +# permanently red install-test whose cause is outside this repository. +oauth2-proxy: + enabled: false diff --git a/renovate.json5 b/renovate.json5 index 27d1bdc..51ed472 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -97,6 +97,27 @@ matchDepTypes: ["app-version"], addLabels: ["app-version"], }, + { + description: [ + "Helm subchart dependencies (the `dependencies:` block of a Chart.yaml). Handled by", + "the BUILT-IN helmv3 manager - no customManager needed, and none would help: the", + "repository URL and the version sit next to each other in one file, which is the", + "case the built-in manager exists for. It is enabled by config:recommended and this", + "config sets no enabledManagers, so nothing has to switch it on. Verified by dry-run", + "for #214: with ytdlp-web-player pinned one release back, the lookup returns", + "oauth2-proxy 10.6.2 -> 10.7.0 as a manager=helmv3 minor update.", + "", + "This rule adds only a label. It exists because the update matters more here than", + "its size suggests: a subchart is invisible to everything downstream that watches", + "Helm release versions, so this PR is the ONLY way a new proxy version - a security", + "fix included - ever reaches a cluster. Under the automerge rule above, patch and", + "minor updates do that on their own once the pipeline is green, and", + "ci/renovate-bump-chart.sh bumps the chart patch that publishes it (appVersion is", + "unchanged, so it takes the patch branch). A major arrives as a PR to merge by hand.", + ], + matchManagers: ["helmv3"], + addLabels: ["subchart"], + }, { description: [ "nginx numbers its release branches by minor: even is the stable branch, odd is", @@ -396,6 +417,32 @@ datasourceTemplate: "docker", depTypeTemplate: "app-version", }, + { + description: [ + "ytdlp-web-player: the registry carries exactly two kinds of tag - four versioned", + "ones (v1.0.0, v1.0.1, v1.1.0, v1.2.0) and the two floating ones, 'latest' and", + "'stable'. Both floating tags MOVE: 'stable' was republished at the same minute as", + "v1.2.0 and 'latest' days later, so either of them as an appVersion would pin", + "nothing and change the running image without a single commit.", + "The anchored regex versioning below is what keeps them out - it requires the v", + "prefix and three numeric parts, which neither 'latest' nor 'stable' can satisfy.", + "docker versioning alone would probably have dropped them too, but 'probably' is", + "the wrong basis for the value that selects the image.", + ], + customType: "regex", + managerFilePatterns: ["ytdlp-web-player/Chart.yaml"], + matchStrings: ["(?m)^appVersion:\\s*\"?(?[^\"\\s]+)\"?"], + depNameTemplate: "docker.io/matszwe02/ytdlp_web_player", + datasourceTemplate: "docker", + depTypeTemplate: "app-version", + // The "v" stays outside every capture group: it is part of the tag and + // carries no ordering, and the same rule the searxng block documents + // applies - a group is a claim about how the value is compared. The + // anchors are what exclude latest/stable. Precedent for keeping the v in + // appVersion at all: calibre-web-automated (+upv4.0.6) and matrix-synapse + // (+upv1.159.0) - it is part of the tag, so it is part of appVersion. + versioningTemplate: "regex:^v(?\\d+)\\.(?\\d+)\\.(?\\d+)$", + }, { customType: "regex", managerFilePatterns: ["zipline/Chart.yaml"], diff --git a/ytdlp-web-player/.helmignore b/ytdlp-web-player/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/ytdlp-web-player/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/ytdlp-web-player/Chart.yaml b/ytdlp-web-player/Chart.yaml new file mode 100644 index 0000000..aef1210 --- /dev/null +++ b/ytdlp-web-player/Chart.yaml @@ -0,0 +1,53 @@ +apiVersion: v2 +name: ytdlp-web-player +description: A Helm chart to deploy ytdlp_web_player on Kubernetes, with an optional oauth2-proxy in front of it +icon: https://raw.githubusercontent.com/matszwe02/ytdlp_web_player/main/src/static/favicon.svg + +type: application + +version: 1.0.0+upv1.2.0 +appVersion: "v1.2.0" + +maintainers: + - name: jklein + +# The first chart in this repo with a dependency. The rule it breaks used to +# read "charts have no dependencies (charts/ directories are empty)" and was +# removed from CLAUDE.md and README.md in the same PR that added this chart - +# see README "Chart style guidelines" for when a dependency is admissible and +# what it costs. +# +# Why one here: the app ships no authentication of any kind, so anyone who +# reaches its URL can drive it. The proxy is the answer, and it belongs into +# this chart rather than next to it, because the two are only useful wired to +# each other: the proxy's upstream is this release's Service, and the wiring is +# the part that goes wrong silently. +dependencies: + - name: oauth2-proxy + # KEEP THIS VERSION MOVING - it is the only path a proxy update has. + # + # In the cluster the proxy used to be a Helm release of its own, and the + # update automation read its chart version straight out of fleet.yaml. As a + # subchart it is invisible there: Fleet only ever sees the version of + # ytdlp-web-player, and what this Chart.yaml declares is baked into the + # packaged .tgz. So a new oauth2-proxy release - a security fix included - + # reaches an operator ONLY when this line is raised and a new + # ytdlp-web-player version is published. Nothing reports the gap; the proxy + # simply stays where it is. + # + # Renovate's built-in helmv3 manager reads this block and opens the PR + # (verified by dry-run, see the PR for #214); ci/renovate-bump-chart.sh then + # bumps this chart's patch version, which is what actually ships it. That + # chain is the whole safety net - if it is ever switched off, this line + # needs a human on a calendar instead. + # + # The subchart version is independent of appVersion: raising it is a minor + # or patch bump of this chart, never tied to the app image. + version: "10.7.0" + repository: "https://oauth2-proxy.github.io/manifests" + # Off by default, so a plain install renders nothing of it at all. Helm + # splits a condition on dots only, so the dash in the chart name needs no + # escaping here - but every read of the same key inside a template does + # need index .Values "oauth2-proxy", because .Values.oauth2-proxy parses as + # a subtraction. + condition: oauth2-proxy.enabled diff --git a/ytdlp-web-player/templates/NOTES.txt b/ytdlp-web-player/templates/NOTES.txt new file mode 100644 index 0000000..a1339f3 --- /dev/null +++ b/ytdlp-web-player/templates/NOTES.txt @@ -0,0 +1,243 @@ +{{- $replicas := include "ytdlp-web-player.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.ytdlpWebPlayer.replicas) -}} +{{- $res := fromYaml (include "ytdlp-web-player.effectiveResources" .Values.ytdlpWebPlayer.resources) -}} +{{- $eph := index ($res.limits | default dict) "ephemeral-storage" | default "the computed ephemeral-storage limit" -}} +{{- $startup := fromYaml (include "ytdlp-web-player.startupProbe" .Values.ytdlpWebPlayer.startupProbe) -}} +{{- $proxy := (index .Values "oauth2-proxy") | default dict -}} +{{- $proxyIngress := $proxy.ingress | default dict -}} +{{- $proxyHost := first ($proxyIngress.hosts | default list) -}} +{{- $env := dict -}} +{{- range $entry := .Values.ytdlpWebPlayer.env }} +{{- if not (kindIs "invalid" $entry.value) }}{{- $_ := set $env $entry.name $entry.value }}{{- end }} +{{- end }} +ytdlp_web_player {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}. +{{- if $proxy.enabled }} With the oauth2-proxy subchart enabled.{{ end }} + +{{- if and $proxy.enabled $proxyIngress.enabled }} + +ACCESS — THROUGH THE PROXY + https://{{ $proxyHost | default "" }} + + The Ingress belongs to the proxy, the proxy forwards to + {{ include "ytdlp-web-player.appServiceAddress" . }}, and that Service is the only + way into the application. Certificate and TLS secret are the subchart's too — + this chart's ingress.clusterIssuer does not reach it; set the cert-manager + annotation under oauth2-proxy.ingress.annotations. +{{- else if .Values.ingress.enabled }} + +ACCESS + https://{{ .Values.ingress.domain }} + + The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" + cluster issuer. Until it has been issued the browser shows a warning; follow it with + + kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress +{{- else }} + +ACCESS — NO INGRESS + ingress.enabled is false, so this release has no Ingress and no certificate. + Both ingress.domain and ingress.clusterIssuer are unnecessary here — unlike + in some other charts in this repo, nothing outside the Ingress template reads + the domain: this application builds no absolute URLs and has no base-URL + setting. Reach it inside the cluster: + + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80 + + Then open http://localhost:8080. +{{- end }} +{{ if eq $replicas "0" }} +SCALED TO ZERO + {{ if .Values.scaleDown }}scaleDown: true{{ else }}ytdlpWebPlayer.replicas: 0{{ end }} — the Deployment runs with replicas: 0, so nothing answers + until you set it back and upgrade again. Service{{ if .Values.ingress.enabled }} and Ingress stay in place, + so the certificate is not re-issued.{{ else }} stays in place.{{ end }} Nothing is lost that was not + going to be lost anyway: the video cache is an emptyDir (see below). + {{- if $proxy.enabled }} + Note that scaleDown does NOT reach the subchart — the proxy keeps running and + will answer with a gateway error. Set oauth2-proxy.replicaCount: 0 as well. + {{- end }} +{{ end }} +{{- if and .Values.ingress.enabled (not $proxy.enabled) }} +THIS RELEASE IS OPEN TO EVERYONE WHO KNOWS THE URL + The application has NO authentication of any kind, and this chart adds none + while oauth2-proxy.enabled is false. What an anonymous visitor can do here is + not "read a page": they can make this pod fetch and transcode arbitrary + videos from the internet — your egress, your CPU, your IP address in the + logs of whatever site is being pulled from, and your ephemeral storage. + + Assume it is found. Hosts get scanned, and certificate transparency logs + publish every name cert-manager requests, so "nobody knows the URL" has a + half-life of hours. + + The intended fix is in this chart: + + oauth2-proxy: + enabled: true + ingress: + enabled: true + hosts: ["{{ .Values.ingress.domain }}"] + ingress: + enabled: false # the proxy owns the host from then on + + Anything else in front of it — an ingress-nginx auth annotation, a network + policy, a VPN-only ingress class — has to come from ingress.annotations or + from outside this chart. +{{- end }} +{{- if and $proxy.enabled .Values.ingress.enabled }} + +WARNING — THE APP'S OWN INGRESS IS A WAY PAST THE PROXY + Both oauth2-proxy.enabled and ingress.enabled are true. The chart only + refuses this when both claim the SAME host; here they do not, so it renders — + but https://{{ .Values.ingress.domain }} reaches the application directly, with no + authentication in front of it, whatever the proxy does on its own host. + Unless that is deliberate, set ingress.enabled=false. +{{- end }} +{{- if $proxy.enabled }} + +THE PROXY IS A SUBCHART, AND THAT HAS TWO CONSEQUENCES + 1. Its version comes from THIS chart's Chart.yaml (oauth2-proxy {{ (first .Chart.Dependencies).Version | default "" }}) and is + baked into the packaged chart. Nothing that watches Helm release versions + can see it, so a new proxy release — a security fix included — arrives + only when a new ytdlp-web-player version is released. There is no second + place that would notice; do not wait for one. + 2. Its templates are upstream's. It does not follow this repo's hardening, + probe and resource conventions, and we deliberately do not patch it. Give + it values (oauth2-proxy.resources, .securityContext, …) if you want that + shape. + +SECRETS — WHAT THE 1PASSWORD ITEM MUST CARRY + {{ if .Values.secrets.oauth2ProxySecretRef }}{{ .Values.secrets.oauth2ProxySecretRef }}{{ else }}(secrets.oauth2ProxySecretRef is not set){{ end }} + → Secret {{ .Release.Name }}-{{ .Chart.Name }}-oauth2-proxy-secret + client-id the OIDC client id + client-secret its secret + cookie-secret 16, 24 or 32 bytes, URL-SAFE base64 + + The operator copies the item's FIELD NAMES into the Secret's keys, so those + three spellings are exact. A field named differently does not fail this + install — the Secret is written without that key and the proxy pod sits in + CreateContainerConfigError, which looks like a broken chart. + + Generate the cookie secret as + + openssl rand -base64 32 | tr '+/' '-_' + + The tr is not cosmetic: oauth2-proxy decodes the value as URL-safe base64 + first, so a standard-base64 value containing + or / fails to decode and the + pod exits with "cookie_secret must be 16, 24, or 32 bytes". + + Repointing secrets.oauth2ProxySecretRef at a different item does NOT restart + the proxy — the subchart's pod template is unchanged by it, and Helm never + sees a Secret's content anyway: + + kubectl -n {{ .Release.Namespace }} rollout restart deployment/{{ .Release.Name }}-oauth2-proxy + +THE PROVIDER IS THE ONLY GATE, AND IT IS WIDER THAN IT LOOKS + The proxy is configured with email_domains = ["*"], so it admits every + identity the provider hands it. Which people that is, is decided ENTIRELY by + the group or policy binding on the provider application — and this app has no + second login behind the proxy, unlike the other instances in the cluster. A + forgotten or too-wide binding therefore does not mean "one more user", it + means every account the provider knows. Check that binding when you set this + up, not later. + + Two settings in the default configFile are load-bearing; do not remove them + while trimming: + reverse_proxy = true else every redirect is built on http:// + behind the TLS ingress — a loop + session_cookie_minimal = true else id_token and access_token go into the + session cookie, a longer group list pushes + it past 4kB, and the browser drops it: the + symptom is HTTP 502 AFTER a successful + login, with [AuthSuccess] in the log + Its companion belongs on the proxy's Ingress: + nginx.ingress.kubernetes.io/proxy-buffer-size: "16k" + + And leave alphaConfig.enabled false. With it on, the subchart generates a + config WITHOUT upstreams and ignores config.configFile silently — the login + succeeds and the app answers 404. + +NOT MEASURED: HLS PLAYBACK THROUGH THE PROXY + Rendering and wiring are verified; a long HLS stream through proxy and + ingress is not. Three places to look first if playback stalls where a direct + ingress works: proxy-buffering in ingress-nginx, its 60-second + proxy-read/send timeouts, and the proxy's own upstream timeout. Browser + extension and PWA are unverified too — an expired session answers with a + redirect to the provider, not a 401, and a fetch() sees only an opaque + redirect. +{{- end }} + +A READY POD DOES NOT MEAN A VIDEO WILL PLAY + All three probes request "/", which renders the player page and touches + neither the network nor ffmpeg. That is on purpose — it is the only cheap + path the app has, and it keeps a slow upstream site from restarting the pod. + The price is that egress problems are invisible to Kubernetes: with DNS + broken or outbound traffic blocked, "/" still answers 200, the rollout goes + green, and every playback fails. Check it from the outside instead: + + kubectl -n {{ .Release.Namespace }} logs deployment/{{ .Release.Name }}-{{ .Chart.Name }}-deployment + + A failing fetch shows up there as a yt-dlp error line, never as a probe + failure. The startup budget is {{ $startup.periodSeconds }}s x {{ $startup.failureThreshold }} = {{ mul $startup.periodSeconds $startup.failureThreshold }}s, which only has to + cover the first process start. + +THE VIDEO CACHE IS AN emptyDir — AND IT IS AN EVICTION RISK, NOT A DISK + DATA_PATH is /data, backed by an emptyDir. Two things follow: + + Nothing survives. A restart, a node drain, an eviction or a helm upgrade + empties it, and every cached video is fetched again on demand. That is the + intended shape — the app expires files by itself after MAX_VIDEO_AGE — but do + not treat it as storage. + + An emptyDir counts against the container's ephemeral-storage limit + ({{ $eph }}){{ if (.Values.storage).dataSizeLimit }}, and storage.dataSizeLimit ({{ (.Values.storage).dataSizeLimit }}) caps it a second time{{ end }}. Going over it is not an + application error: the kubelet evicts the whole pod, the reason sits on the + pod rather than in any log, and everything in flight goes with it. Raising + the limit is the wrong first move — the knobs that decide how much lands + there are in ytdlpWebPlayer.env: + + MAX_VIDEO_DURATION upstream default 36000 (ten hours, one enormous file) + MAX_VIDEO_AGE upstream default 3600 (seconds after last access) + MAX_PROCESSES upstream default 5 (concurrent ffmpeg/yt-dlp) + + Currently set here: {{ if $env }}{{ range $k, $v := $env }}{{ $k }}={{ $v }} {{ end }}{{ else }}none — all upstream defaults apply{{ end }} + +yt-dlp DOES NOT UPDATE ITSELF IN THIS CHART + On start and periodically the app runs "pip install --upgrade yt-dlp" into + its own site-packages. With readOnlyRootFilesystem: true that write cannot + happen; the app catches the failure and carries on with the version baked + into the image (verified — the container starts, serves and transcodes + normally, the update attempt just does nothing). + + This is the right trade in a cluster — a pod that pulls unpinned code off the + network on every restart is not something to want — but know the + consequence: when a video site changes its extraction and playback breaks, + waiting does not fix it. The fix arrives as a new app image, i.e. a new + appVersion of this chart. There is no in-place update path. + + The tools' CACHE is a separate matter and is writable on purpose: /cache is + an emptyDir, with HOME and XDG_CACHE_HOME pointed at it, holding deno's + module cache and yt-dlp's JavaScript challenge-solver library. Without it + the pod still works, but re-fetches that library from github.com on every + extraction — so if egress is restricted, github.com belongs on the allow + list either way, and doubly so if this volume is ever removed. + +ytdlpWebPlayer.env IS APPENDED, NOT MERGED + The entries land after DATA_PATH and PORT, which the chart sets itself. Do + not set those two here: Kubernetes applies the LAST entry of the list, so a + duplicate silently wins and points the app away from its writable volume or + off the port that the Service and all three probes use. Everything else the + upstream understands is fair game; src/example.env in the upstream repository + is the authoritative list. +{{- if gt (atoi $replicas) 1 }} + +MORE THAN ONE REPLICA SHARES NOTHING + ytdlpWebPlayer.replicas is {{ $replicas }}. Each pod has its own /data emptyDir, so a + video fetched by one is invisible to the other, and without session affinity + the segment requests of a single playback are spread across both — which + looks like a stuttering or failing stream rather than an error. Pin clients + to a pod at the ingress, or stay at one replica. +{{- end }} + +WHAT THIS CHART DELIBERATELY DOES NOT DO + No cookies.txt: videos that require a login on the source site do not work, + and there is no value to make them work. No PersistentVolume. No mirror of + the upstream's twenty environment switches as chart values — they live in + ytdlpWebPlayer.env, so an upstream release cannot make this chart lie. diff --git a/ytdlp-web-player/templates/_helpers.tpl b/ytdlp-web-player/templates/_helpers.tpl new file mode 100644 index 0000000..103e30d --- /dev/null +++ b/ytdlp-web-player/templates/_helpers.tpl @@ -0,0 +1,372 @@ +{{/* +Render the body of a container "resources:" block from a values dict of the form + + resources: + limitFactor: 3 # optional, default 3 + requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... } + limits: { ... } # optional, every entry optional + +Rules (variant B, see issue #19): + - requests are rendered as given (camelCase "ephemeralStorage" is normalized + to the Kubernetes key "ephemeral-storage"). + - an explicitly set limit always wins. + - a missing memory / ephemeral-storage limit is computed as + limitFactor x the corresponding request. + - a missing cpu limit is NOT set at all (the kubelet then treats CPU as + unlimited; CPU is compressible, so this cannot kill the workload). + +Usage (the values path to the resources dict varies per chart): + resources: + {{- include "ytdlp-web-player.resources" .Values..resources | nindent 12 }} + +NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with +the chart's name as define prefix; template-chart/templates/_helpers.tpl is the +reference implementation, and ci/run-quantity-render-test.sh renders every +chart on every PR precisely because a copy can carry an old bug into a diff +nobody reads. The copy is not a leftover of the old "charts have no +dependencies" rule: a library chart would be a second thing to publish, +version and pull for a helper that changes about once a year. +*/}} +{{- define "ytdlp-web-player.resources" -}} +{{- $resources := . | default dict -}} +{{- $factor := 3.0 -}} +{{- if not (kindIs "invalid" $resources.limitFactor) -}} +{{- $factor = float64 $resources.limitFactor -}} +{{- end -}} +{{- $requests := dict -}} +{{- range $key, $value := ($resources.requests | default dict) -}} +{{- if not (kindIs "invalid" $value) -}} +{{- $_ := set $requests (include "ytdlp-web-player.resourceKey" $key) $value -}} +{{- end -}} +{{- end -}} +{{- $limits := dict -}} +{{- range $key, $value := ($resources.limits | default dict) -}} +{{- if not (kindIs "invalid" $value) -}} +{{- $_ := set $limits (include "ytdlp-web-player.resourceKey" $key) $value -}} +{{- end -}} +{{- end -}} +{{- range $key, $request := $requests -}} +{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}} +{{- $_ := set $limits $key (include "ytdlp-web-player.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}} +{{- end -}} +{{- end -}} +{{- $out := dict -}} +{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}} +{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}} +{{- toYaml $out -}} +{{- end -}} + +{{/* +Normalize a resource name from values to its Kubernetes key. +*/}} +{{- define "ytdlp-web-player.resourceKey" -}} +{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}} +{{- end -}} + +{{/* +Multiply a Kubernetes quantity by a factor, keeping its suffix. +Input: dict "quantity" "factor" . +Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary +("512Mi", "1Gi", "10Ki") and milli/SI suffixes ("100m", "500M"). Integer +results are printed without a decimal point. +*/}} +{{- define "ytdlp-web-player.multiplyQuantity" -}} +{{- $quantity := include "ytdlp-web-player.formatNumber" .quantity -}} +{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}} +{{- if not $number -}} +{{- fail (printf "cannot parse resource quantity %q" $quantity) -}} +{{- end -}} +{{- $suffix := trimPrefix $number $quantity -}} +{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}} +{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}} +{{- end -}} +{{- $product := mulf (float64 $number) .factor -}} +{{- if eq $product (floor $product) -}} +{{- printf "%d%s" (int64 $product) $suffix -}} +{{- else -}} +{{- printf "%s%s" (include "ytdlp-web-player.formatNumber" $product) $suffix -}} +{{- end -}} +{{- end -}} + +{{/* +Render a number as a plain decimal string, never in exponential notation. + +Why this exists (issue #157): the null-safe fallback helpers pass values +through fromYaml, which parses every number as float64. Go's "%v" switches +float64 to exponential notation from about 1e6 upwards, so a bare byte count +like 1048576 became "1.048576e+06" - not a valid Kubernetes quantity, and the +render aborted. Strings ("512Mi") are passed through untouched. +*/}} +{{- define "ytdlp-web-player.formatNumber" -}} +{{- if kindIs "float64" . -}} +{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}} +{{- else -}} +{{- printf "%v" . -}} +{{- end -}} +{{- end -}} + +{{/* +Render the replica count for a Deployment/StatefulSet. + +Input: dict + "scaleDown" the chart-global .Values.scaleDown flag (may be nil) + "replicas" the workload's replicas value (may be nil) + +Rules (issue #25): + - scaleDown: true takes precedence and always yields 0. + - otherwise an explicitly set replicas value is used as given, including an + explicit 0 — Helm's "default" would swallow 0, hence the explicit + kindIs "invalid" nil check. + - a missing replicas value defaults to 1. + +Usage (the values path to the workload's replicas varies per chart): + replicas: {{ include "ytdlp-web-player.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values..replicas) }} + +NOTE: like the resources helper above, this is a copy carrying this chart's +name as define prefix; template-chart holds the reference implementation. +*/}} +{{- define "ytdlp-web-player.replicas" -}} +{{- if .scaleDown -}} +0 +{{- else if not (kindIs "invalid" .replicas) -}} +{{- .replicas -}} +{{- else -}} +1 +{{- end -}} +{{- end -}} + +{{/* +Merge a user-supplied values sub-tree onto a dict of chart defaults so that a +null / empty override falls back to the defaults instead of erasing them. + +Input: dict "defaults" "given" "path" . Output: YAML of the merged dict (consume +it with fromYaml). + +Why this exists (issues #61 and #99): Helm merges values maps recursively, but +an override key written without children — e.g. a leftover + + securityContext: + +after deleting its last remaining sub-key — is YAML null. Where the chart +defines a default, Helm treats that null as a deletion of the default; the +template then renders "securityContext: null": no readOnlyRootFilesystem, no +dropped capabilities, no allowPrivilegeEscalation: false. The workload starts +and reports healthy while being unhardened, so nothing points at the mistake. +The same shape erases a probe (it disappears) or the resources block (no +requests, no computed limits). This helper defines the opposite semantics: an +empty block means "chart defaults apply". + +Rules: + - a nil "given" is treated as an empty dict (all defaults apply). + - a key whose override value is null keeps the default (nulls never delete). + - a key present in both as a map is merged recursively, so a partially + filled block only overrides the keys it actually sets. + - a scalar override for a key the defaults define as a map is rejected with + a message naming the values path, instead of failing later with a template + field error. + - any other set value wins as given, including an explicit false or 0 — + Helm's "default" and sprig's "mergeOverwrite" would both swallow those + zero values, hence the explicit kindIs "invalid" nil check. + - keys only present in the override are passed through unchanged. + +NOTE: like the helpers above, this is a copy carrying this chart's name as +define prefix; template-chart holds the reference implementation. +*/}} +{{- define "ytdlp-web-player.defaultedDict" -}} +{{- $defaults := .defaults | default dict -}} +{{- $given := .given -}} +{{- $path := .path | default "value" -}} +{{- if kindIs "invalid" $given -}} +{{- $given = dict -}} +{{- end -}} +{{- if not (kindIs "map" $given) -}} +{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}} +{{- end -}} +{{- $out := deepCopy $defaults -}} +{{- range $key, $value := $given -}} +{{- if not (kindIs "invalid" $value) -}} +{{- $default := index $defaults $key -}} +{{- if and (kindIs "map" $value) (kindIs "map" $default) -}} +{{- $_ := set $out $key (fromYaml (include "ytdlp-web-player.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}} +{{- else if kindIs "map" $default -}} +{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}} +{{- else -}} +{{- $_ := set $out $key $value -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- toYaml $out -}} +{{- end -}} + +{{/* +Pick one sub-block out of a values block that may itself be null. + +Input: dict "block" "key" "path" . +Returns YAML of dict "value" , after rejecting a +non-mapping block with a message that names the values path. Needed because +".Values..securityContext" can itself be erased, and indexing into nil +inside a template is not an error but silently yields nothing. +*/}} +{{- define "ytdlp-web-player.subBlock" -}} +{{- $block := .block -}} +{{- if kindIs "invalid" $block -}} +{{- $block = dict -}} +{{- end -}} +{{- if not (kindIs "map" $block) -}} +{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}} +{{- end -}} +{{- toYaml (dict "value" (index $block .key)) -}} +{{- end -}} + +{{/* +The effective security contexts, probes and resources (issue #99). + +The defaults below MUST stay in sync with the corresponding blocks in +values.yaml, which stays the documented, user-facing place for them; this copy +is only the fallback for the case where an override has erased the block. + +Two rules that these fallbacks exist to demonstrate (README, "Chart style +guidelines"): + + - Reproduce THIS chart's defaults, not the repo standard. Where a chart + deviates on purpose (a root-starting image, a non-default limitFactor, a + pinned uid), the fallback must reproduce the deviation. Restoring the + "clean" standard here is not tidying, it is an outage - in one chart it + would move the runtime uid from 1000 to 911 and make existing volume data + inaccessible. + - Carry the probe's CONTEXT, not just its timings. A probe default without + its port name, Host header or exec command is worse than useless: in one + chart the probe vanished entirely, in another it answered 403 and the pod + never became Ready. +*/}} +{{- define "ytdlp-web-player.podSecurityContext" -}} +{{- $given := (fromYaml (include "ytdlp-web-player.subBlock" (dict "block" . "key" "pod" "path" "ytdlpWebPlayer.securityContext"))).value -}} +{{- $defaults := dict + "runAsNonRoot" true + "runAsUser" 20000 + "runAsGroup" 1000 + "fsGroup" 1000 + "fsGroupChangePolicy" "Always" + "seccompProfile" (dict "type" "RuntimeDefault") -}} +{{- include "ytdlp-web-player.defaultedDict" (dict "defaults" $defaults "given" $given "path" "ytdlpWebPlayer.securityContext.pod") -}} +{{- end -}} + +{{- define "ytdlp-web-player.containerSecurityContext" -}} +{{- $given := (fromYaml (include "ytdlp-web-player.subBlock" (dict "block" . "key" "container" "path" "ytdlpWebPlayer.securityContext"))).value -}} +{{- $defaults := dict + "allowPrivilegeEscalation" false + "readOnlyRootFilesystem" true + "runAsNonRoot" true + "capabilities" (dict "drop" (list "ALL") "add" (list)) -}} +{{- include "ytdlp-web-player.defaultedDict" (dict "defaults" $defaults "given" $given "path" "ytdlpWebPlayer.securityContext.container") -}} +{{- end -}} + +{{- define "ytdlp-web-player.livenessProbe" -}} +{{- $defaults := dict + "httpGet" (dict "path" "/" "port" "http") + "periodSeconds" 10 + "timeoutSeconds" 5 + "failureThreshold" 3 -}} +{{- include "ytdlp-web-player.defaultedDict" (dict "defaults" $defaults "given" . "path" "ytdlpWebPlayer.livenessProbe") -}} +{{- end -}} + +{{- define "ytdlp-web-player.readinessProbe" -}} +{{- $defaults := dict + "httpGet" (dict "path" "/" "port" "http") + "periodSeconds" 10 + "timeoutSeconds" 5 + "failureThreshold" 3 -}} +{{- include "ytdlp-web-player.defaultedDict" (dict "defaults" $defaults "given" . "path" "ytdlpWebPlayer.readinessProbe") -}} +{{- end -}} + +{{- define "ytdlp-web-player.startupProbe" -}} +{{- $defaults := dict + "httpGet" (dict "path" "/" "port" "http") + "periodSeconds" 5 + "timeoutSeconds" 5 + "failureThreshold" 30 -}} +{{- include "ytdlp-web-player.defaultedDict" (dict "defaults" $defaults "given" . "path" "ytdlpWebPlayer.startupProbe") -}} +{{- end -}} + +{{/* +The effective resources block, fed into the resources helper above so that an +erased "resources:" still yields the chart's requests and computed limits +instead of a container without any resource accounting. +*/}} +{{- define "ytdlp-web-player.effectiveResources" -}} +{{- $defaults := dict + "limitFactor" 3 + "requests" (dict "cpu" 0.5 "memory" "512Mi" "ephemeral-storage" "2Gi") -}} +{{- $merged := fromYaml (include "ytdlp-web-player.defaultedDict" (dict "defaults" $defaults "given" . "path" "ytdlpWebPlayer.resources")) -}} +{{- include "ytdlp-web-player.resources" $merged -}} +{{- end -}} + +{{/* +The application's listening port, taken from the container port named "http". + +Deliberately NOT null-safe, unlike the probe and resources helpers above. +Issue #99 covers the silent class of erased values - a probe that vanishes, a +container that renders unhardened. This one is loud: a missing port yields a +Service pointing nowhere and an application told to listen on "", which fails +at once and in the open. An error that shows itself needs no fallback, and +every extra branch has to be read by someone. + +Because of this helper the number lives in exactly one place: it becomes the +PORT environment variable, while the Service and all three probes address the +same entry by NAME. +*/}} +{{- define "ytdlp-web-player.port" -}} +{{- $port := "" -}} +{{- range (.Values.ytdlpWebPlayer.ports | default list) -}} +{{- if eq (.name | default "") "http" -}} +{{- $port = .containerPort -}} +{{- end -}} +{{- end -}} +{{- if or (kindIs "invalid" $port) (eq (printf "%v" $port) "") -}} +{{- fail "ytdlpWebPlayer.ports must contain an entry named \"http\" carrying a containerPort - it is the port the application is told to listen on (PORT), the port the Service targets, and the port all three probes address" -}} +{{- end -}} +{{- printf "%v" $port -}} +{{- end -}} + +{{/* +The in-cluster address of this release's application Service - the address the +oauth2-proxy subchart has to carry in its upstreams line. + +A helper so that NOTES.txt and the default in values.yaml cannot drift apart. +Note what it may NOT be built from on the subchart side: inside the subchart +.Chart.Name is "oauth2-proxy", so the parent chart's name has to be written out +literally there. Only .Release.Name is shared by both. +*/}} +{{- define "ytdlp-web-player.appServiceAddress" -}} +http://{{ .Release.Name }}-{{ .Chart.Name }}-service:80 +{{- end -}} + +{{/* +Refuse to render two Ingresses for the same host (issue #214). + +With the proxy enabled, this chart's Ingress and the subchart's Ingress can +both be switched on, and both then claim the same host. That is not a merge and +not a warning: ingress-nginx's admission webhook rejects whichever arrives +second with "host ... and path ... is already defined in ingress ...". Under +Fleet the bundle goes to ErrApplied and Fleet does not retry a rejected apply +by itself - getting out costs a forceSyncGeneration bump on the GitRepo. +Failing here costs a re-render. + +Host-based rather than "both switched on", because a proxy on a host of its own +collides with nothing. That case is not silently blessed either: NOTES.txt says +plainly that the app's own Ingress is then a way past the proxy. + +Called from the Deployment - the one template that always renders - so the +guard cannot be skipped by scaling to zero or by rendering a single file with +"helm template -s". +*/}} +{{- define "ytdlp-web-player.validate" -}} +{{- $proxy := (index .Values "oauth2-proxy") | default dict -}} +{{- $proxyIngress := $proxy.ingress | default dict -}} +{{- if and .Values.ingress.enabled $proxy.enabled $proxyIngress.enabled -}} +{{- if has .Values.ingress.domain ($proxyIngress.hosts | default list) -}} +{{- fail (printf "ingress.enabled and oauth2-proxy.ingress.enabled both claim the host %q. ingress-nginx allows only one Ingress per host and path; its admission webhook rejects the second one, which leaves a Fleet bundle in ErrApplied until forceSyncGeneration is bumped. Decide who owns the host: with the proxy in the data path set ingress.enabled=false (the app is then reached through the proxy and its Service), or drop the proxy's Ingress with oauth2-proxy.ingress.enabled=false." .Values.ingress.domain) -}} +{{- end -}} +{{- end -}} +{{- end -}} diff --git a/ytdlp-web-player/templates/ytdlp-web-player.deployment.yaml b/ytdlp-web-player/templates/ytdlp-web-player.deployment.yaml new file mode 100644 index 0000000..c9436d9 --- /dev/null +++ b/ytdlp-web-player/templates/ytdlp-web-player.deployment.yaml @@ -0,0 +1,135 @@ +{{- include "ytdlp-web-player.validate" . }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment +spec: + replicas: {{ include "ytdlp-web-player.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.ytdlpWebPlayer.replicas) }} + selector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + template: + metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + # NO checksum/config ANNOTATION HERE, AND NOTHING IS MISSING (#82). + # + # The rule is "every pod template consuming a ConfigMap carries a hash of + # it". This workload consumes none: the application is configured purely + # through environment variables, which are part of the pod template + # already, so a changed value rolls the pods by itself. The chart renders + # no ConfigMap and no Secret for the app - it has no credentials of its + # own - so there is nothing to hash and no silent-stale-config gap to + # close. Written down because an absent annotation otherwise looks like + # an oversight on the next review round. + # + # The one Secret this chart can render, the oauth2-proxy OnePasswordItem, + # is consumed by the SUBCHART's pod template, which this chart cannot + # annotate. See values.yaml for the manual rollout restart that replaces + # it. + spec: + automountServiceAccountToken: false + securityContext: + {{- include "ytdlp-web-player.podSecurityContext" .Values.ytdlpWebPlayer.securityContext | nindent 8 }} + containers: + - name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + # Fully qualified on purpose: an unqualified "matszwe02/ytdlp_web_player" + # resolves to Docker Hub implicitly, which is how the repo accumulated + # its unqualified images. + image: "docker.io/matszwe02/ytdlp_web_player:{{ .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + env: + # The four variables the chart owns. + # + # DATA_PATH is absolute, which the application explicitly allows (it + # runs the value through os.path.abspath), so nothing has to be + # writable below /app and the root filesystem stays read-only. + # + # PORT follows the container port named "http", so the Service, the + # probes and the application cannot drift apart. + # + # HOME and XDG_CACHE_HOME point the cache of the two bundled tools + # at the writable volume below. They are set rather than relying on + # a mount at /.cache so that the path does not depend on which uid + # the pod ends up running as - see values.yaml under storage. + - name: DATA_PATH + value: "/data" + - name: PORT + value: {{ include "ytdlp-web-player.port" . | quote }} + - name: HOME + value: "/cache" + - name: XDG_CACHE_HOME + value: "/cache" + {{- range $value := .Values.ytdlpWebPlayer.env }} + {{- if $value.value }} + - name: {{ $value.name | quote }} + # quote is required, not cosmetic: a value like "5000" or "true" + # renders unquoted as a YAML number/bool, and the API server then + # rejects the whole manifest with "cannot unmarshal number into + # Go struct field EnvVar.value of type string". + value: {{ tpl $value.value $ | quote }} + {{- else if $value.valueFrom }} + {{- if $value.valueFrom.secretKeyRef }} + - name: {{ $value.name }} + valueFrom: + secretKeyRef: + name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }} + key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }} + {{- else if $value.valueFrom.configMapKeyRef }} + - name: {{ $value.name }} + valueFrom: + configMapKeyRef: + name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }} + key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }} + {{- end }} + {{- end }} + {{- end }} + ports: + {{- toYaml .Values.ytdlpWebPlayer.ports | nindent 12 }} + volumeMounts: + # Exactly two, and each was measured on its own rather than added + # as a precaution - see values.yaml under storage. Note in + # particular that /tmp is NOT here: the usual reflex for a + # read-only root filesystem, and this image does not need it. + - name: data + mountPath: /data + - name: cache + mountPath: /cache + # Probes, resources and both security contexts go through the + # null-safe helpers (#99): an erased block means "chart defaults + # apply" instead of a vanished probe or an unhardened container. + # Never wrap these in an "if" - a guard hides the erased case + # completely instead of rendering a visible null. + livenessProbe: + {{- include "ytdlp-web-player.livenessProbe" .Values.ytdlpWebPlayer.livenessProbe | nindent 12 }} + readinessProbe: + {{- include "ytdlp-web-player.readinessProbe" .Values.ytdlpWebPlayer.readinessProbe | nindent 12 }} + startupProbe: + {{- include "ytdlp-web-player.startupProbe" .Values.ytdlpWebPlayer.startupProbe | nindent 12 }} + resources: + {{- include "ytdlp-web-player.effectiveResources" .Values.ytdlpWebPlayer.resources | nindent 12 }} + securityContext: + {{- include "ytdlp-web-player.containerSecurityContext" .Values.ytdlpWebPlayer.securityContext | nindent 12 }} + volumes: + # The "emptyDir: {}" branches are written out rather than folded into + # one block with an optional key: an emptyDir whose body renders empty + # is YAML null, and a volume without a source is rejected by the API + # server with "must specify a volume type" - after a green render. + - name: data + {{- if (.Values.storage).dataSizeLimit }} + emptyDir: + sizeLimit: {{ (.Values.storage).dataSizeLimit }} + {{- else }} + emptyDir: {} + {{- end }} + - name: cache + {{- if (.Values.storage).cacheSizeLimit }} + emptyDir: + sizeLimit: {{ (.Values.storage).cacheSizeLimit }} + {{- else }} + emptyDir: {} + {{- end }} + restartPolicy: Always diff --git a/ytdlp-web-player/templates/ytdlp-web-player.ingress.yaml b/ytdlp-web-player/templates/ytdlp-web-player.ingress.yaml new file mode 100644 index 0000000..bde4311 --- /dev/null +++ b/ytdlp-web-player/templates/ytdlp-web-player.ingress.yaml @@ -0,0 +1,29 @@ +{{- if .Values.ingress.enabled }} +{{- include "ytdlp-web-player.validate" . }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress + annotations: + cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + rules: + - host: {{ required "A Domain/Host must be provided" .Values.ingress.domain }} + http: + paths: + - backend: + service: + name: {{ .Release.Name }}-{{ .Chart.Name }}-service + port: + name: http + path: / + pathType: Prefix + tls: + - hosts: + - {{ required "A Domain/Host must be provided" .Values.ingress.domain }} + secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress +{{- end }} diff --git a/ytdlp-web-player/templates/ytdlp-web-player.oauth2-proxy-secret.yaml b/ytdlp-web-player/templates/ytdlp-web-player.oauth2-proxy-secret.yaml new file mode 100644 index 0000000..2a4c869 --- /dev/null +++ b/ytdlp-web-player/templates/ytdlp-web-player.oauth2-proxy-secret.yaml @@ -0,0 +1,26 @@ +{{- $proxy := (index .Values "oauth2-proxy") | default dict -}} +{{- $config := $proxy.config | default dict -}} +{{- $ownSecret := printf "%s-%s-oauth2-proxy-secret" .Release.Name .Chart.Name -}} +{{- if and $proxy.enabled (eq (tpl ($config.existingSecret | default "") .) $ownSecret) }} +# The three credentials the proxy needs, as a 1Password item (README, guideline +# 8). This chart renders the CR itself rather than only expecting a Secret name, +# for the same reason every other chart here does: the operator is the repo's +# secret mechanism, and a chart that only names a Secret leaves the operator +# half of the wiring to a second, undocumented place. +# +# It is rendered only while the subchart still points at THIS name. Set +# oauth2-proxy.config.existingSecret to a Secret of your own and the chart +# creates nothing here - your Secret then has to carry the same three keys. +# +# Note what the operator does and does not do: it copies the item's FIELD NAMES +# into the Secret's keys. A field spelled differently produces a Secret without +# that key, the install stays green, and the proxy pod sits in +# CreateContainerConfigError. See values.yaml for the three names and for the +# URL-safe base64 trap on cookie-secret. +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: {{ $ownSecret }} +spec: + itemPath: {{ required "The oauth2-proxy needs its credentials: set secrets.oauth2ProxySecretRef to the op:// path of an item carrying client-id, client-secret and cookie-secret - or point oauth2-proxy.config.existingSecret at a Secret you manage yourself." .Values.secrets.oauth2ProxySecretRef }} +{{- end }} diff --git a/ytdlp-web-player/templates/ytdlp-web-player.service.yaml b/ytdlp-web-player/templates/ytdlp-web-player.service.yaml new file mode 100644 index 0000000..27ec1eb --- /dev/null +++ b/ytdlp-web-player/templates/ytdlp-web-player.service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-service +spec: + selector: + app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + ports: + - protocol: TCP + port: 80 + # By name, so the container port is configured in exactly one place + # (ytdlpWebPlayer.ports). + targetPort: http + name: http + type: ClusterIP diff --git a/ytdlp-web-player/values.schema.json b/ytdlp-web-player/values.schema.json new file mode 100644 index 0000000..7b5757d --- /dev/null +++ b/ytdlp-web-player/values.schema.json @@ -0,0 +1,196 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "ytdlp-web-player values", + "description": "additionalProperties is false on every level this chart owns, so a key the chart does not read is rejected at render time instead of being silently ignored (#68). Two kinds of object stay open: the ones passed verbatim into the pod spec (probes, security contexts, ports, free-form annotations), because Kubernetes owns their schema - and the whole oauth2-proxy block, because that schema belongs to the subchart. Closing the latter would mean re-declaring somebody else's values and rejecting every key they add next release.", + "type": "object", + "additionalProperties": false, + "properties": { + "global": { + "description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it - and note that with a subchart present this is no longer hypothetical: whatever is set here reaches oauth2-proxy too.", + "type": "object" + }, + "scaleDown": { + "type": "boolean" + }, + "secrets": { + "type": "object", + "additionalProperties": false, + "properties": { + "oauth2ProxySecretRef": { + "description": "op:// item path for the proxy credentials; the item must carry the fields client-id, client-secret and cookie-secret. Read only while the proxy is enabled and still points at the Secret this chart renders.", + "type": ["string", "null"] + } + } + }, + "ytdlpWebPlayer": { + "type": "object", + "additionalProperties": false, + "properties": { + "replicas": { + "type": "integer", + "minimum": 0 + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + }, + "securityContext": { + "$ref": "#/definitions/securityContext" + }, + "ports": { + "description": "Rendered verbatim as the container's ports list; kept open because the chart does not interpret the entries. It does read one thing out of them: the entry named \"http\" carries the port that becomes PORT, the Service target and every probe target. A list without it fails the render with that message.", + "type": ["array", "null"] + }, + "env": { + "$ref": "#/definitions/env" + } + } + }, + "storage": { + "description": "The two emptyDirs. Deliberately not null-safe: an erased block means both size caps are unset, which is also the default, so nothing silent can happen here.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "dataSizeLimit": { + "type": ["string", "null"] + }, + "cacheSizeLimit": { + "type": ["string", "null"] + } + } + }, + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Gates this chart's whole Ingress template (#93). Here it frees BOTH domain and clusterIssuer: nothing outside the Ingress template reads either of them - the application needs no hostname of its own, so the searxng/matrix-synapse case of a domain read from an env block does not arise (guideline 1, checked in templates/ and in values.yaml).", + "type": "boolean" + }, + "clusterIssuer": { + "type": ["string", "null"] + }, + "domain": { + "type": ["string", "null"] + }, + "className": { + "type": ["string", "null"] + }, + "annotations": { + "description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.", + "type": "object" + } + } + }, + "oauth2-proxy": { + "description": "Values of the oauth2-proxy subchart, passed straight through. Open on purpose and at every level: this chart owns neither the keys nor their meaning, and a closed copy would reject whatever the subchart adds in its next release - the exact failure mode a schema is supposed to prevent, only pointed at the wrong chart. This chart reads exactly three of them: enabled (the dependency condition), ingress.enabled and ingress.hosts (the two-Ingress guard).", + "type": "object" + } + }, + "definitions": { + "resources": { + "description": "May be null / empty: the chart then falls back to its default requests and computed limits (#99) instead of rendering a container without resource accounting.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "limitFactor": { + "type": "number", + "exclusiveMinimum": 0 + }, + "requests": { + "$ref": "#/definitions/resourceList" + }, + "limits": { + "$ref": "#/definitions/resourceList" + } + } + }, + "resourceList": { + "description": "Both spellings of the ephemeral-storage key are accepted because the resources helper normalizes camelCase to the Kubernetes key.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "cpu": { + "type": ["string", "number", "null"] + }, + "memory": { + "type": ["string", "number", "null"] + }, + "ephemeral-storage": { + "type": ["string", "number", "null"] + }, + "ephemeralStorage": { + "type": ["string", "number", "null"] + } + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "valueFrom": { + "type": "object", + "additionalProperties": false, + "properties": { + "secretKeyRef": { + "$ref": "#/definitions/keyRef" + }, + "configMapKeyRef": { + "$ref": "#/definitions/keyRef" + } + } + } + } + } + }, + "keyRef": { + "type": "object", + "additionalProperties": false, + "required": ["name", "key"], + "properties": { + "name": { + "type": "string" + }, + "key": { + "type": "string" + } + } + }, + "securityContext": { + "description": "May be null / empty at any level: the chart then falls back to its hardening defaults (#99) instead of rendering an unhardened workload. The fallback reproduces THIS chart's defaults, including its pinned non-root uid for a root-by-default image (guideline 4).", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "pod": { + "description": "Merged onto the chart's pod hardening defaults and rendered as the pod's securityContext; kept open because the chart does not interpret the individual fields.", + "type": ["object", "null"] + }, + "container": { + "description": "Merged onto the chart's container hardening defaults and rendered as the container's securityContext; kept open because the chart does not interpret the individual fields.", + "type": ["object", "null"] + } + } + }, + "probe": { + "description": "Merged onto the chart's probe defaults and rendered as a container probe; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (#99) rather than the probe disappearing. The defaults carry the probe's context - the port NAME \"http\", so a changed container port moves the probes with it.", + "type": ["object", "null"] + } + } +} diff --git a/ytdlp-web-player/values.yaml b/ytdlp-web-player/values.yaml new file mode 100644 index 0000000..4b0c2a4 --- /dev/null +++ b/ytdlp-web-player/values.yaml @@ -0,0 +1,369 @@ +# Scale the whole chart down without uninstalling it: scaleDown: true renders +# every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and +# takes precedence over any per-workload replicas value. Services and Ingress +# stay in place; scaleDown: false brings the workloads back up. +# +# It does NOT reach into the oauth2-proxy subchart: that chart has its own +# replicaCount, and a value of this chart cannot override it implicitly. With +# the proxy enabled, scaling the whole release down means setting +# oauth2-proxy.replicaCount: 0 as well. +scaleDown: false + +secrets: + # op:// item path for the oauth2-proxy credentials. Read ONLY while the proxy + # is enabled and still points at the Secret this chart creates for it (see + # oauth2-proxy.config.existingSecret below); a plain install without the + # proxy needs no 1Password item at all. + # + # The item must carry these three fields, spelled exactly like this, because + # the operator copies an item's field names into the Secret's keys and the + # subchart reads them by name: + # + # client-id the OIDC client id of the provider application + # client-secret its client secret + # cookie-secret 16, 24 or 32 bytes, URL-SAFE base64 + # + # The cookie-secret is the one that costs an evening. oauth2-proxy decodes it + # as URL-safe base64 FIRST, so the obvious "openssl rand -base64 32" produces + # a value containing + and /, the decode fails, and the pod exits with + # "cookie_secret must be 16, 24, or 32 bytes". Generate it as: + # + # openssl rand -base64 32 | tr '+/' '-_' + # + # A field spelled differently does not fail the install: the operator writes + # a Secret without that key and the proxy pod stays in + # CreateContainerConfigError, which looks like a broken chart. + oauth2ProxySecretRef: null + +ytdlpWebPlayer: + # Replica count for this workload; 0 is allowed and scales it down. + # The chart-global scaleDown flag takes precedence. + # + # More than one replica is possible but shares nothing: each pod keeps its + # own /data emptyDir, so a video fetched by one pod is invisible to the + # other, and without session affinity the HLS segment requests of a single + # playback are spread over both. Raise it only behind an ingress that pins a + # client to a pod. + replicas: 1 + # DOCUMENTED DEVIATION FROM THE HARDENING BASELINE - runAsUser/runAsGroup. + # + # The upstream image carries no USER instruction and therefore runs as root + # by default (verified in the published Dockerfile for v1.2.0: python:3.13- + # alpine, apk add ffmpeg deno, no USER). That is a default, not a + # requirement: measured, the container runs as an arbitrary uid with + # readOnlyRootFilesystem: true and the full hardening baseline, serving the + # UI and completing a real transcode. Everything it writes goes below + # DATA_PATH and into the cache directory, and both are emptyDirs this chart + # mounts writable (see storage). So the chart pins a non-root uid rather than + # following the image, and this deviation is about the NUMBER only - no part + # of the hardening baseline is relaxed. + # + # The uid is arbitrary on purpose (20000, the repo default). Nothing in the + # image belongs to it - /app is world-readable and read-only here - and no + # PersistentVolume is involved, so no data is tied to the number. + securityContext: + pod: + runAsNonRoot: true + runAsUser: 20000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: Always + # RuntimeDefault applies the container runtime's default seccomp filter, + # which blocks the syscalls a normal workload never makes. It sits in the + # pod context on purpose: from here it covers every container in the pod + # including init containers, while a container-level seccompProfile would + # have to be repeated for each one (and silently missed on the next one + # added). A container that genuinely needs a different filter can still + # override it under securityContext.container, which wins over this value. + seccompProfile: + type: RuntimeDefault + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL + add: [] + # The application exposes no health endpoint, so all three probes use "/". + # + # What that path costs: it is Flask's index route, which renders the player + # template and touches neither ffmpeg, yt-dlp nor the network. Measured in + # k3d it answers in single-digit milliseconds, so probing it every 10s is + # free even while transcoding runs. + # + # What a green probe does NOT prove: that any video can be played. The app + # fetches its content from the internet at request time; with egress blocked + # or DNS broken, "/" still answers 200 while every playback fails. See + # NOTES.txt. + livenessProbe: + httpGet: + path: / + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # The startup probe gates liveness/readiness, so those need no + # initialDelaySeconds. Startup budget = 5s x 30 = 150s, which is generous: + # the app opened its port in about two seconds in k3d, and the budget only + # has to cover a slow image pull's first process start. + startupProbe: + httpGet: + path: / + port: http + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + # Extra environment variables, APPENDED to the ones the chart sets itself + # (DATA_PATH and PORT). Values are rendered through tpl; the value, + # secretKeyRef and configMapKeyRef forms are supported. + # + # This is where the application is configured. The upstream offers around + # twenty switches and this chart deliberately mirrors none of them as its own + # values: a chart that rebuilds every option is a lie one upstream release + # later, and it would also have to be kept in values.schema.json. The full, + # authoritative list is src/example.env in the upstream repository. As of + # v1.2.0 it holds APP_TITLE, THEME_COLOR, GENERATE_SPRITE_BELOW, AMOLED_BG, + # MAX_VIDEO_AGE, MAX_VIDEO_DURATION, DEFAULT_QUALITY, MAX_QUALITY, AUTOPLAY, + # MIN_LIVE_BUFFER, ALWAYS_TRANSCODE, DISABLE_TRANSCODING, MAX_PROCESSES, + # AUTOSKIP_SB_SEGMENTS, COOKIES_ONLY_ON_FAILURE, PLAYLIST_SUPPORT, + # AUTO_BG_PLAYBACK, AUDIO_VISUALIZER and PROXY. + # + # Do not set DATA_PATH, PORT, HOME or XDG_CACHE_HOME here: those four are the + # chart's, and a second entry with the same name silently wins (Kubernetes + # applies the LAST entry of the list) - pointing the app away from its + # writable volume, off the port the Service and probes use, or its caches + # back onto the read-only root filesystem. + # + # Example: + # env: + # - name: APP_TITLE + # value: "Player" + # - name: MAX_PROCESSES + # value: "2" + env: [] + resources: + # Limits are optional: memory and ephemeral-storage limits default to + # limitFactor x the request, an explicitly set limit always wins, and a + # CPU limit is only set when configured explicitly under "limits". + # + # No CPU limit on purpose (and this is the chart where it matters most): + # transcoding is what this app does, ffmpeg takes every core it is given, + # and a CPU limit does not fail a request - it throttles the encoder, which + # arrives at the viewer as a stuttering stream with nothing in any log. + # + # ephemeral-storage is the interesting number here, not memory. Downloaded + # and transcoded videos live in the /data emptyDir, and an emptyDir counts + # against the container's ephemeral-storage LIMIT. Going over it does not + # produce an application error: the kubelet evicts the whole pod, the + # reason sits on the pod rather than in the app's log, and every video in + # flight is gone with the emptyDir. + # + # Raising this number is the wrong first move. The knobs that decide how + # much lands there are MAX_VIDEO_AGE (how long a file survives after the + # last access), MAX_VIDEO_DURATION (upstream default 36000s = 10 hours, an + # enormous single file) and MAX_PROCESSES (how many can arrive at once) - + # all three via ytdlpWebPlayer.env. The limit only decides how the excess + # is discovered. + limitFactor: 3 + requests: + cpu: 0.5 + memory: 512Mi + # 2Gi request -> 6Gi limit at limitFactor 3. + ephemeralStorage: 2Gi + ports: + # The port the application listens on, and the single place it is + # configured: the chart derives the PORT environment variable from the + # entry named "http", the Service targets it by that name, and all three + # probes address it by name too. Changing the number here moves all four + # together - which is why the chart sets PORT itself instead of leaving it + # to ytdlpWebPlayer.env, where it could disagree with this list. + # + # 5000 is the upstream default (EXPOSE 5000) and unprivileged, so the + # AVD-KSV-0117 rule about containerPort < 1024 does not apply. + - containerPort: 5000 + protocol: TCP + name: http + +storage: + # DATA_PATH is set to /data and backed by an emptyDir: the app treats the + # directory as a cache and removes videos again after MAX_VIDEO_AGE, so there + # is nothing here worth a PersistentVolume - and nothing that survives a pod + # restart either. That is deliberate; see NOTES.txt. + # + # Optional size caps for the two emptyDirs. Leave them null and the only + # ceiling is the container's ephemeral-storage limit (see resources above); + # note that the outcome of hitting either is the same - the kubelet evicts + # the pod - so a sizeLimit buys a clearer eviction message, not a graceful + # failure. + dataSizeLimit: null + + # The second emptyDir, at /cache, with HOME and XDG_CACHE_HOME pointed at it. + # + # EVERY MOUNT HERE WAS MEASURED SEPARATELY, and the result is not the one the + # reflex predicts. Two runs of the image at uid 20000 with a read-only root + # filesystem, driven through metadata extraction, HLS transcoding, sprite + # generation and audio extraction: + # + # - /tmp is NEVER written to. Not once, in any of those paths. The usual + # "read-only rootfs, so mount an emptyDir on /tmp" is dead weight for + # this image, and it is not mounted here. + # - $HOME/.cache IS written to, and without it the log carries ten + # "OSError: [Errno 30] Read-only file system: '/.cache'" lines. Both + # bundled tools use it: deno for its module cache, yt-dlp for the + # JavaScript challenge-solver library it fetches from github.com. + # + # The failure is a WARNING, not an error - the run continues. What it costs + # is silent: the challenge-solver library is re-downloaded from github.com on + # every single extraction instead of once, which turns an outage there, or an + # egress rule that does not expect it, into broken playback with a warning + # nobody read. With the volume mounted, both tools' caches land in /cache and + # the log is clean. + # + # Nothing here is worth keeping across a restart - it is a cache in the + # literal sense, refilled from the network. + cacheSizeLimit: null + +ingress: + # Gates this chart's own Ingress. Everything the Ingress needs is read only + # inside its template - the application itself needs no hostname of its own + # (no base-URL setting, no absolute links, no url_for(_external=True)), so + # switching the Ingress off frees BOTH ingress.domain and + # ingress.clusterIssuer instead of only the issuer. + # + # WHO OWNS THE HOST, WITH THE PROXY ENABLED: the proxy sits in the data path, + # so with oauth2-proxy.enabled=true the Ingress belongs to the PROXY + # (oauth2-proxy.ingress) and this one is switched off; the app is then + # reached only through its Service. Leaving both on for the same host does + # not produce a merged route - ingress-nginx's admission webhook rejects the + # second Ingress outright - so the chart refuses to render that combination. + # Leaving this one on for a DIFFERENT host is allowed and means exactly what + # it says: an unauthenticated way past the proxy. + enabled: true + clusterIssuer: null + domain: null + className: nginx + # Extra annotations for the Ingress; merged with the cert-manager + # cluster-issuer annotation the chart always sets. + annotations: {} + +# --- oauth2-proxy subchart ------------------------------------------------- +# +# Everything below this line is passed to the oauth2-proxy chart +# (https://oauth2-proxy.github.io/manifests), not read by this chart - with the +# single exception of oauth2-proxy.enabled and oauth2-proxy.ingress.hosts, +# which the two guards above consult. The keys are the subchart's, the full +# list of them is in its own values.yaml, and this chart does not restrict +# them: whatever it does not set here stays at the subchart's default. +# +# The subchart's own conventions are NOT this repo's. It is upstream software +# and treated as such - we do not patch its templates, do not re-harden its +# workload and do not re-implement its values. Two consequences worth knowing +# before looking for them: +# +# - Its resource limits, probes and security context follow ITS defaults, not +# the rules in README "Chart style guidelines". Set them under +# oauth2-proxy.resources / .securityContext / .livenessProbe if you want +# this repo's shape; the chart does not impose it on you. +# - The 1Password operator's auto-restart and this repo's checksum/config +# mechanism do not reach into it either. Repointing +# secrets.oauth2ProxySecretRef at a different item changes the +# OnePasswordItem this chart renders, but nothing in the subchart's pod +# template - restart it by hand: +# kubectl -n rollout restart deployment/-oauth2-proxy +# +# The subchart itself has a conditional dependency on redis-ha (off by +# default), which is why a helm dependency update here pulls two repositories. +oauth2-proxy: + # Off by default. The chart is fully usable without it, nothing of the + # subchart is rendered, and no oauth2-proxy image is pulled. + # + # READ THIS BEFORE LEAVING IT OFF: the application has NO authentication of + # any kind and this chart adds none. An Ingress without the proxy is an open + # instance - anyone who reaches the URL can make it fetch and transcode + # arbitrary videos from the internet, on your egress and your CPU. + enabled: false + + # Whether a new proxy version reaches the cluster is decided by the + # dependency version in Chart.yaml, not here - see the comment at that line. + # It is the only remaining path for a proxy update, because the subchart is + # invisible to everything that watches Helm release versions. + + ingress: + # With the proxy enabled this is the Ingress that should be on, and + # ingress.enabled above the one that should be off. Set hosts to the same + # single host the app would otherwise carry. + enabled: false + className: nginx + # hosts: + # - player.example.com + # tls: + # - secretName: tls-player-example-com + # hosts: + # - player.example.com + annotations: {} + # cert-manager.io/cluster-issuer: letsencrypt + # # The second half of the 4kB cookie trap below: the response carrying + # # the session cookie does not fit in ingress-nginx's default 4k proxy + # # buffer, and what the browser gets is a 502 AFTER a successful login. + # nginx.ingress.kubernetes.io/proxy-buffer-size: "16k" + + config: + # The Secret holding client-id, client-secret and cookie-secret. This chart + # renders it as an OnePasswordItem (see secrets.oauth2ProxySecretRef above) + # exactly while this value still names it; point it at a Secret of your own + # and the chart creates none. + # + # The subchart runs this string through tpl, so .Release.Name resolves. + # .Chart.Name would NOT: inside the subchart it is "oauth2-proxy", so the + # parent chart name is written out literally here. + existingSecret: '{{ .Release.Name }}-ytdlp-web-player-oauth2-proxy-secret' + + # The proxy's whole configuration, and the reason it is a configFile rather + # than the structured config.upstreams: the subchart emits config.upstreams + # with toJson and WITHOUT tpl, so a {{ .Release.Name }} written there ends + # up in the manifest verbatim as those eleven characters. config.configFile + # is passed through "tpl ... $", which is what makes the release name + # reachable at all. A wrong upstream here is not a render error - it is a + # 404 after a successful login, in production. + configFile: | + email_domains = ["*"] + upstreams = ["http://{{ .Release.Name }}-ytdlp-web-player-service:80"] + + # Trust X-Forwarded-*. Without it the proxy considers every request + # plain http, and builds its redirect back to http:// - behind a + # TLS-terminating Ingress that is a redirect loop the moment a login + # completes. + reverse_proxy = true + + # Keep id_token and access_token OUT of the session cookie. With them in, + # a user with a longer group list produces a cookie past the browsers' + # 4kB limit; the browser drops it and the request comes back as HTTP 502 + # AFTER a successful login - with "[AuthSuccess]" in the proxy's log, + # which is why this one is hunted in the wrong place for hours. + # + # It is incompatible with pass_access_token, set_authorization_header and + # pass_authorization_header: switch any of those on together with this + # and the proxy refuses to start at all. This app needs none of them - it + # reads no identity headers whatsoever. + session_cookie_minimal = true + + # Set these to your provider. Example for an OIDC provider: + # provider = "oidc" + # oidc_issuer_url = "https://sso.example.com/application/o/player/" + # redirect_url = "https://player.example.com/oauth2/callback" + + # Leave this off. With alphaConfig.enabled=true the subchart generates a + # configuration WITHOUT any upstreams and ignores config.configFile + # silently - the login then succeeds and the app answers 404, because the + # proxy has nothing to forward to. It is off in the subchart's defaults too; + # it is spelled out here because switching it on looks like an upgrade. + alphaConfig: + enabled: false