Two defects kept the publish broken after the load-secrets-action v5 bump
in #140.
v5 no longer exports the loaded values as environment variables for later
steps the way v4 did; only the step outputs carry them. Every
"${{ env.JK_CR_* }}" reference therefore resolved to an empty string from
that bump on, and nothing reported it - the loading step logs "Populating
variable" and stays green either way. #149 moved three of the four
references to steps.<id>.outputs but left "-u ${{ env.JK_CR_USERNAME }}"
on the old form, and piped the username instead of the token into
--password-stdin.
The second defect is why this surfaced as a terminal error: an empty value
collapsed into a missing argument instead of failing. With an empty
username, "-u" swallowed the following "--password-stdin", helm stopped
reading the password from stdin, prompted interactively and died with
"inappropriate ioctl for device".
All four values now come from steps.<id>.outputs, are mapped into each
step's own env block and used as quoted shell variables guarded with ":?".
An empty or unset value aborts the job naming the op:// path to fix,
quoting keeps it from shifting arguments, and the token no longer appears
on the command line.
ci/run-trivy-config.sh renders every changed chart before scanning it and exits
non-zero when the render fails - which it does for freesailarr, because the
chart is excluded from the install-test and therefore has no
ci/freesailarr/test-values.yaml to fall back on. The scan job would go red
without ever scanning anything.
The file supplies only the values the chart declares required (secret ref,
cluster issuer, the three media claims and one ingress host per app, since the
chart has no global ingress.domain) and nothing that would alter the security
posture the scan is meant to look at.
Three review changes to the freesailarr chart.
Ingress: the chart no longer derives hosts as <app>.<ingress.domain> from one
Ingress object. The global ingress.domain is gone; every app carries its own
<app>.ingress.host and renders its own Ingress <release>-<chart>-<app>-ingress
with its own TLS secret tls-<release>-<chart>-<app>-ingress. The hosts are
therefore independent and may live in unrelated domains. The host is required,
but the required() call sits inside the loop over the *enabled* app ingresses,
so switching an app's ingress off must not make the chart demand a host for it.
clusterIssuer, className and annotations stay global defaults and are
overridable per app; an explicitly set per-app value wins (nil check instead of
"default", so an empty override is honoured).
Media: media.<category>.subPath mounts a sub-directory of the claim instead of
its root, default null (claim root = previous behaviour). The same
existingClaim may back all three categories with different subPaths. That is
the point rather than cosmetics: Radarr and Sonarr import by moving, and a move
within one filesystem can be a hardlink instead of a copy - across volume
boundaries every import duplicates the file.
Services: the collective Service with named ports is replaced by one ClusterIP
Service <release>-<chart>-<app>-service per enabled app that listens on a port,
flaresolverr included; recyclarr serves no port in daemon mode and gets none.
Each ingress points at its own app's service. All containers share gluetun's
network namespace, so the services differ only in their port - they provide
stable per-app DNS names, not network separation. The StatefulSet's governing
serviceName no longer names a removed object.
gluetun logs "clearing public IP data: open /tmp/gluetun/ip: permission
denied" every few seconds and never persists its public IP. It creates
/tmp/gluetun with mode 0644, i.e. without the execute bit needed to enter
the directory; it runs as root, but with all capabilities except
NET_ADMIN dropped there is no DAC_OVERRIDE left to paper over the missing
bit. An emptyDir provides the path with usable permissions.
qBittorrent had no network interface configured, so libtorrent listened
and announced on every interface of the shared pod network namespace -
including eth0 (the pod IP) and loopback. gluetun's kill switch drops
exactly those packets, so trackers answered with "skipping tracker
announce (unreachable)" or timed out, no peers were found and torrents
stalled at 0 bytes in stalledDL. Beyond the stall this is a leak: without
the binding qBittorrent also listens on the pod IP, i.e. reachable past
the tunnel.
The setting only exists in the persisted config file, which qBittorrent
creates itself on first start, so an init container seeds it before the
app comes up. The edit is idempotent and preserves existing user
settings: it replaces the keys if present, inserts them into the
[BitTorrent] section if missing, and creates file or section if needed.
The interface is configurable via qbittorrent.networkInterface (default
tun0); emptying it drops the init container.
With readOnlyRootFilesystem the s6-overlay init cannot rewrite
/etc/passwd, so the containers silently kept the image's built-in
uid/gid 911 instead of the configured PUID/PGID (verified at runtime) -
breaking ownership on the shared media volumes. Drop the now-unneeded
/run and /tmp emptyDirs; deviation documented in values.yaml.
Short tunnel re-negotiations recover internally; a kubelet restart only
prolongs the outage while the kill switch already blocks all egress.
Liveness budget 60s, readiness stays at 30s.
Replace the upstream-recommended postStart hook, which runs
concurrently with the entrypoint and loses the race (observed: it
deleted the ip rule the restarted gluetun had just created, leaving the
container in a permanent CrashLoop behind the kill switch). A command
wrapper now removes stale table-51820 rules and the leftover tun
device deterministically before exec'ing /gluetun-entrypoint, covering
container restarts after unclean exits.
An uncleanly exited gluetun leaves its ip rule in the shared pod
network namespace; the restarted container then fails forever with
'adding ip rule 101: file exists' while the kill switch keeps blocking
all egress. Apply the upstream-recommended Kubernetes fix: a postStart
hook dropping table-51820 rules.
The image copies and patches the chromedriver binary to the hardcoded
path /app/chromedriver at startup (no env override exists) and writes
chromedriver caches under /app, so readOnlyRootFilesystem cannot hold;
deviation documented in values.yaml. Reverts the HOME=/tmp workaround,
which only covered the cache path, not the binary patching.
The image's home is /app on the read-only root filesystem, but
flaresolverr writes chromedriver caches under $HOME (/app/.local);
pointing HOME at the /tmp emptyDir keeps the root filesystem read-only.
The image declares its non-root user by name only, which the kubelet's
runAsNonRoot check cannot verify (CreateContainerConfigError); pinning
the uid/gid numerically satisfies the check.
The issue plan listed qmcgaw/gluetun:3.41.1, but the published tag is
v3.41.1. appVersion is the exact image tag per the style guide, so
appVersion becomes v3.41.1 and the chart version 1.0.0+upv3.41.1
(precedent: matrix-synapse, satisfactory, calibre-web-automated).
Sets seccompProfile.type: RuntimeDefault in the pod security context of
minecraft-router, outline, urlaubsverwaltung, zipline, voucher-vault,
satisfactory, patchmon and calibre-web-automated, so the container
runtime's default syscall filter applies to every container in the pod.
The value lives in the pod context rather than the container context
because it covers init containers and any container added later without
being repeated; a container that needs a different filter can still
override it under securityContext.container, which wins over the pod
value.
Five of the eight also get the docker.io/ prefix on their previously
unqualified image references, so the pull no longer depends on the
runtime's default-registry setting: itzg/mc-router,
outlinewiki/outline, urlaubsverwaltung/urlaubsverwaltung,
l4rm4nd/vouchervault (twice, app and init container) and
guacamole/guacd:1.6.0. zipline, satisfactory and calibre-web-automated
were already qualified with ghcr.io.
Verified in a throwaway k3d cluster against real application work, not
just a Running pod, and with Seccomp: 2 (filter mode) read from
/proc/<pid>/status inside each container:
- minecraft-router: a Minecraft handshake for a mapped domain travels
through the router to a backend and the backend's status payload comes
back to the client.
- outline: all migrations apply (45 tables), OIDC discovery succeeds and
the server-rendered page answers 200 with <title>Outline</title>.
- urlaubsverwaltung: Liquibase applies 79 changesets (35 tables), the app
seeds its settings and vacation types, /login answers 200.
- zipline: 27 migrations applied, then a full round trip - first user
created, login, token, upload, and the file downloaded back byte-exact
from the PVC.
- voucher-vault: the init container's collectstatic output is served
(200, 22 KB), Django has its 22 tables and uWSGI answers 200 with
<title>VoucherVault</title>.
- satisfactory: application behavior is NOT proven - the image is
multiple GB and pulls 10 GB+ of game files on first start, which is why
the chart sits in ci/excluded-charts.txt. What is proven is that the
rendered pod securityContext is accepted server-side and yields
Seccomp: 2 with uid/gid 1000 on a substitute image.
- patchmon: both containers run under the pod profile and both report
Seccomp: 2. The server applies 47 migrations (44 tables), connects to
Postgres and Redis and serves its dashboard; guacd answers a real
Guacamole "select rdp" instruction with its full args list, over the
same 127.0.0.1:4822 path the chart wires up.
- calibre-web-automated: the readOnlyRootFilesystem: false deviation is
unaffected - id abc is still uid 1000 (not the 911 the image falls back
to in read-only mode), /config and the Calibre metadata.db are owned by
1000, and both the root s6 init and the uid-1000 application report
Seccomp: 2. calibredb 9.1 runs and adds a book. Its IPC worker prints a
traceback while doing so, but an unconfined control pod on the same
image prints the same class of traceback, so it is not the filter.
trivy config now reports "0 to act on" for all eight; the untouched
samba still shows KSV-0104 and KSV-0030, which confirms these charts are
what closed the findings.
No values.schema.json needed changing: in all eight schemas
securityContext.pod is type ["object", "null"] with no property
restrictions, so the new key validates as it stands. The #99 null paths
still hold - nulling securityContext or securityContext.pod falls back to
the defaults including the new one.
Renovate resolves the prefixed guacd reference: a local dry-run lookup
(renovate 44.40.1, --platform=local --dry-run=lookup) reports depName
docker.io/guacamole/guacd against registryUrl https://index.docker.io
with lookupName guacamole/guacd, currentVersion 1.6.0 and its real
release timestamp, with no warnings. The other four prefixed references
live in templates, which Renovate never reads - they are tracked by the
customManagers via appVersion in Chart.yaml with a depName hardcoded in
renovate.json5, so they are unaffected.
Major bump for all eight: runtime behavior changes.
Refs #84
The rationale called the alpine switch a proposal from the issue text.
A later comment on #84 commissions it as a check with a defined scope
(nginx.conf compatibility, port 8080, uid 101, /tmp redirection) to be
done alongside the seccompProfile PR for this chart. The outcome is open,
the check is not - the constraint has to survive either way.
The anchored form rejected any tag with a suffix. Docker versioning only
offers candidates carrying the current tag's suffix, so the day the
delegation sidecar moves to a slimmer base image (#84, still open) every
candidate would fail the filter and nginx would stop receiving updates -
without an error, a warning, or a red dashboard.
Verified against currentValue 1.28.0-alpine: anchored form returns
"updates": [] and "warnings": [], the suffix-tolerant form proposes
1.30.4-alpine and still refuses the newer 1.31.x mainline.
nginx numbers its release branches by minor: even is stable, odd is
mainline. PR #139 proposed 1.30.4 -> 1.31.4, which would have moved the
Synapse delegation sidecar onto mainline without that ever being a
decision. allowedVersions now permits even minors only; patch updates
inside the stable branch still come through and still automerge.
The two open Renovate PRs (#138 alpine 3.22 -> 3.24, #139 nginx 1.30.4 ->
1.31.4) never automerged because both are minor updates while the rule
only covered patch and digest. Sidecar images barely move in patch steps,
so patch-only would practically never fire.
Automerging a chart update publishes it, so the install-test can no
longer report failures without blocking: continue-on-error is gone and
issue #22's stabilization phase ends here. Majors stay manual.
Renovate only ever rewrites appVersion or an image tag, so every PR it
opened against a chart directory failed the build-chart version check
(#138, #139). ci/renovate-bump-chart.sh recomputes
version: <semver>+up<appVersion> as a postUpgradeTasks hook: chart major
when the app major moves, chart patch otherwise.
Each chart renders its main image as <repo>:{{ .Chart.AppVersion }}, so
repository and version live in different files and Renovate cannot join
them. The mapping is one customManager per chart in renovate.json5,
deliberately not a comment inside each Chart.yaml - that would touch all
20 chart directories and force 20 publishes for a comment change.
Patch and digest updates automerge after a 3-day minimumReleaseAge once
the pipeline is green; majors arrive one line at a time to match the
stepwise app-major procedure. ci/** stays excluded: the install-test
fixtures pin throwaway infrastructure to major lines on purpose.
One step: 2025-08-05 is the immediate successor release, no publication
in between (verified against the project's release list). Tag present on
Docker Hub for amd64 and arm64.
Patch bump, because the changelog review found nothing that touches the
values surface or the chart's behaviour. Checked against the shipped
source of both images rather than against the release notes:
- whitelist.txt format: lib/Configuration.php is byte-identical apart
from its VERSION constant, so the parsing the chart relies on (trim,
split on newline, drop empty entries, "*" wildcard) is unchanged.
- the five production bridges: all present, none renamed. Youtube,
TheHackerNews, ARDMediathek and Reddit are byte-identical; Heise has a
content-level change only (paywalled heise+ items now carry an
archive.is link, two more CSS selectors stripped). The three bridges
removed upstream - Askfm, DansTonChat, FirstLookMediaTech - are none
of ours.
- RSSBRIDGE_* environment mapping: same file, therefore unchanged.
- write paths: re-derived empirically rather than inferred, because the
opcache lock file in /tmp appears in no filesystem diff. The new image
needs exactly the same five, and a control run without /tmp still dies
with "Unable to create lock file: Read-only file system".
- health endpoint: actions/ is identical, /?action=health intact.
- PHP 8.2.28 -> 8.2.29, nginx and Debian unchanged, so the
/run/php/php8.2-fpm.sock path the chart mounts still applies. Measured
fresh-start peak under five concurrent feed fetches: 64.5 MiB on the
new image against 71.1 MiB on the old one under an identical
procedure. Requests and limits stay as they are.
The memory comments in values.yaml are refreshed to the image the chart
now ships, and the older 119 MiB figure is labelled as what it was - a
warm container, not a fresh start.
We stay on the dated release tag. The newest release is itself over
twelve months old and the project now pushes latest and commit-based
sha-* tags continuously, but a sha-* tag is not more released than the
release, only newer, and latest is rolling - both conflict with the
repo's convention of reproducible pinned tags.
Fixes#135
Follow-up to the template-chart hardening fixes in #133, which landed
while this branch was open.
The env block now quotes names, values and keyRef fields. Without it a
value like "5" renders as a YAML number and the API server rejects the
whole manifest. RSS-Bridge takes numeric settings that way
(RSSBRIDGE_http_timeout), so this was reachable with the first env entry
anyone adds.
The port-80 question is settled by measurement rather than by the
"it works in k3d" reasoning #133 warns about. Same pod spec in k3d
(k3s v1.35.5), only ip_unprivileged_port_start changed:
1024, capabilities.drop [ALL] nginx: bind() to 0.0.0.0:80 failed
(13: Permission denied)
1024, plus NET_BIND_SERVICE identical failure - for a non-root
process the capability lands in the
permitted but not the effective
set, so it buys nothing
0 binds, HTTP 200
So the chart cannot buy safety with a capability the way samba does, and
port 80 is not negotiable either: nginx is configured for it inside the
image and the entrypoint's only lever is a sed against a read-only
/etc/nginx. What it can do is stop depending on the node default - the
pod securityContext now pins the sysctl to 0, so the bind no longer
relies on cluster configuration. Verified: the chart-installed pod
reports 0 and answers 200 through the Service.
Worth recording how that failure looks without probes: the entrypoint
backgrounds nginx and then execs php-fpm, so a failed bind leaves a
container reporting Running with no web server at all. Both test pods
above were Running. The probes on /?action=health are what turn it into
a pod that never becomes Ready.
trivy config still reports 0 to act on; the KSV-0117 statement is
updated to the measured reasoning.
Sets seccompProfile.type: RuntimeDefault in the pod security context, so
the container runtime's default syscall filter applies to every container
in the pod.
The value lives in the pod context rather than the container context
because it covers init containers and any container added later without
being repeated; a container that needs a different filter can still
override it under securityContext.container, which wins over the pod
value.
Verified in k3d against the database path rather than a static page:
under the filter the app opens its connection to Postgres and Prisma
applies all migrations (141 rows in _prisma_migrations, 31 tables
created), and the server-rendered pages answer with HTTP 200. The kernel
reports Seccomp: 2 (filter mode) inside the container.
Major bump: runtime behavior changes.
Refs #84
Two findings from the Trivy config scan that landed on main in #129.
AVD-KSV-0125 is fixed rather than excepted: writing the image as
docker.io/rssbridge/rss-bridge puts a real registry in the registry
position instead of "rssbridge", which is what ci/trivy/data/ksv0125.yaml
asks for. Verified in k3d that the qualified reference resolves to the
same digest (sha256:0eca99b2...) and still runs.
AVD-KSV-0117 (containerPort below 1024) becomes a scoped, documented
exception. nginx listens on 80 inside the image, and the entrypoint's
only way to change that is a sed against /etc/nginx/sites-enabled/default
which a read-only root filesystem rejects - so the alternative to port 80
is giving up readOnlyRootFilesystem. The premise of the check does not
hold here anyway: the container binds 80 as uid 33 with all capabilities
dropped and no NET_BIND_SERVICE.
trivy config now reports 0 to act on for this chart.
Takes over the four loose manifests in cluster-config/rss-bridge/ as a
proper chart, on the current template-chart standard: strict values
schema, null-safe helpers, computed limits, replicas/scaleDown,
checksum/config, NOTES.txt.
Two things deviate from the repo standard on purpose:
- The Ingress models host AND TLS secret name per entry instead of the
single conventional tls-<release>-<chart>-ingress. The installation
this replaces has grown three separate certificates, one per host.
Renaming them would make cert-manager issue three new ones and leave
each host unreachable until it did - a self-inflicted outage on a
deployment that has been up for over a year. Entries sharing a secret
name are grouped into one tls entry, and omitting the name falls back
to the convention, so consolidating later is a values change.
- Requests and limits are decoupled. The old manifest requested 2Gi of
ephemeral storage, identical to its limit, reserving two gigabytes for
a workload measured at 2.2 MB after fetching all five whitelisted
bridges. The request drops to 64Mi; the limit stays at 2Gi.
Hardening was verified rather than assumed. The image ships no USER and
runs as root by default, but runs fully hardened once its writable paths
are provided: uid/gid 33, readOnlyRootFilesystem, all capabilities
dropped, seccompProfile RuntimeDefault. The writable paths were found by
running the image with a writable rootfs, exercising every bridge and
reading its diff layer - /app/cache, /run, /run/php, /var/lib/nginx.
/tmp did not show up that way, because PHP's opcache creates its lock
file there and unlinks it immediately; it only surfaced when the
hardened container died at startup. Stopping at the first writable path
would have shipped a chart that cannot start.
Probes use the app's own /?action=health, which renders fixed JSON
without touching the cache or any remote site, so neither a scraped site
going down nor an unwritable cache can restart the pod.
Fixes#128
Five changes to the scaffold every future chart is copied from:
1. seccompProfile.type: RuntimeDefault in the pod security context, in
line with the rest of the seccomp series (#84). Pod context, because
from there it also covers init containers and any container added
later without being repeated.
2. containerPort 80 -> 8080, clearing AVD-KSV-0117. Note that the usual
justification for this does not hold: a non-root container CAN bind
port 80 on current Kubernetes, because the kubelet sets
net.ipv4.ip_unprivileged_port_start=0 in the pod network namespace.
Measured, not assumed - see the values.yaml comment. The reason to
stay above 1024 is that this is a runtime default rather than a
guarantee, and that every real chart here already does.
3. The ConfigMap placeholder key is renamed from the literal "key" to
"example-setting", clearing AVD-KSV-01010 (secret-scanner patterns
match any key named "key").
4. The image placeholder is fully qualified as ghcr.io/ORG/IMAGE, so the
template stops teaching the unqualified form that produced the
repo's unqualified Docker Hub images.
5. env values are rendered through "quote". Without it a value like
"8080" renders as a YAML number and the API server rejects the whole
manifest ("cannot unmarshal number into Go struct field
EnvVar.value of type string"). Every real chart in the repo already
quotes here; only the template did not. Found by instantiating the
template and installing it, not by rendering it.
Verified by instantiating the scaffold into a working chart per the
README procedure and installing it in k3d: the workload becomes Ready,
the Service routes to the app on 8080 and an ephemeral container added
to the running pod inherits Seccomp: 2 from the pod context.
Refs #84