# 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. MUTUALLY EXCLUSIVE WITH httpRoute.enabled - # the render aborts when both are true, and the message names both value # paths. # # CORRECTED WITH THE GATEWAY-API SUPPORT (Gitea #114): this used to say that # everything the Ingress needs is read only inside its template, and that # switching the Ingress off therefore freed BOTH ingress.domain and # ingress.clusterIssuer. The second half still holds - the application needs # no hostname of its own (no base-URL setting, no absolute links, no # url_for(_external=True)) and this mode renders no Certificate, so # clusterIssuer is still freed. But ingress.domain is now read by the # HTTPRoute as well, so it is freed only when BOTH modes are off. # # 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. # # INERT IN GATEWAY MODE - the Gateway API has no annotation channel for # controller behaviour. Note which annotation this is NOT about: the 16k # proxy-buffer-size this chart recommends belongs on the PROXY's Ingress # (oauth2-proxy.ingress.annotations), and the subchart keeps rendering an # Ingress in either mode, so that one is unaffected by the switch. annotations: {} # THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114). # # Mutually exclusive with ingress.enabled - the render aborts when both are # true. The chart renders ONLY an HTTPRoute with parentRefs: no Gateway, no # class, no Certificate, and no implementation-specific policy object. Class # and certificate sit on the Gateway and its listeners and belong to the # platform (cluster-config). # # BEFORE SWITCHING THIS ON, KNOW WHAT IT DOES NOT DO. The oauth2-proxy # SUBCHART HAS NO GATEWAY SUPPORT and this chart cannot give it one - upstream's # chart is consumed as it comes (README, "Chart dependencies"), so it renders an # Ingress or nothing. In the RECOMMENDED configuration, where the proxy sits in # the data path and owns the host while this chart's own routing object is off, # the object that publishes the release is THE SUBCHART'S INGRESS whatever this # value says. Turning httpRoute on there buys nothing. # # This route is for the other two cases: # - the proxy switched off (oauth2-proxy.enabled: false), so this chart # publishes the application itself; # - the application on a host of ITS OWN beside the proxy's host, which is # allowed and means exactly what it says: an unauthenticated way past the # proxy. # # AND THE HOST GUARD IS SHARPER IN THIS MODE, NOT WEAKER. If this chart and the # proxy's Ingress claim the SAME host: # - in Ingress mode ingress-nginx's admission webhook rejects the second # object loudly (the Fleet bundle goes to ErrApplied); # - in Gateway mode the webhook NEVER FIRES - it compares Ingress objects # with Ingress objects, and an HTTPRoute is a different kind. Both objects # stay valid and one hostname is served over two data paths that are NOT # equivalent: the proxy's Ingress goes through authentication, this chart's # HTTPRoute goes straight to the application. Which one a client reaches # depends on DNS, so it is an authentication bypass that comes and goes # with a record and that nothing in Kubernetes reports. # The chart therefore refuses to render that combination in BOTH modes, via the # ytdlp-web-player.published helper (ingress.enabled OR httpRoute.enabled). # Before #114 that guard asked ingress.enabled, which is false by construction # in Gateway mode - it would have gone silent exactly where it matters most. # # NOTHING WAS MEASURED ON A LIVE INGRESS OF THIS CHART, because there is none. # Checked 2026-10-12: the release runs in namespace jlab-player with both # Deployments Ready, and not one of the cluster's 23 Ingress objects is in that # namespace - neither this chart's nor the subchart's. What publishes it # instead could not be determined from this workspace (its identity may not # list httproutes or secrets). An open question, not a guess. # # THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them # (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute # without them fails at install, so this is a value and not a guess. httpRoute: enabled: false # The Gateway(s) the route attaches to - a platform object, usually in # another namespace, whose listener must allow this namespace in # allowedRoutes. Entries are the Gateway API's ParentReference (name, and # optionally namespace, sectionName, port, group, kind), passed through # unchanged: # # parentRefs: # - name: public # namespace: gateway # sectionName: https # # REQUIRED while httpRoute.enabled is true - the render aborts on an empty # list, because a route without a parent is created, is valid and routes # nothing, with no event to say so. parentRefs: [] # Empty means [ingress.domain], so the hostname has one source of truth. Set # explicitly only to publish under a different or an additional name - which # is how a migration from the Ingress runs, and also how the application is # given a host of its own beside the proxy's. The host guard reads this list # too, so a hostname set here that the proxy's Ingress also claims is caught. hostnames: [] # --- 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 # --------------------------------------------------------------------------- # NetworkPolicy (Gitea #5). On by default. The reference implementation with # the full reasoning is template-chart; the convention is in README, "Chart # style guidelines", and the operator's view on this chart's wiki page. # --------------------------------------------------------------------------- networkPolicy: # WHAT THIS POLICY ALLOWS: inbound 5000 only, and outbound DNS plus TCP 80 # and 443 to ANY destination - fetching media is the application, and this # object cannot name a host. # # WHAT IT DOES NOT COVER: THE OAUTH2-PROXY SUBCHART'S PODS. This policy # selects this chart's own workload by its app label. The subchart renders # its own Deployment with its own labels and ships no policy, so with the # proxy enabled the pod that actually faces the Ingress is unrestricted in # both directions. That is a gap in the convention, not a choice of this # chart: an upstream chart is consumed as it comes (README, "Chart # dependencies"). Do not read "this chart has a NetworkPolicy" as "this # release is covered". # # Inbound 5000 is not restricted by source in the default (probe port), and a # policy is only a boundary where the CNI enforces one. enabled: true # Namespaces whose pods may reach this workload, in addition to the release's # own namespace, which is always allowed. Empty means: own namespace only. # # Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every # namespace itself, so no labelling work is needed on a namespace this chart # does not own - and no typo protection either: a name that does not exist # matches nothing and is silently ineffective. Note the granularity: an entry # opens the port to EVERY pod in that namespace. allowedNamespaces: [] # Source CIDRs allowed to reach the port named "http" (5000), which all three probes address. # # EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a # "from" selector, so the port is reachable from anywhere the CNI routes to - # inside the cluster, since it carries no NodePort. # # That is the deliberate choice between two bad defaults. The kubelet's probes # originate from the IP of the WORKER NODE running the pod, an address this # chart cannot know and must not guess: a CIDR that excludes the real node # blocks every probe, the pod never becomes Ready, the rollout times out, and # the symptom reads as a broken application rather than as a policy. # # Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind # the companion trap: once this list is non-empty the rule IS restricted, so # everything else that needs the port has to be in it. The chart adds the own # namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason. probeSources: [] # The ingress controller allowed to reach the routed port once probeSources # restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in # the restricted branch - with the default (open) rule the controller reaches # the port anyway. The two keys become ONE "from" entry and are therefore an # AND: pods carrying podLabels IN namespace. That is the point of having them # at all - the controller's namespace in allowedNamespaces would open the port # to every pod in kube-system. # # MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone, # the only IngressClass is "traefik" and it is the default, and the controller # is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the # Gateway API here, so this one source covers the Ingress and the HTTPRoute # path alike. A policy written against the wrong controller is accepted, looks # strict, and silently blocks all web traffic - and ingress.className says # nothing about it, because a class names an implementation, not the pods the # traffic comes from. ingressController: namespace: kube-system podLabels: app.kubernetes.io/name: rke2-traefik egress: # Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and # has no switch - a knob whose only other setting breaks every name lookup # in the pod is a trap, not a feature. # # PORTS, NOT DESTINATIONS, and that is a limit of the object rather than # laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames, # so "only to the database host" cannot be written at all. Each entry # permits its port to ANY destination. What the cap still buys is real: a # compromised pod cannot open arbitrary outbound connections. # # 80 and 443 are already allowed. Add a port only for a source that serves # media elsewhere. extraPorts: []