Merge pull request #173 from jk-cluster/feat/searxng-chart

searxng: neues Chart (1.0.0+up2026.8.22.9fea41204)
This commit is contained in:
jklein authored and GitHub committed 2026-08-27 19:34:58 +00:00
commit ce5f654a8a
18 files changed
+1391 -5

No files matched your search

+1
View File
@@ -35,6 +35,7 @@ on:
- minecraft-router
- minecraft-server
- rss-bridge
- searxng
# 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
+15
View File
@@ -0,0 +1,15 @@
name: Publish SearXNG Helm Chart
on:
workflow_dispatch:
push:
branches:
- main
paths:
- searxng/**
jobs:
publish-searxng:
name: Publish SearXNG Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
with:
chart-name: 'searxng'
secrets: inherit
+5 -2
View File
@@ -22,6 +22,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo
| `rss-bridge` | RSS-Bridge | Deployment + whitelist ConfigMap; `ingress.hosts` is a required list of host + optional `tlsSecretName`, so several hosts can keep their own existing certificates (documented deviation from the one-TLS-secret rule, see its `values.yaml`) |
| `samba` | Samba | StatefulSet, users secret + config ConfigMap |
| `satisfactory` | Satisfactory game server | StatefulSet, no ingress |
| `searxng` | SearXNG | Stateless Deployment + settings ConfigMap (`use_default_settings: true` plus a free-form `settings.extra` passthrough); the signing key is a required `OnePasswordItem`, an external Valkey an optional second one. `ingress.domain` is read twice — Ingress host and `SEARXNG_BASE_URL` — so it stays required with the Ingress switched off |
| `teamspeak-server` | TeamSpeak server | StatefulSet, no ingress |
| `template-chart` | — | Scaffold for new charts (placeholder `xxx`), never published |
| `urlaubsverwaltung` | Urlaubsverwaltung | Deployment, DB + OIDC secrets |
@@ -73,7 +74,9 @@ What Renovate keeps current:
| GitHub Actions | Built-in `github-actions` manager |
| Tool versions pinned as workflow env vars (`TRIVY_VERSION`) | `# renovate: datasource=… depName=…` comment above the line |
Each chart renders its main image as `<repo>:{{ .Chart.AppVersion }}` — repository in the template, version in `Chart.yaml`. Renovate cannot join two files, so the mapping lives in `renovate.json5`, one block per chart. It is deliberately *not* a `# renovate:` comment inside each `Chart.yaml`: that would touch all chart directories and force a version bump plus a publish per chart for a pure comment change. Charts whose tags carry a flavour or date suffix (`apache-tika`, `minecraft-server`, `rss-bridge`, `teamspeak-server`) override `versioning` in their block; the reasoning sits next to each one. `freesailarr` has no block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image.
Each chart renders its main image as `<repo>:{{ .Chart.AppVersion }}` — repository in the template, version in `Chart.yaml`. Renovate cannot join two files, so the mapping lives in `renovate.json5`, one block per chart. It is deliberately *not* a `# renovate:` comment inside each `Chart.yaml`: that would touch all chart directories and force a version bump plus a publish per chart for a pure comment change. Charts whose tags carry a flavour or date suffix (`apache-tika`, `minecraft-server`, `rss-bridge`, `searxng`, `teamspeak-server`) override `versioning` in their block; the reasoning sits next to each one.
One rule those overrides share, learned from `searxng` and worth knowing before writing the next one: **a capture group is a claim about how the value is compared.** Renovate's regex versioning parses `major`, `minor`, `patch`, `build` and `revision` with `Number.parseInt` (`modules/versioning/regex/index.js`, `_parse`), so a non-numeric commit hash captured as `build` becomes whatever digits lead it — `9fea41204` is `9`, `777ba8fa4` is `777`, and a hash starting with a letter is `NaN`. Measured on renovate 41.173.1 and 44.48.0, that makes Renovate offer an image pushed *earlier the same day* as an update to a newer one, unattended under the patch/minor automerge rule. Parts of a tag that carry no ordering belong **outside** every group: the anchored pattern still requires them to be there, they simply take no part in the comparison. `prerelease` is not the way out either — it sorts correctly but marks every tag `isStable === false`, which hands the whole chart to `ignoreUnstable`. `freesailarr` has no block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image.
A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**. That holds for all nine images alike — none of them is special — and it is the intended outcome, since `appVersion` here is the chart's own marker rather than a tracked upstream version.
@@ -253,6 +256,6 @@ Reference implementation: `template-chart/values.schema.json` and `template-char
- `icon:` is mandatory in every `Chart.yaml`. **Documented exception — freesailarr, the only chart without one**: the rule everywhere else is "the icon is this chart's one upstream's logo", and a chart bundling nine independently versioned projects has no such upstream — picking one of the nine would imply a "main app" the stack deliberately does not have. That is the whole criterion, and it applies to no other chart in the repo: a single-upstream chart without an icon is a missing icon, not an exception. The omission costs an INFO line in `helm lint --strict` (`icon is recommended`), which still exits 0.
- `helm lint --strict` must pass without findings.
- `NOTES.txt` for every chart is planned (#43); seven charts ship one so far (`apache-tika`, `cyberchef`, `freesailarr`, `gotenberg`, `homeassistant`, `patchmon`, `rss-bridge`).
- `NOTES.txt` for every chart is planned (#43); eight charts ship one so far (`apache-tika`, `cyberchef`, `freesailarr`, `gotenberg`, `homeassistant`, `patchmon`, `rss-bridge`, `searxng`).
- CI: every installable chart has fixtures under `ci/<chart>/` (`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).
+15
View File
@@ -0,0 +1,15 @@
# Fixtures for the searxng install-test: the one Secret the 1Password operator
# would otherwise materialize (release name: ci).
#
# The value is a throwaway string, and this chart genuinely needs nothing more
# than that - unlike freesailarr, whose fixtures need a real credential to get
# past a VPN handshake. What it does need is a value that is NOT the upstream
# placeholder "ultrasecretkey": SearXNG's webapp.py init() exits 1 on that, so
# an empty or careless fixture would produce a crash loop rather than a
# passing install-test.
apiVersion: v1
kind: Secret
metadata:
name: ci-searxng-secret
stringData:
secret-key: searxng-ci-secret-key-0123456789abcdef0123456789abcdef
+32
View File
@@ -0,0 +1,32 @@
# searxng: stateless, one secret and no infrastructure to stand up. The
# Ingress just has to apply - there is no ingress-nginx/cert-manager in the CI
# cluster.
#
# The install runs the DEFAULT path (bot detection off, no Valkey), which is
# what a real installation of this chart runs. The optional Valkey branch is
# rendered but not installed here: switching the limiter on without a reachable
# Valkey produces a Ready pod that logs an error per request, so it would make
# the install-test slower without making it stricter.
ingress:
domain: searxng.ci.local
clusterIssuer: ci
# The dummy 1Password Secret is created in fixtures.yaml; the operator does not
# run in CI, so the OnePasswordItem CR stays unfulfilled and only the stub CRD
# has to accept it.
secrets:
searxngSecretRef: op://ci/searxng/dummy
# Deliberately not the chart defaults. The install-test is the only place that
# proves the ConfigMap is more than a fixed file: instanceName exercises the
# first-class keys, and "extra" exercises the merge over them - including a
# nested map that has to be joined rather than replaced (search.safe_search
# from above must survive next to search.default_lang from here).
settings:
instanceName: "SearXNG CI"
safeSearch: 1
extra:
search:
default_lang: "en"
ui:
default_theme: simple
+44 -3
View File
@@ -21,7 +21,7 @@
timezone: "Europe/Berlin",
labels: ["dependencies"],
// 20 charts plus the workflows: without a cap the first run after a quiet
// 21 charts plus the workflows: without a cap the first run after a quiet
// week opens a wall of PRs, and every chart PR spins up a k3d install-test.
prConcurrentLimit: 10,
prHourlyLimit: 4,
@@ -174,8 +174,8 @@
// its own manager that hard-codes the repository and reads `appVersion:`.
//
// Deliberately *not* solved with a `# renovate:` comment inside each
// Chart.yaml: that would touch all 20 chart directories and force 20
// version bumps plus 20 publishes for a pure comment change. Keeping the
// Chart.yaml: that would touch all 21 chart directories and force 21
// version bumps plus 21 publishes for a pure comment change. Keeping the
// mapping here leaves the charts untouched.
//
// When adding a chart, add a block here — see README "Creating a new chart".
@@ -300,6 +300,47 @@
depTypeTemplate: "app-version",
versioningTemplate: "regex:^(?<major>\\d{4})-(?<minor>\\d{2})-(?<patch>\\d{2})$",
},
{
description: [
"searxng: every tag is <date>-<commit> (2026.8.22-9fea41204) - no semver line at all,",
"and 'latest' moves daily. The date carries the ordering; the commit hash is matched",
"but deliberately NOT captured.",
],
customType: "regex",
managerFilePatterns: ["searxng/Chart.yaml"],
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
depNameTemplate: "docker.io/searxng/searxng",
datasourceTemplate: "docker",
depTypeTemplate: "app-version",
// The hash sits outside every capture group on purpose, and that is the
// whole point of this line. Renovate's regex versioning reads a "build"
// group NUMERICALLY - regex/index.js _parse() does
// release.push(Number.parseInt(build)) - so a commit hash becomes
// whatever digits happen to lead it: parseInt("9fea41204") is 9,
// parseInt("777ba8fa4") is 777, and any hash starting with a letter
// (6 of the 25 tags live today) is NaN.
//
// Verified against the real API on renovate 41.173.1 and 44.48.0, with
// currentValue 2026.8.22-9fea41204:
// with (?<build>[0-9a-f]+):
// isGreaterThan("2026.8.22-777ba8fa4", current) -> true
// without it (this line):
// isGreaterThan("2026.8.22-777ba8fa4", current) -> false, equals -> true
// 777ba8fa4 was pushed at 03:06 and 9fea41204 at 05:38 the SAME day, so
// the build group makes Renovate offer the older image as an update -
// a downgrade, unattended, because date updates land in the patch/minor
// automerge rule. With NaN the pair is neither equal nor ordered at all.
//
// Uncaptured, the hash still has to be THERE (the pattern is anchored),
// it just takes no part in the comparison: two builds of one day are
// equal, so no sideways update is proposed, and a newer date wins
// whatever the hash looks like.
//
// "prerelease" instead of "build" was measured too and rejected: it
// sorts correctly but marks every single tag isStable === false, which
// hands the whole chart to ignoreUnstable.
versioningTemplate: "regex:^(?<major>\\d{4})\\.(?<minor>\\d{1,2})\\.(?<patch>\\d{1,2})-[0-9a-f]+$",
},
{
customType: "regex",
managerFilePatterns: ["samba/Chart.yaml"],
+23
View File
@@ -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/
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v2
name: searxng
description: A Helm chart to deploy SearXNG on Kubernetes
icon: https://raw.githubusercontent.com/searxng/searxng/master/client/simple/src/brand/searxng.svg
type: application
# SearXNG publishes no semver line at all: every image tag is <date>-<commit>
# (2026.8.22-9fea41204) and "latest" moves daily. appVersion is therefore the
# exact tag, and the +up suffix normalizes the dash to a dot as the versioning
# scheme requires - the same shape rss-bridge and minecraft-server use.
version: 1.0.0+up2026.8.22.9fea41204
appVersion: "2026.8.22-9fea41204"
maintainers:
- name: jklein
+89
View File
@@ -0,0 +1,89 @@
{{- $replicas := include "searxng.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.searxng.replicas) -}}
{{- $settings := fromYaml (include "searxng.effectiveSettings" .Values.settings) -}}
SearXNG {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{- 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.
ingress.domain is still required: it builds SEARXNG_BASE_URL, which SearXNG
puts into its OpenSearch description, into the signed image-proxy URLs and
into every absolute link. Reach the instance inside the cluster with
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
and open http://localhost:8080. Note that links on the page will still point
at https://{{ .Values.ingress.domain }}/.
{{- end }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}searxng.replicas: 0{{ end }} — the deployment runs with replicas: 0, so the endpoint
above does not answer 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 }}
{{ end }}
CONFIGURATION
Instance name: {{ $settings.instanceName }}
settings.yml is rendered from the "settings" values into
{{ .Release.Name }}-{{ .Chart.Name }}-configmap and mounted at /etc/searxng/settings.yml.
It starts with use_default_settings: true and names only the deviations, so
every engine the image ships stays available and upstream engine changes
arrive with the app version instead of with a chart release.
Anything the chart does not model first-class goes into settings.extra in
SearXNG's own key names; it is merged over the generated block. Changing any
of it rolls the pods — the deployment hashes the ConfigMap, because a subPath
mount would otherwise never reach the running container.
WHAT THE GREEN CHECKMARK DOES AND DOES NOT MEAN
All three probes hit /healthz, the only path SearXNG's bot detection lets
through unconditionally. Its handler returns a fixed "OK". So a Ready pod
means the worker answers — not that a search works, not that any engine is
reachable, and not that a login placed in front of the Ingress is up (probes
go straight to the pod and never pass the Ingress).
{{- if not $settings.limiter }}
BOT DETECTION IS OFF
settings.limiter is false and no Valkey is configured. The instance relies on
whatever sits in front of it — put authentication there via
ingress.annotations if it must not be open to everyone.
{{- else if not .Values.secrets.valkeySecretRef }}
BOT DETECTION IS ON WITHOUT A VALKEY
settings.limiter is true but secrets.valkeySecretRef is unset. The limiter
needs a Valkey to keep its counters, so it does nothing at all: SearXNG logs
ERROR:searx.limiter: The limiter requires Valkey, please consult the
documentation: https://docs.searxng.org/admin/searx.limiter.html
once at worker start, the pod becomes Ready, and every request is served
unlimited. This release is therefore NOT rate limited, and the only trace of
that is one line at the top of the log. Set secrets.valkeySecretRef, or set
settings.limiter back to false.
{{- end }}
ROTATING THE SIGNING KEY
secrets.searxngSecretRef carries the key that signs the settings cookie and
the image-proxy URLs. Pointing it at a DIFFERENT 1Password item rolls the
pods; changing the value INSIDE the same item does not — Helm never sees that
content. After such a rotation:
kubectl -n {{ .Release.Namespace }} rollout restart deployment/{{ .Release.Name }}-{{ .Chart.Name }}-deployment
A new key invalidates every user's saved preferences cookie, which is a
logout-shaped event for the people using it.
NOTHING TO BACK UP
The pod holds no state worth keeping: settings.yml comes from the ConfigMap,
the signing key from the Secret, and the SQLite caches under /tmp plus
/var/cache/searxng are emptyDirs that refill on their own. App-version
upgrades and rollbacks therefore carry no data risk.
+349
View File
@@ -0,0 +1,349 @@
{{/*
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 "searxng.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. template-chart holds the reference implementation.
*/}}
{{- define "searxng.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 "searxng.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- $limits := dict -}}
{{- range $key, $value := ($resources.limits | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $limits (include "searxng.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- range $key, $request := $requests -}}
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
{{- $_ := set $limits $key (include "searxng.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 "searxng.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 "searxng.multiplyQuantity" -}}
{{- $quantity := printf "%v" .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 "%v%s" $product $suffix -}}
{{- 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 "searxng.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.<app>.replicas) }}
NOTE: like the resources helper above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix. template-chart
holds the reference implementation.
*/}}
{{- define "searxng.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.
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 duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies. template-chart holds the
reference implementation.
*/}}
{{- define "searxng.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 "searxng.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.<app>.securityContext" can itself be erased, and indexing into nil
inside a template is not an error but silently yields nothing.
*/}}
{{- define "searxng.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 "searxng.podSecurityContext" -}}
{{- $given := (fromYaml (include "searxng.subBlock" (dict "block" . "key" "pod" "path" "searxng.securityContext"))).value -}}
{{/* 977 is the image's searxng user, deliberately not the repo's generic
20000/1000: the image creates /etc/searxng and /var/cache/searxng owned
977:977 and the entrypoint checks that ownership by name. A "cleaned up"
standard uid here would leave the config mount unreadable. */}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 977
"runAsGroup" 977
"fsGroup" 977
"fsGroupChangePolicy" "Always"
"seccompProfile" (dict "type" "RuntimeDefault") -}}
{{- include "searxng.defaultedDict" (dict "defaults" $defaults "given" $given "path" "searxng.securityContext.pod") -}}
{{- end -}}
{{- define "searxng.containerSecurityContext" -}}
{{- $given := (fromYaml (include "searxng.subBlock" (dict "block" . "key" "container" "path" "searxng.securityContext"))).value -}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
{{- include "searxng.defaultedDict" (dict "defaults" $defaults "given" $given "path" "searxng.securityContext.container") -}}
{{- end -}}
{{/* All three probe fallbacks use /healthz, not "/", and that path is part of
the context this fallback has to carry (guideline 5). /healthz is the only
path SearXNG's bot detection lets through unconditionally
(searx/limiter.py, filter_request), and its handler returns a fixed "OK"
without touching an engine or the cache - so liveness hangs on no external
dependency either. The port NAME matters just as much: the container port
is named "http" and a fallback without it would address nothing. */}}
{{- define "searxng.livenessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthz" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "searxng.defaultedDict" (dict "defaults" $defaults "given" . "path" "searxng.livenessProbe") -}}
{{- end -}}
{{- define "searxng.readinessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthz" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "searxng.defaultedDict" (dict "defaults" $defaults "given" . "path" "searxng.readinessProbe") -}}
{{- end -}}
{{- define "searxng.startupProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthz" "port" "http")
"periodSeconds" 5
"timeoutSeconds" 5
"failureThreshold" 30 -}}
{{- include "searxng.defaultedDict" (dict "defaults" $defaults "given" . "path" "searxng.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 "searxng.effectiveResources" -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "cpu" 0.1 "memory" "256Mi" "ephemeral-storage" "64Mi") -}}
{{- $merged := fromYaml (include "searxng.defaultedDict" (dict "defaults" $defaults "given" . "path" "searxng.resources")) -}}
{{- include "searxng.resources" $merged -}}
{{- end -}}
{{/*
The effective "settings" block, i.e. this chart's user-facing configuration
keys with the same null-safe semantics as the blocks above: an erased
"settings:" means "chart defaults apply" rather than an instance whose name,
safe-search level and image proxy silently become empty.
The defaults MUST stay in sync with the settings block in values.yaml.
*/}}
{{- define "searxng.effectiveSettings" -}}
{{- $defaults := dict
"instanceName" "SearXNG"
"safeSearch" 0
"autocomplete" "duckduckgo"
"imageProxy" true
"limiter" false
"publicInstance" false
"extra" dict -}}
{{- include "searxng.defaultedDict" (dict "defaults" $defaults "given" . "path" "settings") -}}
{{- end -}}
{{/*
The body of /etc/searxng/settings.yml.
Two things this is doing, both deliberate:
- It starts from use_default_settings: true and names ONLY the deviations,
so the ~3500-line settings.yml of the image stays the source of truth for
engines and every upstream change to them arrives without a chart release.
- "settings.extra" is merged OVER the block the first-class keys build, with
defaultedDict rather than sprig's mergeOverwrite. That difference is
load-bearing: mergeOverwrite drops zero values from the source, so an
"extra" entry setting some flag to false or 0 would be silently ignored -
a config value that looks set and does nothing, which is exactly the class
of bug the schema work (#68) exists to remove.
server.secret_key is deliberately absent: it arrives as SEARXNG_SECRET from
the 1Password Secret (settings_defaults.py lets the environment override the
file), so the signing key never lands in a ConfigMap.
*/}}
{{- define "searxng.settingsYaml" -}}
{{- $settings := fromYaml (include "searxng.effectiveSettings" .Values.settings) -}}
{{- $base := dict
"use_default_settings" true
"general" (dict "instance_name" $settings.instanceName)
"search" (dict "safe_search" $settings.safeSearch "autocomplete" $settings.autocomplete)
"server" (dict
"limiter" $settings.limiter
"public_instance" $settings.publicInstance
"image_proxy" $settings.imageProxy) -}}
{{- $merged := fromYaml (include "searxng.defaultedDict" (dict "defaults" $base "given" $settings.extra "path" "settings.extra")) -}}
{{- toYaml $merged -}}
{{- end -}}
@@ -0,0 +1,11 @@
{{- if .Values.secrets.valkeySecretRef }}
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-valkey-secret
spec:
# Optional, and only meaningful with settings.limiter: true. The item must
# carry a "valkey-url" field pointing at an EXTERNAL Valkey/Redis; the chart
# deploys none of its own, because charts in this repo have no dependencies.
itemPath: {{ .Values.secrets.valkeySecretRef }}
{{- end }}
+14
View File
@@ -0,0 +1,14 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: "{{ .Release.Name }}-{{ .Chart.Name }}-configmap"
data:
# Mounted at /etc/searxng/settings.yml via subPath. That path is not free
# choice: the image exports __SEARXNG_SETTINGS_PATH=/etc/searxng/settings.yml
# and its entrypoint creates the file from its own template only when it does
# NOT exist. Because this mount is always there, the entrypoint finds the
# file, skips both the copy and the sed that would otherwise invent a random
# secret_key, and never needs to write into /etc/searxng - which is what lets
# the container run with a read-only root filesystem.
settings.yml: |
{{- include "searxng.settingsYaml" . | nindent 4 }}
+169
View File
@@ -0,0 +1,169 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
replicas: {{ include "searxng.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.searxng.replicas) }}
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
template:
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
annotations:
# Hash of the rendered ConfigMap: a changed settings.yml changes the
# pod template and thus rolls the pods. Without it the change would
# never reach the running container - settings.yml is a subPath mount,
# which the kubelet never refreshes, and SearXNG reads its
# configuration once at start.
checksum/config: {{ include (print $.Template.BasePath "/searxng.configmap.yaml") . | sha256sum }}
# Hashes over the rendered OnePasswordItems. Separately named on
# purpose: they only promise that the config SOURCE changed. Repointing
# a *SecretRef at another vault item rolls the pods; rotating the value
# inside the same item does not, because Helm never sees the content
# (the operator fills the Secret in-cluster) and the values are consumed
# as environment variables, which Kubernetes never refreshes in a
# running container. After such a rotation, restart by hand.
checksum/secret-ref: {{ include (print $.Template.BasePath "/searxng.secret.yaml") . | sha256sum }}
checksum/secret-ref-valkey: {{ include (print $.Template.BasePath "/searxng-valkey.secret.yaml") . | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
{{- include "searxng.podSecurityContext" .Values.searxng.securityContext | nindent 8 }}
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
# Registry written out in full on purpose: an unqualified reference
# would put "searxng" in the registry position, which says nothing
# about where the image comes from and is reported as an untrusted
# registry (AVD-KSV-0125). See ci/trivy/data/ksv0125.yaml.
image: "docker.io/searxng/searxng:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
env:
# The signing key for the settings cookie and the image-proxy URLs.
# Set by the chart rather than left to searxng.env, because SearXNG
# exits 1 when server.secret_key is still the upstream placeholder
# and the image's self-generating fallback cannot fire here (it
# only runs when settings.yml is missing, and this chart always
# mounts one).
- name: SEARXNG_SECRET
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-{{ .Chart.Name }}-secret"
key: "secret-key"
# server.base_url. This is the SECOND read of ingress.domain, well
# outside the Ingress template, so it carries its own required()
# (README, "Values that must not lie", point 1). Without it, an
# install with ingress.enabled: false would render base_url as
# "https:///" - the pod would go green while the OpenSearch
# description, every signed image-proxy URL and every absolute link
# pointed at a broken address.
- name: SEARXNG_BASE_URL
value: "https://{{ required "A Domain/Host must be provided (ingress.domain), it is used for SEARXNG_BASE_URL even when the Ingress is disabled" .Values.ingress.domain }}/"
{{- if .Values.secrets.valkeySecretRef }}
# Only rendered when an external Valkey/Redis is configured. The
# limiter is what needs it; see settings.limiter in values.yaml.
- name: SEARXNG_VALKEY_URL
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-{{ .Chart.Name }}-valkey-secret"
key: "valkey-url"
{{- end }}
{{- range $value := .Values.searxng.env }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
# quote is required, not cosmetic: a value like "8080" 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". Reachable with
# the very first entry someone adds here - SEARXNG_LIMITER and
# GRANIAN_PORT are both of that shape.
value: {{ tpl $value.value $ | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name | quote }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name | quote }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
{{- end }}
{{- end }}
{{- end }}
ports:
# 8080 is the image's own GRANIAN_PORT and its EXPOSE. It is not a
# value, because a value here could lie: changing it would move the
# containerPort and the probes while granian kept listening on
# 8080. The way to move the port is to set both GRANIAN_PORT (or
# its SEARXNG_PORT alias, which the entrypoint maps onto it) in
# searxng.env and this number - which means a chart change, on
# purpose.
#
# The port NAME is load-bearing and must not be renamed: the
# Service selects it by name and so do all three probes.
- containerPort: 8080
protocol: TCP
name: http
# 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 "searxng.livenessProbe" .Values.searxng.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "searxng.readinessProbe" .Values.searxng.readinessProbe | nindent 12 }}
startupProbe:
{{- include "searxng.startupProbe" .Values.searxng.startupProbe | nindent 12 }}
resources:
{{- include "searxng.effectiveResources" .Values.searxng.resources | nindent 12 }}
securityContext:
{{- include "searxng.containerSecurityContext" .Values.searxng.securityContext | nindent 12 }}
volumeMounts:
- name: settings
mountPath: /etc/searxng/settings.yml
subPath: settings.yml
readOnly: true
# The two writable paths below are what keeps
# readOnlyRootFilesystem: true possible. They are image facts
# rather than user configuration, so they are not values.
- name: tmp
# NOT optional, and not obvious: SearXNG keeps three SQLite
# caches (data, engines, favicons) and searx/cache.py falls back
# to tempfile.gettempdir() when no db_url is configured - i.e.
# /tmp. webapp.py init() connects to two of them before the
# first request, so a read-only /tmp is not a degraded cache, it
# is a container that never starts.
mountPath: /tmp
- name: cache
# The image's __SEARXNG_DATA_PATH. The entrypoint aborts with
# exit 127 if the directory is missing, so it has to exist -
# which it does even unmounted, since the image ships it. It is
# mounted anyway and deliberately: nothing in this version writes
# there, but it is the path the application declares as its data
# directory, and the day a release starts using it a read-only
# root filesystem would turn that into a crash loop.
#
# EXPECTED, not a fault: fsGroup leaves this emptyDir owned
# root:977, so the entrypoint prints a multi-line "!!! WARNING
# ... is not owned by searxng:searxng" and carries on. It cannot
# chown as a non-root process and does not need to - the group
# bit is enough.
mountPath: /var/cache/searxng
volumes:
- name: settings
configMap:
name: "{{ .Release.Name }}-{{ .Chart.Name }}-configmap"
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
restartPolicy: Always
+31
View File
@@ -0,0 +1,31 @@
{{- if .Values.ingress.enabled }}
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 (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
# Extra annotations are MERGED with the cert-manager annotation above, not
# substituted for it. This is where forward-auth (Authentik and friends)
# goes; see the example in values.yaml.
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ required "A Domain/Host must be provided (ingress.domain)" .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 (ingress.domain)" .Values.ingress.domain }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }}
+10
View File
@@ -0,0 +1,10 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
spec:
# The item must carry a "secret-key" field; it becomes SEARXNG_SECRET and
# thereby server.secret_key, which signs the settings cookie and the
# image-proxy URLs. Without it SearXNG exits 1 at start (webapp.py refuses
# to run on the upstream placeholder), so the value is required.
itemPath: {{ required "You must provide a searxng Secret Reference (secrets.searxngSecretRef)" .Values.secrets.searxngSecretRef }}
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
spec:
selector:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
ports:
- protocol: TCP
port: 80
targetPort: http
name: http
type: ClusterIP
+215
View File
@@ -0,0 +1,215 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"title": "searxng values",
"description": "Values schema for the searxng chart (issue #68). additionalProperties is false on every chart-owned object level, so a key the chart does not read is rejected at render time instead of being silently ignored. Objects that go verbatim into the Kubernetes pod spec (probes, security contexts, global, free-form annotations) stay open, and so does settings.extra - its schema belongs to SearXNG, not to this chart. The schema ADDS to the required() calls in the templates, it does not replace them.",
"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.",
"type": "object"
},
"scaleDown": {
"type": "boolean"
},
"secrets": {
"type": "object",
"additionalProperties": false,
"properties": {
"searxngSecretRef": {
"description": "op:// item path carrying a \"secret-key\" field. Required by the template - SearXNG exits 1 when server.secret_key is still the upstream placeholder.",
"type": ["string", "null"]
},
"valkeySecretRef": {
"description": "op:// item path carrying a \"valkey-url\" field for an external Valkey/Redis. Optional; only meaningful with settings.limiter: true.",
"type": ["string", "null"]
}
}
},
"settings": {
"description": "The chart-owned part of /etc/searxng/settings.yml. May be null / empty: the chart then falls back to its own defaults (issue #99) instead of rendering an instance with an empty name and no image proxy.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"instanceName": {
"description": "general.instance_name.",
"type": ["string", "null"]
},
"safeSearch": {
"description": "search.safe_search - 0 off, 1 moderate, 2 strict. Constrained because SearXNG accepts nothing else and a typo here would otherwise reach the app as a config error at start.",
"type": ["integer", "null"],
"enum": [0, 1, 2, null]
},
"autocomplete": {
"description": "search.autocomplete - the suggestion backend, or the empty string to disable suggestions. Not enumerated: the list of backends is upstream's and grows.",
"type": ["string", "null"]
},
"imageProxy": {
"description": "server.image_proxy.",
"type": ["boolean", "null"]
},
"limiter": {
"description": "server.limiter - the bot detection. Needs an external Valkey (secrets.valkeySecretRef) to do anything.",
"type": ["boolean", "null"]
},
"publicInstance": {
"description": "server.public_instance.",
"type": ["boolean", "null"]
},
"extra": {
"description": "Free-form SearXNG settings, merged over the block the keys above build and rendered verbatim into settings.yml. Deliberately open: the schema here is SearXNG's, and restricting it would only reject keys the upstream has not invented yet.",
"type": ["object", "null"]
}
}
},
"searxng": {
"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"
},
"env": {
"$ref": "#/definitions/env"
}
}
},
"ingress": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the Ingress template only. ingress.domain is read a second time in the Deployment (SEARXNG_BASE_URL) and keeps its own required() there, so switching the Ingress off frees clusterIssuer but NOT domain.",
"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"
}
}
}
},
"definitions": {
"resources": {
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #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 (issue #99) instead of rendering an unhardened workload. Those fallbacks reproduce THIS chart's uid/gid 977, not the repo's generic numbers.",
"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 (issue #99) rather than the probe disappearing. The chart's defaults carry the probe's context - the /healthz path and the \"http\" port name - not just its timings.",
"type": ["object", "null"]
}
}
}
+339
View File
@@ -0,0 +1,339 @@
# 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. PVCs, Secrets and
# Services stay in place; scaleDown: false brings the workloads back up.
scaleDown: false
secrets:
# op:// item path for the 1Password operator; the item must carry a
# "secret-key" field. REQUIRED - the chart has no default and fails to
# render without it.
#
# Why it is required rather than optional: server.secret_key signs the
# settings cookie and the image-proxy URLs. SearXNG refuses to start when it
# is still the upstream placeholder - searx/webapp.py init():
#
# if not app.debug and get_setting("server.secret_key") == 'ultrasecretkey':
# logger.error("server.secret_key is not changed. ...")
# sys.exit(1)
#
# The image's entrypoint has a self-generating fallback, but it only fires
# when /etc/searxng/settings.yml is MISSING, and this chart always mounts one
# from its ConfigMap - so the fallback can never run here. Without this value
# the instance would inherit "ultrasecretkey" from the upstream defaults and
# exit 1 in a crash loop.
#
# And it is a Secret, not a ConfigMap key, because a ConfigMap is the place
# people look into without thinking twice: anyone with read access in the
# namespace would see the key that signs every user's cookies.
searxngSecretRef: null
# op:// item path for an EXTERNAL Valkey/Redis, carrying a "valkey-url"
# field (e.g. redis://valkey.example.svc:6379/0). Optional: leave it null
# unless the bot detection below is switched on.
#
# Setting it renders a second OnePasswordItem and passes the URL to the
# container as SEARXNG_VALKEY_URL. The chart ships NO Valkey of its own -
# charts in this repo have no dependencies (README, guideline 13), so an
# external connection through a secret is the same shape outline and
# paperless-ngx use for their databases.
valkeySecretRef: null
#
# Documented limitation (README, guideline 9): the CONTENT of these items is
# invisible to Helm - the 1Password operator fills the Kubernetes Secret in
# the cluster after rendering, so there is nothing to hash and no checksum
# can cover it. Repointing either *SecretRef at a different item DOES roll
# the pods (the Deployment hashes the rendered OnePasswordItem into
# checksum/secret-ref), but rotating the value inside the same item does
# not: both are consumed as environment variables, which Kubernetes never
# refreshes in a running container. After such a rotation, restart by hand:
#
# kubectl -n <namespace> rollout restart deployment/<release>-searxng-deployment
# The contents of /etc/searxng/settings.yml, rendered into the ConfigMap.
#
# The file is deliberately minimal and starts with use_default_settings: true,
# so it inherits the ~3500-line settings.yml the image ships and only names the
# deviations. That is what keeps every upstream engine change flowing in
# without a chart release, and it is why this chart does NOT map SearXNG's
# hundreds of engine options onto first-class values: such a mapping would be
# out of date with the first upstream change and would then lie about what the
# instance actually runs.
#
# Everything else goes through "extra" below, which is merged over the block
# the four keys build. Changing anything here rolls the pods - the Deployment
# carries a checksum over the rendered ConfigMap. Without it the change would
# never reach the running container: settings.yml is a subPath mount, which
# the kubelet never refreshes, and SearXNG reads its configuration once at
# start.
settings:
# general.instance_name - the name shown in the UI and in the OpenSearch
# description. Upstream default: "SearXNG".
instanceName: "SearXNG"
# search.safe_search - 0 = off, 1 = moderate, 2 = strict.
safeSearch: 0
# search.autocomplete - the suggestion backend, e.g. "duckduckgo", "google",
# "startpage", "wikipedia". The empty string disables suggestions.
# Upstream default: "duckduckgo".
autocomplete: "duckduckgo"
# server.image_proxy - route image thumbnails through the instance instead
# of letting the browser fetch them from the origin. true is the value the
# upstream container template sets (the library default is false).
imageProxy: true
# server.limiter - SearXNG's bot detection.
#
# Off by default, and that is a decision rather than an omission: the limiter
# needs a Valkey/Redis to keep its counters, and this chart deploys none. The
# instance is meant to sit behind authentication at the Ingress (see
# ingress.annotations below), so nothing that the bot detection would look at
# ever reaches the application - switching it on would only add a second
# place to debug.
#
# If you do switch it on, set secrets.valkeySecretRef as well. What happens
# WITHOUT it was measured rather than guessed, and the answer is the
# uncomfortable middle one - a quiet fall-back, not a loud failure:
#
# * one ERROR at worker start, "searx.limiter: The limiter requires
# Valkey, please consult the documentation: ...", and nothing after that
# * the pod becomes Ready and stays Ready
# * every request is served normally - /, /search and /healthz all
# answered 200, with no rate limiting in effect at all
#
# So a limiter switched on without a Valkey is not protecting anything, and
# the only sign of it is a single log line at start. With a reachable Valkey
# the same setup does limit: measured against a throwaway Valkey, "/" then
# answers 429 while /healthz still answers 200 - which is the whole reason
# the probes use /healthz.
limiter: false
# server.public_instance - extra protections for instances listed publicly.
# Off for the same reason as the limiter: it depends on the limiter's
# infrastructure and this instance is not public.
publicInstance: false
# Free-form passthrough, merged OVER the block the keys above build and
# rendered verbatim into settings.yml. This is where everything the chart
# does not model first-class goes, in SearXNG's own key names:
#
# extra:
# search:
# default_lang: "de"
# formats: ["html", "json"]
# ui:
# default_theme: simple
# engines:
# - name: wikipedia
# disabled: false
#
# Merge semantics come from the same null-safe helper the security contexts
# use: nested maps are merged key by key, an explicit false or 0 wins, and a
# null keeps the value from the block above (nulls never delete). A scalar
# written where the base has a map is rejected by name instead of failing
# later with a template error.
#
# This object stays open in values.schema.json on purpose: its schema is
# SearXNG's, not this chart's, and restricting it would only reject keys the
# upstream has not invented yet.
extra: {}
searxng:
# Replica count for this workload; 0 is allowed and scales it down.
# The chart-global scaleDown flag takes precedence.
#
# Note before raising this above 1: SearXNG keeps its settings in a signed
# cookie rather than server-side, so several replicas do not need a shared
# session store - but they DO need the same server.secret_key, which they
# have here (one Secret for the whole release). The per-pod SQLite caches
# under /tmp are independent and merely warm up separately.
replicas: 1
# uid/gid 977 is the "searxng" user of the image, and the number is not
# interchangeable: /etc/searxng and /var/cache/searxng are created 0755 and
# owned 977:977 in the image (searxng/base Dockerfile), and the entrypoint
# compares the ownership of both against searxng:searxng by name.
#
# EXPECTED LOG LINE, not a fault: the emptyDir mounted at
# /var/cache/searxng below is owned root:977 (fsGroup only sets the group),
# so the entrypoint prints a multi-line
#
# !!! WARNING
# !!! "/var/cache/searxng" directory is not owned by "searxng:searxng"
#
# and then continues. It cannot fix the ownership because the process is not
# root - which is the point - and it does not need to: the directory is
# group-writable through fsGroup. The warning is written down here because
# otherwise the next person who finds it in the log spends an hour on it.
securityContext:
pod:
runAsNonRoot: true
runAsUser: 977
runAsGroup: 977
fsGroup: 977
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
# All three probes hit /healthz, and that is a deliberate choice in both
# directions.
#
# For it: /healthz is the ONLY path SearXNG's bot detection lets through
# unconditionally - searx/limiter.py filter_request() returns early on
# exactly this path before any of its checks run. This is not a theoretical
# margin. Measured in k3d against a reachable Valkey with settings.limiter
# true: "/" answered 429 while /healthz answered 200. A probe on "/" would
# therefore not merely risk a 429, it would collect one the moment the
# limiter is switched on - the pod would go NotReady and the symptom would
# point at the application instead of at the bot detection. It also costs
# nothing: the handler is
# `return Response('OK', mimetype='text/plain')`, no engines, no cache, no
# network - so liveness carries no external dependency (README, "Values that
# must not lie", point 3).
#
# Against it, stated plainly: a fixed string proves that the WSGI worker
# answers. It does not prove that a search works, that any engine is
# reachable, or that this chart's settings.yml was the file that got loaded.
# A green pod here is a running process, nothing more.
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness, so those need no
# initialDelaySeconds. Startup budget = 5s * 30 = 150s. Measured in k3d
# (k3s v1.35.5) with the image already on the node: 6 s from container start
# to Ready. The budget is therefore about 25x a healthy start - the margin is
# for a cold node under load, not for a slow application.
startupProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 30
# Extra environment variables, appended to the ones the chart always sets
# (SEARXNG_SECRET, SEARXNG_BASE_URL and - when secrets.valkeySecretRef is
# set - SEARXNG_VALKEY_URL). Values are rendered through tpl; the value,
# secretKeyRef and configMapKeyRef forms are supported.
#
# NOTE that the list below is a DEFAULT, not an addition to one: an
# installation that sets searxng.env replaces it wholesale and has to repeat
# the GRANIAN_HOST entry.
#
# Also note the precedence: SearXNG's own SEARXNG_* variables
# (settings_defaults.py) override the ConfigMap, so an env entry named e.g.
# SEARXNG_LIMITER wins over settings.limiter above. That is a way out if you
# need it, and a trap if you forget it.
env:
# The bind address of granian, the WSGI server the image runs since it
# dropped uwsgi. It is a value rather than a template constant because it
# is the one setting that could plausibly need correcting per cluster, and
# the correction should be a values change, not a chart release.
#
# "::" is the image's own default and it was MEASURED here, not assumed.
# In k3d (k3s v1.35.5) the pod is single-stack IPv4 - one podIP, and eth0
# carries nothing but a link-local fe80:: address - and granian still logs
#
# [INFO] Listening at: http://:::8080
#
# while the kubelet's IPv4 probes are answered and the pod goes Ready. The
# v4-mapped socket therefore works without a routable IPv6 address. On a
# cluster where it does not (an IPv6-disabled kernel: granian would fail to
# bind and the container would never answer), set this to "0.0.0.0".
#
# Note that there is no SEARXNG_* alias for the bind address: the
# entrypoint maps only SEARXNG_PORT onto GRANIAN_PORT, and the
# SEARXNG_BIND_ADDRESS setting is a leftover from the uwsgi era that
# granian never reads.
- name: GRANIAN_HOST
value: "::"
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".
limitFactor: 3
requests:
cpu: 0.1
# Measured on the shipped image in k3d, cgroup memory.current of the
# container: 152 MiB once the worker is up and answering, peaking at
# 157 MiB across a real search. 256Mi covers that with headroom; the
# computed limit (3x = 768Mi) leaves room for the per-request engine
# work, which fans out to many upstreams at once.
memory: 256Mi
# SearXNG writes its SQLite caches into /tmp - searx/cache.py falls back
# to tempfile.gettempdir() when no db_url is configured. Measured after a
# real search: 356 KiB total (DATA_CACHE and ENGINES_CACHE, each a small
# db plus its -shm/-wal files), and the application expires entries
# itself. 64Mi is roughly 180x the measured footprint while still
# bounding a runaway; the computed limit is 192Mi.
ephemeralStorage: 64Mi
# No CPU limit by default (repo standard): CPU is compressible, so a limit
# only throttles.
ingress:
# Gates the whole Ingress template.
#
# Checked in both directions per guideline 1, and the answer here is NOT the
# comfortable one: ingress.domain is read a SECOND time, outside this
# template, to build SEARXNG_BASE_URL in the Deployment. It therefore keeps
# its own required() there and stays mandatory even with the Ingress turned
# off; only clusterIssuer becomes unnecessary.
#
# Why that read cannot be dropped: server.base_url is what SearXNG puts into
# the OpenSearch description, into the image-proxy URLs it signs and into
# every absolute link it generates. Left empty, the instance renders and the
# pod goes green while browsers get sent to a broken address - exactly the
# silent failure the guideline exists for.
enabled: true
clusterIssuer: null
# The hostname this instance is served under. Required (see above), with no
# default - hostnames are installation data and have no business in a chart.
domain: null
className: nginx
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
#
# This is where authentication in front of SearXNG belongs, and it needs no
# chart change: forward-auth is entirely an ingress-nginx matter. With
# Authentik, for example:
#
# annotations:
# nginx.ingress.kubernetes.io/auth-url: |-
# http://ak-outpost-authentik-embedded-outpost.authentik.svc.cluster.local:9000/outpost.goauthentik.io/auth/nginx
# nginx.ingress.kubernetes.io/auth-signin: |-
# https://searxng.example.com/outpost.goauthentik.io/start?rd=$escaped_request_uri
# nginx.ingress.kubernetes.io/auth-response-headers: |-
# Set-Cookie,X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid
# nginx.ingress.kubernetes.io/auth-snippet: |
# proxy_set_header X-Forwarded-Host $http_host;
#
# (example.com is a placeholder - substitute your own hostname.)
#
# Two things to know before relying on it:
#
# * The kubelet's probes go straight to the pod and never pass the
# Ingress, so an authentication outpost that is down cannot turn this
# release red. Good for availability - and it also means a green pod
# says nothing about whether the login in front of it works.
# * Anyone who adds this instance as a browser search engine sends their
# queries through the same login. That is not a chart concern, but it is
# the reason such a setup "suddenly stops working" after a session
# expires.
annotations: {}