Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 3s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 10s
Install-test / Install-test ${{ fromJSON(inputs.charts) }} (pull_request) Successful in 2m35s
Validate Helm Charts / Install-test (pull_request) Successful in 2m35s
ConvertX is a self-hosted file converter with a web UI. StatefulSet with one replica, a PVC at /app/data holding the SQLite database, the uploads and the converted output, a ClusterIP Service and the standard Ingress. No external database, cache or object storage. The chart runs non-root as uid/gid 1000 and bypasses the image entrypoint. The image carries no USER, so it starts as root, and /entrypoint.sh exists only to drop to uid 1000 with gosu. Overriding command with the image's own CMD lets Kubernetes do that work instead - runAsUser/runAsGroup 1000 and fsGroup in place of the entrypoint's recursive chown - so the repo's hardening baseline stays intact rather than being relaxed for this image. What that costs is named at the command: line in the StatefulSet: if upstream ever moves real work into the entrypoint, this chart skips it silently, with no error and no failed probe. The entrypoint of v0.19.0 does nothing but uid/gid plumbing (and with PUID unset its first branch is already a plain exec "$@"), and ConvertX migrates its SQLite schema in-process at import, so the bypass is safe today. It is to be re-read at every app-version update. The one line the bypass does lose is "export HOME=/home/convertx", so the chart sets HOME itself instead of relying on the runtime's passwd lookup, and mounts an emptyDir there: LibreOffice, Calibre, Inkscape and fontconfig build their profiles under HOME on the first conversion, and it sits on the read-only root filesystem. /tmp is the second emptyDir, for the toolchain's scratch files. Both count against the container's ephemeral-storage limit, which is why that request is sized for them. uid 1000 rather than the repo's arbitrary 20000, deliberately: the image owns /home/convertx with it, and the uid owns data on a PersistentVolume, so the number is not free to tidy later. The fallback defaults in _helpers.tpl reproduce the deviation rather than the repo standard. All three probes use /healthcheck, a static 200 that the application exempts from authentication. "/" is never a 200 with this chart's defaults: it answers 302 to /setup while no account exists and to /login afterwards, so a probe against it would pass on a redirect whose meaning changes on day two. The startup budget is 300s because ConvertX shells out to ~20 converter binaries at import just to log their versions. Values: - secrets.jwtSecretRef, required, field "jwt-secret". Without JWT_SECRET the application mints a random one per process, so every restart invalidates every session - silently. - accountRegistration, allowUnauthenticated and httpAllowed, all false. Upstream warns explicitly against the first two on an internet-facing instance; the warning is carried into values.yaml and NOTES.txt. They go through a helper that renders the literal "true"/"false" the application's strict comparison needs and aborts the render on anything else - "yes" would otherwise be filed under "off" without a word. - autoDeleteEveryNHours, default 24, 0 disables. It governs retention on the PVC. The 0 is honoured through an explicit nil check; Helm's "default" would swallow it and restore 24-hour deletion on the install that asked for none. - ingress.maxUploadFileSize, default 1024m. ConvertX enforces no size limit of its own (maxRequestBodySize is MAX_SAFE_INTEGER), so unlike outline there is exactly one ceiling here and this is it. Switching the Ingress off frees BOTH domain and clusterIssuer, checked in both directions: ConvertX has no base-URL, public-URL or redirect-URI variable, so nothing outside the Ingress reads the domain. WEBROOT is deliberately not a value. It shifts every route including /healthcheck, which would break all three probes while the application stayed healthy, and this chart's Ingress still maps "/". PUID/PGID/UMASK are not values either - they configure the bypassed entrypoint and nothing else. Wiring: publish-convertx.yml, the change-app-version.yml choice list, ci/convertx/ (test-values, the JWT Secret fixture the stub OnePasswordItem CRD cannot materialize, and INSTALL_TIMEOUT=900 for a ~1.4 GiB image), a renovate.json5 block anchored on the v prefix so the moving latest, main and pr-<n> tags cannot be selected, and the README chart row. Verified locally: helm lint --strict clean, render against the CI fixtures, the null-safe fallbacks for every erased block, the resource-quantity render test, and NOTES.txt through the define/ConfigMap trick since helm template does not render it. Runtime behaviour is unverified here - this workspace has no Docker daemon and no k3d, so the PR install-test is the only runtime proof.
328 lines
13 KiB
Smarty
328 lines
13 KiB
Smarty
{{/*
|
|
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).
|
|
|
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
|
the chart's name as define prefix, because sharing it would mean a library
|
|
chart. template-chart/templates/_helpers.tpl is the reference implementation.
|
|
*/}}
|
|
{{- define "convertx.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 "convertx.resourceKey" $key) $value -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- $limits := dict -}}
|
|
{{- range $key, $value := ($resources.limits | default dict) -}}
|
|
{{- if not (kindIs "invalid" $value) -}}
|
|
{{- $_ := set $limits (include "convertx.resourceKey" $key) $value -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- range $key, $request := $requests -}}
|
|
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
|
{{- $_ := set $limits $key (include "convertx.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 "convertx.resourceKey" -}}
|
|
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Multiply a Kubernetes quantity by a factor, keeping its suffix.
|
|
Input: dict "quantity" <string|number> "factor" <float64>.
|
|
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 "convertx.multiplyQuantity" -}}
|
|
{{- $quantity := include "convertx.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 "convertx.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 "convertx.formatNumber" -}}
|
|
{{- if kindIs "float64" . -}}
|
|
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
|
{{- else -}}
|
|
{{- printf "%v" . -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Render the replica count for the StatefulSet.
|
|
|
|
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.
|
|
*/}}
|
|
{{- define "convertx.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" <dict> "given" <dict|nil> "path" <string, optional
|
|
values path used in error messages>. 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.
|
|
This helper defines the opposite semantics: an empty block means "chart
|
|
defaults apply".
|
|
*/}}
|
|
{{- define "convertx.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 "convertx.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" <dict|nil> "key" <string> "path" <string>.
|
|
Returns YAML of dict "value" <the sub-block, may be nil>, after rejecting a
|
|
non-mapping block with a message that names the values path. Needed because
|
|
".Values.convertx.securityContext" can itself be erased, and indexing into nil
|
|
inside a template is not an error but silently yields nothing.
|
|
*/}}
|
|
{{- define "convertx.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).
|
|
|
|
These defaults 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 OF THEM REPRODUCE A DELIBERATE DEVIATION AND MUST NOT BE "TIDIED":
|
|
|
|
- runAsUser / runAsGroup are 1000, not the repo's arbitrary 20000. The image
|
|
creates its convertx user at uid 1000 and owns /home/convertx with it, and
|
|
that uid owns the data on the PersistentVolume. Restoring the repo default
|
|
here would move the runtime uid off the volume's data.
|
|
- the probes point at /healthcheck, not at "/". "/" answers 302 in every
|
|
default configuration of this chart, and the redirect target changes once
|
|
the first account exists. The port name "http" is part of the context and
|
|
belongs here too: a default that loses it leaves the workload with a probe
|
|
that cannot succeed.
|
|
*/}}
|
|
{{- define "convertx.podSecurityContext" -}}
|
|
{{- $given := (fromYaml (include "convertx.subBlock" (dict "block" . "key" "pod" "path" "convertx.securityContext"))).value -}}
|
|
{{- $defaults := dict
|
|
"runAsNonRoot" true
|
|
"runAsUser" 1000
|
|
"runAsGroup" 1000
|
|
"fsGroup" 1000
|
|
"fsGroupChangePolicy" "Always"
|
|
"seccompProfile" (dict "type" "RuntimeDefault") -}}
|
|
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" $given "path" "convertx.securityContext.pod") -}}
|
|
{{- end -}}
|
|
|
|
{{- define "convertx.containerSecurityContext" -}}
|
|
{{- $given := (fromYaml (include "convertx.subBlock" (dict "block" . "key" "container" "path" "convertx.securityContext"))).value -}}
|
|
{{- $defaults := dict
|
|
"allowPrivilegeEscalation" false
|
|
"readOnlyRootFilesystem" true
|
|
"runAsNonRoot" true
|
|
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
|
|
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" $given "path" "convertx.securityContext.container") -}}
|
|
{{- end -}}
|
|
|
|
{{- define "convertx.livenessProbe" -}}
|
|
{{- $defaults := dict
|
|
"httpGet" (dict "path" "/healthcheck" "port" "http")
|
|
"periodSeconds" 10
|
|
"timeoutSeconds" 5
|
|
"failureThreshold" 3 -}}
|
|
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.livenessProbe") -}}
|
|
{{- end -}}
|
|
|
|
{{- define "convertx.readinessProbe" -}}
|
|
{{- $defaults := dict
|
|
"httpGet" (dict "path" "/healthcheck" "port" "http")
|
|
"periodSeconds" 10
|
|
"timeoutSeconds" 5
|
|
"failureThreshold" 3 -}}
|
|
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.readinessProbe") -}}
|
|
{{- end -}}
|
|
|
|
{{- define "convertx.startupProbe" -}}
|
|
{{- $defaults := dict
|
|
"httpGet" (dict "path" "/healthcheck" "port" "http")
|
|
"periodSeconds" 10
|
|
"timeoutSeconds" 5
|
|
"failureThreshold" 30 -}}
|
|
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.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.
|
|
|
|
The numbers reproduce values.yaml, which carries the reasoning: they are sized
|
|
for converter subprocesses (LibreOffice, Calibre, ImageMagick, ffmpeg), not
|
|
for an idle web server, and the ephemeral-storage figure has to cover the two
|
|
emptyDirs this chart mounts.
|
|
*/}}
|
|
{{- define "convertx.effectiveResources" -}}
|
|
{{- $defaults := dict
|
|
"limitFactor" 3
|
|
"requests" (dict "cpu" 0.25 "memory" "1Gi" "ephemeralStorage" "1Gi") -}}
|
|
{{- $merged := fromYaml (include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.resources")) -}}
|
|
{{- include "convertx.resources" $merged -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Render a values boolean as the literal string the application accepts.
|
|
|
|
Input: dict "value" <bool|string|nil> "path" <values path, for the error>
|
|
"default" <bool, used when the value is nil>
|
|
|
|
Why this is not just "{{ .Values.x }}" (README, "Values that must not lie",
|
|
point 6): ConvertX parses each of these variables as
|
|
|
|
process.env.X?.toLowerCase() === "true"
|
|
|
|
so ANYTHING that is not the literal "true" means false - "yes", "1", "on",
|
|
"TRUE " with a trailing space. Two of the three variables this helper renders
|
|
open the instance up (ACCOUNT_REGISTRATION, ALLOW_UNAUTHENTICATED), and a
|
|
value written to switch one of them ON that silently lands on OFF is the kind
|
|
of failure nobody goes looking for, because the symptom is the safe state.
|
|
|
|
So: a bool renders as "true"/"false", the strings "true"/"false" render
|
|
normalized to lower case, nil takes the given default, and everything else
|
|
ABORTS THE RENDER naming the values path. Go's "not" is deliberately unused
|
|
here - it treats every non-empty string as true, so "not \"false\"" is false
|
|
and the string form would flip the wrong way.
|
|
*/}}
|
|
{{- define "convertx.boolString" -}}
|
|
{{- $v := .value -}}
|
|
{{- if kindIs "invalid" $v -}}
|
|
{{- $v = .default -}}
|
|
{{- end -}}
|
|
{{- if kindIs "bool" $v -}}
|
|
{{- ternary "true" "false" $v -}}
|
|
{{- else -}}
|
|
{{- $s := lower (printf "%v" $v) -}}
|
|
{{- if or (eq $s "true") (eq $s "false") -}}
|
|
{{- $s -}}
|
|
{{- else -}}
|
|
{{- fail (printf "%s must be true or false, got %q - ConvertX compares this variable against the literal string \"true\", so any other spelling would silently mean false" .path $v) -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Render AUTO_DELETE_EVERY_N_HOURS.
|
|
|
|
An explicit 0 is the documented way to switch the cleanup off, so this cannot
|
|
go through Helm's "default": that swallows a 0 and would restore 24-hour
|
|
deletion on exactly the install that asked for none. Same nil-check shape as
|
|
the replicas helper, and for the same reason.
|
|
*/}}
|
|
{{- define "convertx.autoDeleteEveryNHours" -}}
|
|
{{- if kindIs "invalid" . -}}
|
|
24
|
|
{{- else -}}
|
|
{{- include "convertx.formatNumber" . -}}
|
|
{{- end -}}
|
|
{{- end -}}
|