freesailarr: shared domain with sub-paths, qBittorrent save path (3.0.0+up1.0.0)

Adds a second, optional ingress shape and fixes the download target of
qBittorrent. The existing per-app-host shape stays the default and renders
unchanged.

Shared domain (#167)

Setting ingress.host moves every app without a host of its own into one
Ingress object with one path each (<app>.ingress.path, default /<app>), one
TLS secret and therefore one DNS name and one certificate for the whole
stack. A per-app host always wins and keeps its own object, so mixed
operation needs no extra values.

The apps differ in what they can do under a sub-path, and the chart follows
that rather than pretending they are alike:

  - radarr, sonarr, prowlarr get <APP>__SERVER__URLBASE from the chart, but
    only when their own env block does not already carry the variable.
  - bazarr has no environment variable for its base path and stays one
    documented manual step (Settings -> General -> Base URL).
  - qbittorrent has no base path at all and renders a second object on the
    same host with a prefix-stripping rewrite. It repeats the same TLS secret
    name and deliberately omits the cert-manager annotation, so both objects
    state their own TLS while only one certificate is requested.
  - seerr has no base path either and cannot be rewritten, so it is pinned to
    "/" and a fail refuses any other path. A second fail refuses two apps
    claiming the same path.

qBittorrent save path (#166)

Without Session\DefaultSavePath qBittorrent writes to its built-in default
/config/Downloads - the 1Gi config volume. It fills within minutes, every
torrent goes to "errored" and qBittorrent can no longer write its own
metadata to BT_backup, so a restart in that state costs the torrent list.

The init container (renamed qbittorrent-config-interface ->
qbittorrent-config-seed, since it no longer only sets the interface) now
seeds qbittorrent.savePath, default /downloads. Unlike the interface binding
it is inserted ONLY when the key is absent and never replaced: a path changed
in the WebUI is a legitimate operator decision and must survive a restart.

This is the reason for the major bump: an installation that never set the
path writes somewhere else after the upgrade.

Documentation

NOTES.txt and the media block in values.yaml now spell out why a shared claim
with three subPaths does not produce hardlinks: the kernel compares the
vfsmount in do_linkat, not the superblock, so two bind mounts of one
filesystem are EXDEV - while stat reports the same device number on both
sides and suggests the opposite. The layout that would work (one claim, one
mount, categories as sub-directories) is named as a possibility; the chart
cannot express it today.

README.md gains the shared domain as the second admissible ingress shape of
the style guideline.
This commit is contained in:
jklein committed 2026-08-25 20:55:43 +02:00
1 parent b2d74ca5fb
commit fc768efaf5
8 files changed
+559 -46

No files matched your search

+70 -14
View File
@@ -34,21 +34,31 @@ spec:
automountServiceAccountToken: false
securityContext:
{{- include "freesailarr.podSecurityContext" . | nindent 8 }}
{{- if and .Values.qbittorrent.enabled .Values.qbittorrent.networkInterface }}
{{- if and .Values.qbittorrent.enabled (or .Values.qbittorrent.networkInterface .Values.qbittorrent.savePath) }}
initContainers:
# qBittorrent has no environment variable for the session's network
# interface - it only reads it from the persisted config file, which
# the app itself creates on first start. Without the binding
# libtorrent listens and announces on every interface of the shared
# pod network namespace (eth0/pod IP and loopback included); gluetun's
# kill switch drops those packets, trackers stay unreachable and
# torrents stall at 0 bytes. Seeding the two keys before qbittorrent
# starts closes that leak and makes a fresh install work out of the
# box. The edit is idempotent and preserves all other settings:
# existing keys are replaced, missing ones are inserted into the
# [BitTorrent] section (qBittorrent 5.x reads them there), and a
# missing file or section is created.
- name: qbittorrent-config-interface
# qBittorrent reads neither the session's network interface nor its
# default save path from the environment - both live only in the
# persisted config file, which the app itself creates on first start.
# This container seeds them before qbittorrent starts, so a fresh
# install works out of the box. Both edits preserve all other settings:
# keys are inserted into the [BitTorrent] section (qBittorrent 5.x
# reads them there), and a missing file or section is created.
#
# The two keys are handled DIFFERENTLY on purpose:
#
# - Session\Interface is ENFORCED on every start. Without the binding
# libtorrent listens and announces on every interface of the shared
# pod network namespace (eth0/pod IP and loopback included);
# gluetun's kill switch drops those packets, trackers stay
# unreachable and torrents stall at 0 bytes. A deviating value is a
# leak, so an existing key is replaced.
# - Session\DefaultSavePath is only INSERTED WHEN ABSENT, never
# replaced. Without it qBittorrent falls back to /config/Downloads,
# i.e. the small config volume, which fills within minutes and puts
# every torrent into "errored" (#166). But where downloads go is a
# legitimate operator decision: someone who changes the path in the
# WebUI must not have it overwritten on every pod restart.
- name: qbittorrent-config-seed
image: "{{ .Values.qbittorrent.configInit.image.repository }}:{{ .Values.qbittorrent.configInit.image.tag }}"
imagePullPolicy: IfNotPresent
command:
@@ -65,6 +75,7 @@ spec:
printf '\n' >> "$conf"
fi
grep -q '^\[BitTorrent\]$' "$conf" || printf '[BitTorrent]\n' >> "$conf"
{{- if .Values.qbittorrent.networkInterface }}
# Drop any existing binding keys, then re-insert them directly
# after the [BitTorrent] header.
awk -v ifc="$QBT_INTERFACE" '
@@ -78,9 +89,30 @@ spec:
' "$conf" > "${conf}.tmp"
mv "${conf}.tmp" "$conf"
echo "qBittorrent session bound to interface $QBT_INTERFACE"
{{- end }}
{{- if .Values.qbittorrent.savePath }}
# Insert only when the key is absent - an existing value is the
# operator's and stays untouched.
if grep -q '^Session\\DefaultSavePath=' "$conf"; then
echo "qBittorrent save path already set, left untouched: $(grep -m1 '^Session\\DefaultSavePath=' "$conf")"
else
awk -v sp="$QBT_SAVE_PATH" '
{ print }
/^\[BitTorrent\]$/ { print "Session\\DefaultSavePath=" sp }
' "$conf" > "${conf}.tmp"
mv "${conf}.tmp" "$conf"
echo "qBittorrent default save path seeded: $QBT_SAVE_PATH"
fi
{{- end }}
env:
{{- if .Values.qbittorrent.networkInterface }}
- name: QBT_INTERFACE
value: {{ .Values.qbittorrent.networkInterface | quote }}
{{- end }}
{{- if .Values.qbittorrent.savePath }}
- name: QBT_SAVE_PATH
value: {{ .Values.qbittorrent.savePath | quote }}
{{- end }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "configInit" "given" .Values.qbittorrent.configInit.resources) | nindent 12 }}
securityContext:
@@ -239,6 +271,14 @@ spec:
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
{{- /* Base path for the shared-domain mode: the chart sets
UrlBase itself as soon as prowlarr renders under a
sub-path, unless the env block above already carries
the variable - see the freesailarr.urlBase helper. */}}
{{- with (include "freesailarr.urlBase" (dict "root" $ "app" "prowlarr")) }}
- name: PROWLARR__SERVER__URLBASE
value: {{ . | quote }}
{{- end }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.prowlarr.env) | nindent 12 }}
ports:
- containerPort: 9696
@@ -300,6 +340,14 @@ spec:
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
{{- /* Base path for the shared-domain mode: the chart sets
UrlBase itself as soon as radarr renders under a
sub-path, unless the env block above already carries
the variable - see the freesailarr.urlBase helper. */}}
{{- with (include "freesailarr.urlBase" (dict "root" $ "app" "radarr")) }}
- name: RADARR__SERVER__URLBASE
value: {{ . | quote }}
{{- end }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.radarr.env) | nindent 12 }}
ports:
- containerPort: 7878
@@ -368,6 +416,14 @@ spec:
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
{{- /* Base path for the shared-domain mode: the chart sets
UrlBase itself as soon as sonarr renders under a
sub-path, unless the env block above already carries
the variable - see the freesailarr.urlBase helper. */}}
{{- with (include "freesailarr.urlBase" (dict "root" $ "app" "sonarr")) }}
- name: SONARR__SERVER__URLBASE
value: {{ . | quote }}
{{- end }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.sonarr.env) | nindent 12 }}
ports:
- containerPort: 8989