Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8074ed14a5
|
||
|
|
d9267b2180 | ||
|
|
a414af089d | ||
|
|
93fdb3eaea | ||
|
|
819d7b2cf7 | ||
|
|
7115eb87f2 | ||
|
|
9344a45992
|
||
|
|
4715d85b99 | ||
|
|
5ff6e401c7 | ||
|
|
8074e0de59
|
||
|
|
1edcf6fb9c
|
||
|
|
c36c5ee2c5
|
No files matched your search
@@ -38,6 +38,7 @@ on:
|
|||||||
- searxng
|
- searxng
|
||||||
- bentopdf
|
- bentopdf
|
||||||
- ytdlp-web-player
|
- ytdlp-web-player
|
||||||
|
- sure
|
||||||
# freesailarr is deliberately absent: none of its nine images takes
|
# freesailarr is deliberately absent: none of its nine images takes
|
||||||
# its tag from appVersion (they are all pinned in its values.yaml),
|
# its tag from appVersion (they are all pinned in its values.yaml),
|
||||||
# so this workflow would rewrite appVersion and republish the chart
|
# so this workflow would rewrite appVersion and republish the chart
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
name: Publish Sure Helm Chart
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- sure/**
|
||||||
|
jobs:
|
||||||
|
publish-sure:
|
||||||
|
name: Publish Sure Helm Chart
|
||||||
|
uses: ./.github/workflows/publish-helm-chart.yml
|
||||||
|
with:
|
||||||
|
chart-name: 'sure'
|
||||||
|
secrets: inherit
|
||||||
@@ -32,6 +32,7 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
|
|||||||
| `samba` | Samba | StatefulSet, users secret + config ConfigMap |
|
| `samba` | Samba | StatefulSet, users secret + config ConfigMap |
|
||||||
| `satisfactory` | Satisfactory game server | StatefulSet, no ingress |
|
| `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 |
|
| `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 |
|
||||||
|
| `sure` | Sure (we-promise, the maintained Maybe-finance fork) | One StatefulSet with **two containers**: the Puma web process and a Sidekiq worker, sharing one ReadWriteOnce PVC at `/rails/storage` — that shared volume is the reason they live in one pod. Only the web container migrates (the image entrypoint runs `db:prepare` solely for the `./bin/rails server` command, so the worker is started through `args:` and skips it). Three 1Password items (app/database/redis); Postgres and Redis stay external. Readiness deliberately probes `/` rather than Rails' `/up`, which is a boot check and touches no database; the worker's three probes ask `sidekiqmon` for this pod's own heartbeat. `ingress.domain` is read for `APP_DOMAIN` and the WebAuthn relying party, so it stays required with the Ingress switched off |
|
||||||
| `teamspeak-server` | TeamSpeak server | StatefulSet, no ingress |
|
| `teamspeak-server` | TeamSpeak server | StatefulSet, no ingress |
|
||||||
| `template-chart` | — | Scaffold for new charts (placeholder `xxx`), never published |
|
| `template-chart` | — | Scaffold for new charts (placeholder `xxx`), never published |
|
||||||
| `urlaubsverwaltung` | Urlaubsverwaltung | Deployment, DB + OIDC secrets |
|
| `urlaubsverwaltung` | Urlaubsverwaltung | Deployment, DB + OIDC secrets |
|
||||||
@@ -63,7 +64,7 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
|
|||||||
|
|
||||||
Everything below was measured on this instance, not taken from documentation, and it is the reason for every deviation in `validate-charts.yml`. How the move got here is not repeated — that is Gitea #14.
|
Everything below was measured on this instance, not taken from documentation, and it is the reason for every deviation in `validate-charts.yml`. How the move got here is not repeated — that is Gitea #14.
|
||||||
|
|
||||||
**Reusable workflows with `secrets: inherit` work**, so the 23 thin `publish-<chart>.yml` hulls run unchanged. One quirk worth knowing before debugging the wrong thing: a *missing* secret does not fail the job, it fails job **creation**, so the run shows no steps at all.
|
**Reusable workflows with `secrets: inherit` work**, so the 24 thin `publish-<chart>.yml` hulls run unchanged. One quirk worth knowing before debugging the wrong thing: a *missing* secret does not fail the job, it fails job **creation**, so the run shows no steps at all.
|
||||||
|
|
||||||
**Runner labels mean different machines than on GitHub.** `ubuntu-latest` here has **no Docker socket**; `ubuntu-docker` has one. Exactly one job cares — the install-test, because k3d is a Docker client — and its `runs-on` is therefore an expression rather than a fixed label, so the same file still works on a GitHub runner where `ubuntu-docker` does not exist. Every other job (lint/package, the quantity render, the Trivy scan) needs no socket and stays on `ubuntu-latest`.
|
**Runner labels mean different machines than on GitHub.** `ubuntu-latest` here has **no Docker socket**; `ubuntu-docker` has one. Exactly one job cares — the install-test, because k3d is a Docker client — and its `runs-on` is therefore an expression rather than a fixed label, so the same file still works on a GitHub runner where `ubuntu-docker` does not exist. Every other job (lint/package, the quantity render, the Trivy scan) needs no socket and stays on `ubuntu-latest`.
|
||||||
|
|
||||||
@@ -344,6 +345,12 @@ Reference implementation: `template-chart/values.schema.json` and `template-char
|
|||||||
|
|
||||||
**2. Probes, security contexts and resources go through the null-safe helper, never behind an `if`.** An override written without children is YAML null and erases the block: the container renders `securityContext: null` (unhardened, healthy, silent), a probe disappears, or `resources` loses its requests and computed limits. The `defaultedDict` helper defines the opposite semantics — an empty block means "chart defaults apply" — while an explicitly set value still wins, **including `false` and `0`** (hence the `kindIs "invalid"` check; `default` and `mergeOverwrite` both swallow zero values). Do **not** wrap these blocks in `{{- if }}`: a guard does not render a visible `null`, it removes the probe entirely, which is the same bug with no evidence left.
|
**2. Probes, security contexts and resources go through the null-safe helper, never behind an `if`.** An override written without children is YAML null and erases the block: the container renders `securityContext: null` (unhardened, healthy, silent), a probe disappears, or `resources` loses its requests and computed limits. The `defaultedDict` helper defines the opposite semantics — an empty block means "chart defaults apply" — while an explicitly set value still wins, **including `false` and `0`** (hence the `kindIs "invalid"` check; `default` and `mergeOverwrite` both swallow zero values). Do **not** wrap these blocks in `{{- if }}`: a guard does not render a visible `null`, it removes the probe entirely, which is the same bug with no evidence left.
|
||||||
|
|
||||||
|
The rule is about the **shape, not about those three block kinds**. `satisfactory` shipped 7.0.0 with `game` null-safe and its two neighbours not (Gitea #16): `--set server=null` aborted the render, and a single erased key was the quieter half — a nulled `server.gamePort` rendered `port:` and `nodePort:` empty into the Service and an empty `containerPort` into the StatefulSet, rejected by nothing until the API server saw it. Every chart-owned block a template dereferences belongs behind the helper, and a chart where only *some* of them do is worse than one where none do: whoever sees `game=null` fall back cleanly assumes `server=null` does too.
|
||||||
|
|
||||||
|
**A deliberate exception is fine; an undocumented one is the bug.** `satisfactory.storage` is left un-defaulted on purpose — an erased size yields an invalid PVC that fails at apply time rather than a volume quietly sized by the chart — so `server: null` still renders a PVC without a size. That is a defensible trade and it is *not* the null-safety bug next to it; what makes it safe to leave standing is that the schema, `values.yaml` and the chart page all name it as an exception and point at the issue where it is argued (Gitea #39). Note also where the trade actually bites: only on a **new** install. For an existing release a fallback to the default size would fail too, because a PVC cannot shrink. Do not overturn a reasoned exception inside a bugfix patch — the patch then stops being one.
|
||||||
|
|
||||||
|
**And `helm template` does not render `NOTES.txt`.** A value read there — `server` is read in four files in that chart — stays broken after a fix that looks green in every render, until someone runs a real `helm install`. `helm install --dry-run=client` is not the way around it either: it still wants a reachable API server (measured on helm v3.22.0). What works without a cluster: in a throwaway copy of the chart, wrap the verbatim `NOTES.txt` body in a `{{- define }}` and `include` it into a scratch ConfigMap, which `helm template` *does* render — same engine, same root context, only the sink differs.
|
||||||
|
|
||||||
**3. Do not hang liveness on an external dependency where a dependency-free path exists.** `zipline` deliberately probes `/` for liveness and `/api/healthcheck` only for readiness and startup, so a database outage cannot restart the pod. Where no such path exists — `outline` returns 500 on `/` when the database is down — all three probes may share the endpoint, but the reason belongs in a comment on the helper, or the next reader will "fix" the inconsistency.
|
**3. Do not hang liveness on an external dependency where a dependency-free path exists.** `zipline` deliberately probes `/` for liveness and `/api/healthcheck` only for readiness and startup, so a database outage cannot restart the pod. Where no such path exists — `outline` returns 500 on `/` when the database is down — all three probes may share the endpoint, but the reason belongs in a comment on the helper, or the next reader will "fix" the inconsistency.
|
||||||
|
|
||||||
The other half of the same rule: readiness **must** hang on the dependency, and a separate path is not proof that it does. `urlaubsverwaltung` probed `/actuator/health/liveness` and `/actuator/health/readiness` for two years and still stayed 1/1 Ready through a total database outage (#218), because Spring Boot's `readiness` group contains `readinessState` **alone** — the split paths were right, the group content was empty of anything external. Whatever aggregates behind a probe path (Spring health groups, a `/healthz` a framework assembles for you) has to be read off the **running** endpoint, not off the path name; the chart names the members explicitly (`MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE=readinessState,db`) so the composition is in the template rather than in an upstream default.
|
The other half of the same rule: readiness **must** hang on the dependency, and a separate path is not proof that it does. `urlaubsverwaltung` probed `/actuator/health/liveness` and `/actuator/health/readiness` for two years and still stayed 1/1 Ready through a total database outage (#218), because Spring Boot's `readiness` group contains `readinessState` **alone** — the split paths were right, the group content was empty of anything external. Whatever aggregates behind a probe path (Spring health groups, a `/healthz` a framework assembles for you) has to be read off the **running** endpoint, not off the path name; the chart names the members explicitly (`MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE=readinessState,db`) so the composition is in the template rather than in an upstream default.
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Fixtures for the sure install-test: throwaway PostgreSQL 16 (the version
|
||||||
|
# upstream's compose file uses) and Redis, plus the secrets the 1Password
|
||||||
|
# operator would otherwise materialize (release name: ci).
|
||||||
|
#
|
||||||
|
# Both are mandatory for this chart, and for different reasons: the web
|
||||||
|
# container migrates the schema at start and its readiness probe runs a query,
|
||||||
|
# while all three probes of the worker container ask Redis whether the Sidekiq
|
||||||
|
# process still has a heartbeat. A missing Redis therefore does not show up as
|
||||||
|
# "a background job is late" but as a pod that never turns Ready.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: ci-sure-secret
|
||||||
|
stringData:
|
||||||
|
# 64 bytes hex, as upstream's "openssl rand -hex 64" produces. Any value
|
||||||
|
# works for a throwaway database - it is the key the app derives its Active
|
||||||
|
# Record encryption keys from, and nothing here survives the run.
|
||||||
|
secret-key-base: 3f786850e387550fdab836ed7e6dc881de23001b3f786850e387550fdab836ed7e6dc881de23001b3f786850e387550fdab836ed7e6dc881de23001b3f786850
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: ci-sure-db-secret
|
||||||
|
stringData:
|
||||||
|
host: postgres
|
||||||
|
port: "5432"
|
||||||
|
database: sure
|
||||||
|
username: ci
|
||||||
|
password: ci
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: ci-sure-redis-secret
|
||||||
|
stringData:
|
||||||
|
connection-url: redis://:cipass@redis:6379/0
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: public.ecr.aws/docker/library/postgres:16-alpine
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: ci
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
value: ci
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: sure
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
readinessProbe:
|
||||||
|
# -d names the database the chart actually uses. Without it
|
||||||
|
# pg_isready asks for a database named after the user and the
|
||||||
|
# server logs 'FATAL: database "ci" does not exist' every five
|
||||||
|
# seconds - noise that reads like a chart problem in the diagnostics
|
||||||
|
# dump when something else fails.
|
||||||
|
exec:
|
||||||
|
command: ["pg_isready", "-U", "ci", "-d", "sure"]
|
||||||
|
periodSeconds: 5
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: redis
|
||||||
|
labels:
|
||||||
|
app: redis
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: redis
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: redis
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: redis
|
||||||
|
image: public.ecr.aws/docker/library/redis:7-alpine
|
||||||
|
args: ["redis-server", "--requirepass", "cipass"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 6379
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 6379
|
||||||
|
periodSeconds: 5
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: redis
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: redis
|
||||||
|
ports:
|
||||||
|
- port: 6379
|
||||||
|
targetPort: 6379
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# The first start of the web container creates and migrates the whole Rails
|
||||||
|
# schema ("./bin/rails db:prepare", run by the image entrypoint) before Puma
|
||||||
|
# binds its port, and the worker container only registers with Sidekiq once
|
||||||
|
# that schema exists. Both startup probes budget 600s for it, so the rollout
|
||||||
|
# wait has to be longer than a single startup budget, not shorter.
|
||||||
|
INSTALL_TIMEOUT=900
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
secrets:
|
||||||
|
appSecretRef: op://ci/sure-app
|
||||||
|
dbSecretRef: op://ci/sure-db
|
||||||
|
redisSecretRef: op://ci/sure-redis
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
domain: sure.ci.local
|
||||||
|
# clusterIssuer is strictly required (no default); the Ingress just has to
|
||||||
|
# apply - there is no cert-manager in the CI cluster.
|
||||||
|
clusterIssuer: ci
|
||||||
@@ -24,7 +24,7 @@ misconfigurations:
|
|||||||
statement: "No CPU limits, on purpose: CPU is compressible, so a limit throttles where the missing limit merely shares - see README 'Resources (auto-limits)'. The chart helper renders a CPU limit only when one is set explicitly."
|
statement: "No CPU limits, on purpose: CPU is compressible, so a limit throttles where the missing limit merely shares - see README 'Resources (auto-limits)'. The chart helper renders a CPU limit only when one is set explicitly."
|
||||||
|
|
||||||
- id: AVD-KSV-0020
|
- id: AVD-KSV-0020
|
||||||
statement: "UIDs are pinned to the value the image expects, not raised above 10000: minecraft-server 1000 (gosu default), teamspeak-server 9987, voucher-vault 33, the matrix-synapse delegation nginx 101. calibre-web-automated shows the risk - a runtime uid moving from 1000 to 911 makes existing volume data inaccessible, and every chart with a PVC carries that same risk class."
|
statement: "UIDs are pinned to the value the image expects, not raised above 10000: minecraft-server 1000 (gosu default), teamspeak-server 9987, voucher-vault 33, the matrix-synapse delegation nginx 101, sure 1000 (its Dockerfile declares USER 1000:1000). calibre-web-automated shows the risk - a runtime uid moving from 1000 to 911 makes existing volume data inaccessible, and every chart with a PVC carries that same risk class."
|
||||||
|
|
||||||
- id: AVD-KSV-0021
|
- id: AVD-KSV-0021
|
||||||
statement: "GIDs follow the pinned UIDs above; the same image expectation and the same PVC ownership risk apply."
|
statement: "GIDs follow the pinned UIDs above; the same image expectation and the same PVC ownership risk apply."
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ icon: https://avatars.githubusercontent.com/u/988985
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 4.1.1+up1.46.5
|
version: 4.1.2+up1.47.1
|
||||||
appVersion: "1.46.5"
|
appVersion: "1.47.1"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
+2
-2
@@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/lukevella/rallly/main/apps/landing/publi
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 8.1.4+up4.15.1
|
version: 8.1.5+up4.15.2
|
||||||
appVersion: "4.15.1"
|
appVersion: "4.15.2"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
+28
-1
@@ -41,7 +41,7 @@
|
|||||||
prHourlyLimit: 0,
|
prHourlyLimit: 0,
|
||||||
|
|
||||||
// Minor and patch arrive as separate PRs rather than one combined branch.
|
// Minor and patch arrive as separate PRs rather than one combined branch.
|
||||||
// With 23 charts a shared branch means one red install-test blocks unrelated
|
// With 24 charts a shared branch means one red install-test blocks unrelated
|
||||||
// updates, and the separation is what lets a patch be reviewed in seconds.
|
// updates, and the separation is what lets a patch be reviewed in seconds.
|
||||||
separateMinorPatch: true,
|
separateMinorPatch: true,
|
||||||
|
|
||||||
@@ -485,6 +485,33 @@
|
|||||||
datasourceTemplate: "docker",
|
datasourceTemplate: "docker",
|
||||||
depTypeTemplate: "app-version",
|
depTypeTemplate: "app-version",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: [
|
||||||
|
"sure: the registry carries four kinds of tag side by side - releases (0.7.4), the",
|
||||||
|
"prereleases -alpha.N / -rc.N / -hotfix.N, the moving names stable/latest/nightly,",
|
||||||
|
"and one sha-<commit> per build. semver versioning is the whole filter: it parses",
|
||||||
|
"the release line, marks every prerelease isStable === false so `ignoreUnstable`",
|
||||||
|
"never offers one, and rejects the moving names and the sha- tags as unparseable",
|
||||||
|
"rather than ever selecting one. That matters more here than the tag list suggests,",
|
||||||
|
"because automerge is global in this config (top of the file) and majors carry no",
|
||||||
|
"age gate: an update this manager offers is one that merges as soon as the required",
|
||||||
|
"check and the required review are there, so the filtering has to happen at the",
|
||||||
|
"lookup, not at the merge. It is also the reason NOT to write a capture-group regex",
|
||||||
|
"here - the moving tags have to stay outside whatever pattern is used, and 'outside'",
|
||||||
|
"is free with semver.",
|
||||||
|
"",
|
||||||
|
"Upstream calls 'latest' the alpha channel and 'stable' the latest release; at the",
|
||||||
|
"time of writing 'stable' resolves to the same digest as 0.7.4, so pinning the",
|
||||||
|
"version tag loses nothing and keeps the chart's appVersion honest.",
|
||||||
|
],
|
||||||
|
customType: "regex",
|
||||||
|
managerFilePatterns: ["sure/Chart.yaml"],
|
||||||
|
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
|
||||||
|
depNameTemplate: "ghcr.io/we-promise/sure",
|
||||||
|
datasourceTemplate: "docker",
|
||||||
|
depTypeTemplate: "app-version",
|
||||||
|
versioningTemplate: "semver",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
description: "teamspeak-server: the 6.0.0 line only ships beta tags (6.0.0-beta12.1). Loose versioning compares the numeric parts left to right, so beta12.1 sorts above beta9; semver would rank every beta as an equal prerelease.",
|
description: "teamspeak-server: the 6.0.0 line only ships beta tags (6.0.0-beta12.1). Loose versioning compares the numeric parts left to right, so beta12.1 sorts above beta9; semver would rank every beta as an equal prerelease.",
|
||||||
customType: "regex",
|
customType: "regex",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: satisfactory
|
|||||||
description: A Helm chart to deploy a Satisfactory Gameserver on Kubernetes
|
description: A Helm chart to deploy a Satisfactory Gameserver on Kubernetes
|
||||||
icon: https://raw.githubusercontent.com/wolveix/satisfactory-server/main/.github/logo.png
|
icon: https://raw.githubusercontent.com/wolveix/satisfactory-server/main/.github/logo.png
|
||||||
type: application
|
type: application
|
||||||
version: 7.0.0+upv1.9.10
|
version: 7.0.1+upv1.9.10
|
||||||
appVersion: "v1.9.10"
|
appVersion: "v1.9.10"
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
{{- $replicas := include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) -}}
|
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||||
{{- $gamePort := int .Values.server.gamePort -}}
|
{{- $dev := fromYaml (include "satisfactory.dev" .Values.dev) -}}
|
||||||
{{- $msgPort := int .Values.server.messagingPort -}}
|
{{- $replicas := include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" (include "satisfactory.formatNumber" $server.replicas)) -}}
|
||||||
|
{{- $gamePort := int $server.gamePort -}}
|
||||||
|
{{- $msgPort := int $server.messagingPort -}}
|
||||||
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||||
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
|
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
|
||||||
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
|
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
|
||||||
{{- $pvc := printf "%s-%s-vc" .Release.Name .Chart.Name -}}
|
{{- $pvc := printf "%s-%s-vc" .Release.Name .Chart.Name -}}
|
||||||
{{- $storage := .Values.server.storage | default "unset" -}}
|
{{- $storage := $server.storage | default "unset" -}}
|
||||||
{{- $res := fromYaml (include "satisfactory.effectiveResources" .Values.server.resources) -}}
|
{{- $res := fromYaml (include "satisfactory.effectiveResources" $server.resources) -}}
|
||||||
{{- $lim := $res.limits | default dict -}}
|
{{- $lim := $res.limits | default dict -}}
|
||||||
{{- $req := $res.requests | default dict -}}
|
{{- $req := $res.requests | default dict -}}
|
||||||
{{- $eph := index $lim "ephemeral-storage" | default "the computed ephemeral-storage limit" -}}
|
{{- $eph := index $lim "ephemeral-storage" | default "the computed ephemeral-storage limit" -}}
|
||||||
{{- $mem := index $lim "memory" | default "the computed memory limit" -}}
|
{{- $mem := index $lim "memory" | default "the computed memory limit" -}}
|
||||||
{{- $factor := (.Values.server.resources | default dict).limitFactor | default 2 -}}
|
{{- $factor := ($server.resources | default dict).limitFactor | default 2 -}}
|
||||||
{{- $startup := fromYaml (include "satisfactory.startupProbe" .Values.startupProbe) -}}
|
{{- $startup := fromYaml (include "satisfactory.startupProbe" .Values.startupProbe) -}}
|
||||||
{{- $budget := "" -}}
|
{{- $budget := "" -}}
|
||||||
{{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}}
|
{{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}}
|
||||||
@@ -21,7 +23,7 @@
|
|||||||
{{- $seasonalEnv := include "satisfactory.disableSeasonalEvents" $game.enableSeasonalEvents -}}
|
{{- $seasonalEnv := include "satisfactory.disableSeasonalEvents" $game.enableSeasonalEvents -}}
|
||||||
{{- $seasonalOff := eq $seasonalEnv "true" -}}
|
{{- $seasonalOff := eq $seasonalEnv "true" -}}
|
||||||
{{- $beta := eq (lower (printf "%v" $game.experimental)) "true" -}}
|
{{- $beta := eq (lower (printf "%v" $game.experimental)) "true" -}}
|
||||||
{{- $debug := eq (lower (printf "%v" .Values.dev.debug)) "true" -}}
|
{{- $debug := eq (lower (printf "%v" $dev.debug)) "true" -}}
|
||||||
{{- $outOfRange := or (lt $gamePort 30000) (gt $gamePort 32767) (lt $msgPort 30000) (gt $msgPort 32767) -}}
|
{{- $outOfRange := or (lt $gamePort 30000) (gt $gamePort 32767) (lt $msgPort 30000) (gt $msgPort 32767) -}}
|
||||||
{{- $skipRaw := "" -}}
|
{{- $skipRaw := "" -}}
|
||||||
{{- range $entry := .Values.env -}}
|
{{- range $entry := .Values.env -}}
|
||||||
|
|||||||
@@ -308,6 +308,63 @@ DISABLESEASONALEVENTS at "true", the value this chart has always rendered.
|
|||||||
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "game") -}}
|
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "game") -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
The effective dev block (Gitea issue #16, the rest of #99).
|
||||||
|
|
||||||
|
"--set dev=null" — or a leftover "dev:" whose last sub-key was deleted — used
|
||||||
|
to abort the render with a nil-pointer error at .Values.dev.debug, in the
|
||||||
|
StatefulSet and in NOTES.txt. It now falls back to the default values.yaml
|
||||||
|
documents, the same way the game block has since 7.0.0.
|
||||||
|
|
||||||
|
The defaults below MUST stay in sync with the dev block 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.
|
||||||
|
*/}}
|
||||||
|
{{- define "satisfactory.dev" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"debug" false -}}
|
||||||
|
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "dev") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
The effective server block (Gitea issue #16, the rest of #99).
|
||||||
|
|
||||||
|
"--set server=null" used to abort the render at .Values.server.replicas, and a
|
||||||
|
single erased sub-key was worse than that: it rendered silently. A nulled
|
||||||
|
server.gamePort produced a Service with "port:" and "nodePort:" empty and a
|
||||||
|
StatefulSet with an empty containerPort — a manifest the API server rejects
|
||||||
|
only at apply time. Those now fall back to the documented defaults instead.
|
||||||
|
|
||||||
|
TWO keys are deliberately NOT defaulted here.
|
||||||
|
|
||||||
|
server.resources, because it already runs through
|
||||||
|
satisfactory.effectiveResources, which carries its own defaults and is
|
||||||
|
null-safe on its own; repeating limitFactor/4Gi/1/5Gi here would create a
|
||||||
|
third place for them to drift apart.
|
||||||
|
|
||||||
|
server.storage, because values.schema.json documents the opposite decision
|
||||||
|
for it on purpose: an erased size must yield an invalid PVC that fails at
|
||||||
|
apply time rather than a workload quietly sized by the chart. An erased
|
||||||
|
"server:" therefore still renders a PVC without a size — deliberately, not by
|
||||||
|
oversight. That exception is worth re-examining, but it is someone's reasoned
|
||||||
|
decision and not a bugfix's to overturn; it is negotiated in Gitea issue #39.
|
||||||
|
|
||||||
|
Both rely on the same mechanic: keys the defaults do not mention are passed
|
||||||
|
through by satisfactory.defaultedDict unchanged, so an erased "server:" leaves
|
||||||
|
them unset and whatever consumes them downstream decides what that means.
|
||||||
|
|
||||||
|
The defaults below MUST stay in sync with the server block 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.
|
||||||
|
*/}}
|
||||||
|
{{- define "satisfactory.server" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"replicas" 1
|
||||||
|
"gamePort" 30777
|
||||||
|
"messagingPort" 30888 -}}
|
||||||
|
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "server") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Render the DISABLESEASONALEVENTS env value from game.enableSeasonalEvents
|
Render the DISABLESEASONALEVENTS env value from game.enableSeasonalEvents
|
||||||
(issue #215).
|
(issue #215).
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
@@ -7,5 +8,5 @@ spec:
|
|||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.server.storage }}
|
storage: {{ $server.storage }}
|
||||||
storageClassName: longhorn
|
storageClassName: longhorn
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||||
|
{{- $gamePort := include "satisfactory.formatNumber" $server.gamePort -}}
|
||||||
|
{{- $msgPort := include "satisfactory.formatNumber" $server.messagingPort -}}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
@@ -8,19 +11,19 @@ spec:
|
|||||||
type: NodePort
|
type: NodePort
|
||||||
ports:
|
ports:
|
||||||
- name: "api"
|
- name: "api"
|
||||||
port: {{ .Values.server.gamePort }}
|
port: {{ $gamePort }}
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
nodePort: {{ .Values.server.gamePort }}
|
nodePort: {{ $gamePort }}
|
||||||
targetPort: {{ .Values.server.gamePort }}
|
targetPort: {{ $gamePort }}
|
||||||
- name: "game"
|
- name: "game"
|
||||||
port: {{ .Values.server.gamePort }}
|
port: {{ $gamePort }}
|
||||||
protocol: UDP
|
protocol: UDP
|
||||||
nodePort: {{ .Values.server.gamePort }}
|
nodePort: {{ $gamePort }}
|
||||||
targetPort: {{ .Values.server.gamePort }}
|
targetPort: {{ $gamePort }}
|
||||||
- name: "messaging"
|
- name: "messaging"
|
||||||
port: {{ .Values.server.messagingPort }}
|
port: {{ $msgPort }}
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
nodePort: {{ .Values.server.messagingPort }}
|
nodePort: {{ $msgPort }}
|
||||||
targetPort: {{ .Values.server.messagingPort }}
|
targetPort: {{ $msgPort }}
|
||||||
selector:
|
selector:
|
||||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
{{- $game := fromYaml (include "satisfactory.game" .Values.game) -}}
|
{{- $game := fromYaml (include "satisfactory.game" .Values.game) -}}
|
||||||
|
{{- $dev := fromYaml (include "satisfactory.dev" .Values.dev) -}}
|
||||||
|
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: StatefulSet
|
kind: StatefulSet
|
||||||
metadata:
|
metadata:
|
||||||
@@ -10,7 +12,7 @@ spec:
|
|||||||
matchLabels:
|
matchLabels:
|
||||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||||
replicas: {{ include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) }}
|
replicas: {{ include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" (include "satisfactory.formatNumber" $server.replicas)) }}
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
@@ -24,7 +26,7 @@ spec:
|
|||||||
image: "ghcr.io/wolveix/satisfactory-server:{{ .Chart.AppVersion }}"
|
image: "ghcr.io/wolveix/satisfactory-server:{{ .Chart.AppVersion }}"
|
||||||
env:
|
env:
|
||||||
- name: DEBUG
|
- name: DEBUG
|
||||||
value: "{{ .Values.dev.debug }}"
|
value: "{{ $dev.debug }}"
|
||||||
- name: MAXPLAYERS
|
- name: MAXPLAYERS
|
||||||
value: "{{ include "satisfactory.formatNumber" $game.maxPlayers }}"
|
value: "{{ include "satisfactory.formatNumber" $game.maxPlayers }}"
|
||||||
# PUID/PGID are only used by the image's root entrypoint path;
|
# PUID/PGID are only used by the image's root entrypoint path;
|
||||||
@@ -42,9 +44,9 @@ spec:
|
|||||||
- name: DISABLESEASONALEVENTS
|
- name: DISABLESEASONALEVENTS
|
||||||
value: "{{ include "satisfactory.disableSeasonalEvents" $game.enableSeasonalEvents }}"
|
value: "{{ include "satisfactory.disableSeasonalEvents" $game.enableSeasonalEvents }}"
|
||||||
- name: SERVERGAMEPORT
|
- name: SERVERGAMEPORT
|
||||||
value: "{{ .Values.server.gamePort }}"
|
value: "{{ include "satisfactory.formatNumber" $server.gamePort }}"
|
||||||
- name: SERVERMESSAGINGPORT
|
- name: SERVERMESSAGINGPORT
|
||||||
value: "{{ .Values.server.messagingPort }}"
|
value: "{{ include "satisfactory.formatNumber" $server.messagingPort }}"
|
||||||
{{- range $value := .Values.env }}
|
{{- range $value := .Values.env }}
|
||||||
{{- if $value.value }}
|
{{- if $value.value }}
|
||||||
- name: {{ $value.name }}
|
- name: {{ $value.name }}
|
||||||
@@ -67,13 +69,13 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
ports:
|
ports:
|
||||||
- name: "api"
|
- name: "api"
|
||||||
containerPort: {{ .Values.server.gamePort }}
|
containerPort: {{ include "satisfactory.formatNumber" $server.gamePort }}
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
- name: "game"
|
- name: "game"
|
||||||
containerPort: {{ .Values.server.gamePort }}
|
containerPort: {{ include "satisfactory.formatNumber" $server.gamePort }}
|
||||||
protocol: UDP
|
protocol: UDP
|
||||||
- name: "messaging"
|
- name: "messaging"
|
||||||
containerPort: {{ .Values.server.messagingPort }}
|
containerPort: {{ include "satisfactory.formatNumber" $server.messagingPort }}
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
{{- include "satisfactory.livenessProbe" .Values.livenessProbe | nindent 12 }}
|
{{- include "satisfactory.livenessProbe" .Values.livenessProbe | nindent 12 }}
|
||||||
@@ -82,7 +84,7 @@ spec:
|
|||||||
startupProbe:
|
startupProbe:
|
||||||
{{- include "satisfactory.startupProbe" .Values.startupProbe | nindent 12 }}
|
{{- include "satisfactory.startupProbe" .Values.startupProbe | nindent 12 }}
|
||||||
resources:
|
resources:
|
||||||
{{- include "satisfactory.effectiveResources" .Values.server.resources | nindent 12 }}
|
{{- include "satisfactory.effectiveResources" $server.resources | nindent 12 }}
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: satisfactory-volume
|
- name: satisfactory-volume
|
||||||
mountPath: /config
|
mountPath: /config
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft-07/schema#",
|
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||||
"title": "satisfactory values",
|
"title": "satisfactory values",
|
||||||
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key that the chart does not read is rejected at render time instead of being silently ignored. The only objects that stay open are the ones passed verbatim into the Kubernetes pod spec (probes, security contexts, global) - the chart does not interpret their contents, so it cannot judge which keys are meaningful. The hardening, probe and resources blocks additionally accept null, because an erased block falls back to the chart defaults (issue #99). Note that this chart keeps env, probes and securityContext at the TOP level rather than under a chart-named key.",
|
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key that the chart does not read is rejected at render time instead of being silently ignored. The only objects that stay open are the ones passed verbatim into the Kubernetes pod spec (probes, security contexts, global) - the chart does not interpret their contents, so it cannot judge which keys are meaningful. The hardening, probe and resources blocks additionally accept null, because an erased block falls back to the chart defaults (issue #99); since 7.0.1 that holds for every chart-owned block, dev, game and server included (Gitea issue #16), so no block aborts the render any more. The one key that stays un-defaulted is server.storage, and it says so itself (Gitea issue #39). Note that this chart keeps env, probes and securityContext at the TOP level rather than under a chart-named key.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -13,11 +13,12 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"type": "object",
|
"description": "May be null / empty: the chart then falls back to its documented dev defaults (Gitea issue #16, the rest of issue #99) instead of aborting the render with a nil-pointer error, which is what an erased block used to do.",
|
||||||
|
"type": ["object", "null"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"debug": {
|
"debug": {
|
||||||
"description": "Passed to the image as DEBUG.",
|
"description": "Passed to the image as DEBUG. Null means the chart default (false).",
|
||||||
"type": ["boolean", "string", "null"]
|
"type": ["boolean", "string", "null"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,25 +42,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"type": "object",
|
"description": "May be null / empty: the chart then falls back to its documented server defaults (Gitea issue #16, the rest of issue #99) instead of aborting the render with a nil-pointer error, which is what an erased block used to do. Two keys are exceptions. server.resources is not defaulted here but passed through to the resources fallback, which carries the defaults for it. server.storage is not defaulted at all, deliberately - see its own description.",
|
||||||
|
"type": ["object", "null"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"replicas": {
|
"replicas": {
|
||||||
"type": "integer",
|
"description": "Null means the chart default (1). An explicit 0 is kept and scales the StatefulSet down.",
|
||||||
|
"type": ["integer", "null"],
|
||||||
"minimum": 0
|
"minimum": 0
|
||||||
},
|
},
|
||||||
"gamePort": {
|
"gamePort": {
|
||||||
"type": "integer",
|
"description": "Null means the chart default (30777). Up to 7.0.0 an erased port rendered an empty port number into the Service and the StatefulSet, which only the API server rejected (Gitea issue #16).",
|
||||||
|
"type": ["integer", "null"],
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 65535
|
"maximum": 65535
|
||||||
},
|
},
|
||||||
"messagingPort": {
|
"messagingPort": {
|
||||||
"type": "integer",
|
"description": "Null means the chart default (30888). Same erased-port trap as gamePort above.",
|
||||||
|
"type": ["integer", "null"],
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 65535
|
"maximum": 65535
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"description": "Size of the data PVC. Not null-safe on purpose: an erased value yields an invalid PVC rather than a silently degraded workload.",
|
"description": "Size of the data PVC. Not null-safe on purpose: an erased value yields an invalid PVC rather than a silently degraded workload. This stays the documented exception after Gitea issue #16 made every other block in this chart null-safe, so \"server: null\" still renders a PVC without a size; whether failing loudly at apply time beats falling back to 20Gi is negotiated in Gitea issue #39, not inside a bugfix patch.",
|
||||||
"type": ["string", "null"]
|
"type": ["string", "null"]
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
# Services stay in place; scaleDown: false brings the workloads back up.
|
# Services stay in place; scaleDown: false brings the workloads back up.
|
||||||
scaleDown: false
|
scaleDown: false
|
||||||
|
|
||||||
|
# Erasing this block (dev: with no sub-keys, or --set dev=null) falls back to
|
||||||
|
# the defaults below instead of aborting the render — same for game, server and
|
||||||
|
# the hardening/probe/resources blocks (Gitea issue #16, issue #99).
|
||||||
dev:
|
dev:
|
||||||
debug: false
|
debug: false
|
||||||
|
|
||||||
@@ -28,12 +31,23 @@ game:
|
|||||||
# again. Set enableSeasonalEvents: true to actually switch the events on.
|
# again. Set enableSeasonalEvents: true to actually switch the events on.
|
||||||
enableSeasonalEvents: false
|
enableSeasonalEvents: false
|
||||||
|
|
||||||
|
# Erasing this block, or any single key in it, falls back to the defaults below
|
||||||
|
# (Gitea issue #16) — with the documented exception of storage, see there.
|
||||||
|
# Before 7.0.1 "server: null" aborted the render, and a single erased key was
|
||||||
|
# the quieter half: a nulled gamePort rendered an empty port number into the
|
||||||
|
# Service and the StatefulSet, rejected by the API server at apply time and by
|
||||||
|
# nothing before it.
|
||||||
server:
|
server:
|
||||||
# Replica count for this workload; 0 is allowed and scales it down.
|
# Replica count for this workload; 0 is allowed and scales it down.
|
||||||
# The chart-global scaleDown flag takes precedence.
|
# The chart-global scaleDown flag takes precedence.
|
||||||
replicas: 1
|
replicas: 1
|
||||||
gamePort: 30777
|
gamePort: 30777
|
||||||
messagingPort: 30888
|
messagingPort: 30888
|
||||||
|
# The size of the data PVC, and the ONE key of this block that is NOT
|
||||||
|
# null-safe — deliberately, not by oversight. An erased storage (including
|
||||||
|
# "server: null") renders a PVC without a size, which fails at apply time
|
||||||
|
# instead of quietly sizing the volume from the chart. Whether that is still
|
||||||
|
# the right trade is negotiated in Gitea issue #39, not here.
|
||||||
storage: 20Gi
|
storage: 20Gi
|
||||||
resources:
|
resources:
|
||||||
# Limits are optional: memory and ephemeral-storage limits default to
|
# Limits are optional: memory and ephemeral-storage limits default to
|
||||||
|
|||||||
@@ -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/
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: sure
|
||||||
|
description: A Helm chart to deploy Sure (we-promise) on Kubernetes
|
||||||
|
icon: https://raw.githubusercontent.com/we-promise/sure/main/public/android-chrome-512x512.png
|
||||||
|
|
||||||
|
type: application
|
||||||
|
|
||||||
|
version: 1.0.0+up0.7.4
|
||||||
|
appVersion: "0.7.4"
|
||||||
|
|
||||||
|
maintainers:
|
||||||
|
- name: jklein
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
{{- $replicas := include "sure.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.sure.replicas) -}}
|
||||||
|
{{- $webStartup := fromYaml (include "sure.webStartupProbe" .Values.sure.web.startupProbe) -}}
|
||||||
|
{{- $workerStartup := fromYaml (include "sure.workerStartupProbe" .Values.sure.worker.startupProbe) -}}
|
||||||
|
{{- $workerLiveness := fromYaml (include "sure.workerLivenessProbe" .Values.sure.worker.livenessProbe) -}}
|
||||||
|
Sure {{ .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
|
||||||
|
|
||||||
|
The first account created on a fresh instance becomes the super admin — sign
|
||||||
|
in yourself before handing the URL to anyone.
|
||||||
|
{{- else }}
|
||||||
|
|
||||||
|
ACCESS — NO INGRESS
|
||||||
|
ingress.enabled is false, so this release has no Ingress and no certificate.
|
||||||
|
ingress.domain is still required: it becomes APP_DOMAIN (the host in every
|
||||||
|
mail link) and the WebAuthn relying party the app binds passkeys to. 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. With the Ingress off the chart also renders
|
||||||
|
RAILS_ASSUME_SSL and RAILS_FORCE_SSL as "false", which is what makes that
|
||||||
|
plain-HTTP session able to log in at all; mail links still point at
|
||||||
|
https://{{ .Values.ingress.domain }}/.
|
||||||
|
{{- end }}
|
||||||
|
{{ if eq $replicas "0" }}
|
||||||
|
SCALED TO ZERO
|
||||||
|
{{ if .Values.scaleDown }}scaleDown: true{{ else }}sure.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so neither the web nor
|
||||||
|
the worker process is running. PVC, Secrets, Service{{ if .Values.ingress.enabled }}, Ingress and the
|
||||||
|
certificate stay{{ else }} stay{{ end }} in place; set it back and upgrade to bring both containers up.
|
||||||
|
{{ end }}
|
||||||
|
WHAT THE THREE 1PASSWORD ITEMS MUST CONTAIN
|
||||||
|
The chart creates OnePasswordItem CRs; the operator turns each into a
|
||||||
|
Kubernetes Secret whose keys are the FIELD NAMES of the 1Password item. A
|
||||||
|
missing field does not fail the install — the release goes through and the
|
||||||
|
pod then hangs in CreateContainerConfigError naming the key it could not
|
||||||
|
find (kubectl -n {{ .Release.Namespace }} describe pod).
|
||||||
|
|
||||||
|
secrets.appSecretRef → {{ .Values.secrets.appSecretRef }}
|
||||||
|
Secret {{ .Release.Name }}-{{ .Chart.Name }}-secret, field:
|
||||||
|
secret-key-base SECRET_KEY_BASE, 64 random bytes hex
|
||||||
|
(openssl rand -hex 64)
|
||||||
|
|
||||||
|
secrets.dbSecretRef → {{ .Values.secrets.dbSecretRef }}
|
||||||
|
Secret {{ .Release.Name }}-{{ .Chart.Name }}-db-secret, fields:
|
||||||
|
host DB_HOST, the PostgreSQL hostname
|
||||||
|
port DB_PORT, usually 5432
|
||||||
|
database POSTGRES_DB
|
||||||
|
username POSTGRES_USER
|
||||||
|
password POSTGRES_PASSWORD
|
||||||
|
|
||||||
|
secrets.redisSecretRef → {{ .Values.secrets.redisSecretRef }}
|
||||||
|
Secret {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret, field:
|
||||||
|
connection-url REDIS_URL, e.g. redis://:password@host:6379/0
|
||||||
|
|
||||||
|
ROTATING SECRET_KEY_BASE DESTROYS MORE THAN SESSIONS
|
||||||
|
In self-hosted mode Sure derives its Active Record encryption keys from
|
||||||
|
SECRET_KEY_BASE (config/initializers/active_record_encryption.rb: three
|
||||||
|
SHA-256 digests over that one value) unless
|
||||||
|
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY and its two companions are set. Every
|
||||||
|
encrypted column in the database — provider tokens, OAuth credentials, MFA
|
||||||
|
secrets — is therefore readable only with the SECRET_KEY_BASE it was written
|
||||||
|
under. Replacing that value does not just log everyone out: it makes the
|
||||||
|
existing encrypted data undecryptable, and no rotation of the item brings it
|
||||||
|
back. Treat this field as permanent for the life of the database, or pin the
|
||||||
|
three encryption keys explicitly through sure.web.env / sure.worker.env
|
||||||
|
before the first start.
|
||||||
|
|
||||||
|
CHANGING A SECRET'S CONTENT RESTARTS NOTHING
|
||||||
|
Helm never sees the content of a OnePasswordItem, so it cannot hash it. The
|
||||||
|
pod template carries checksum/secret-ref annotations over the rendered
|
||||||
|
OnePasswordItems, so pointing a *SecretRef at a DIFFERENT item rolls the
|
||||||
|
pod — but editing a value INSIDE the same item does not: the operator
|
||||||
|
updates the Kubernetes Secret while both containers keep the old value in
|
||||||
|
their environment. After such a change:
|
||||||
|
|
||||||
|
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
|
||||||
|
The operator's auto-restart annotation would not help here and is
|
||||||
|
deliberately not set: it restarts Deployments only, and this is a
|
||||||
|
StatefulSet.
|
||||||
|
|
||||||
|
POSTGRES AND REDIS ARE NOT PART OF THIS CHART
|
||||||
|
Both must exist and be reachable before the first start. Redis is not a
|
||||||
|
cache the app can do without — it holds the Sidekiq queues, the Rails cache
|
||||||
|
store and the rate limiter.
|
||||||
|
|
||||||
|
What the probes do with that, per container:
|
||||||
|
web liveness and startup use /up, which is Rails' boot check and
|
||||||
|
touches no database — a Postgres outage therefore does not restart
|
||||||
|
the pod. Readiness uses "/", which runs a User.count query, so the
|
||||||
|
pod leaves the Service while the database is gone and returns on
|
||||||
|
its own afterwards.
|
||||||
|
worker all three probes run "sidekiqmon processes" and look for this
|
||||||
|
pod's own entry, i.e. a live Sidekiq heartbeat in Redis. There is
|
||||||
|
no HTTP and no dependency-free alternative: with Redis gone the
|
||||||
|
worker has nothing to do, and liveness only restarts after
|
||||||
|
{{ $workerLiveness.failureThreshold }} x {{ $workerLiveness.periodSeconds }}s = {{ mul $workerLiveness.failureThreshold $workerLiveness.periodSeconds }}s of missing heartbeat.
|
||||||
|
|
||||||
|
A pod that never turns Ready is usually a connection problem, not a chart
|
||||||
|
problem: read the container logs, not the pod events.
|
||||||
|
|
||||||
|
kubectl -n {{ .Release.Namespace }} logs statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs -c {{ .Release.Name }}-{{ .Chart.Name }}-web
|
||||||
|
|
||||||
|
THE FIRST START MIGRATES, AND THE STARTUP PROBE IS THE CLOCK
|
||||||
|
The image entrypoint runs "./bin/rails db:prepare" — but only when the
|
||||||
|
command is "./bin/rails server". That is the web container, and only the web
|
||||||
|
container: the worker runs "bundle exec sidekiq" and never migrates, so the
|
||||||
|
two can never race. Puma binds the port after the migration, which means the
|
||||||
|
whole schema creation happens inside the startup probe's budget:
|
||||||
|
|
||||||
|
web {{ $webStartup.failureThreshold }} x {{ $webStartup.periodSeconds }}s = {{ mul $webStartup.failureThreshold $webStartup.periodSeconds }}s
|
||||||
|
worker {{ $workerStartup.failureThreshold }} x {{ $workerStartup.periodSeconds }}s = {{ mul $workerStartup.failureThreshold $workerStartup.periodSeconds }}s (it waits for the schema the web container creates)
|
||||||
|
|
||||||
|
Raise sure.web.startupProbe.failureThreshold BEFORE a large app-version jump
|
||||||
|
or a first import, not after the crash loop has started — a restart lands in
|
||||||
|
the middle of a migration. Back up the database before any app-version bump:
|
||||||
|
migrations are one-way, and a helm rollback returns the old image to a
|
||||||
|
database the new one has already rewritten.
|
||||||
|
|
||||||
|
ONE POD, ONE VOLUME, TWO CONTAINERS
|
||||||
|
Uploads, imports and exports live on the ReadWriteOnce PVC
|
||||||
|
{{ .Release.Name }}-{{ .Chart.Name }}-pvc, mounted at /rails/storage in both containers. That is
|
||||||
|
the reason web and worker share a pod at all: a background job writes the
|
||||||
|
same files the web process serves, and one RWO volume cannot be attached to
|
||||||
|
two pods.
|
||||||
|
{{- if gt (int $replicas) 1 }}
|
||||||
|
|
||||||
|
sure.replicas is {{ $replicas }}. Expect the extra pods to stay Pending on
|
||||||
|
"Multi-Attach error for volume" unless the storage class is genuinely RWX.
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
Backup is two things at the same time: the database holds the accounts and
|
||||||
|
one row per attachment, the PVC holds the bytes. Restoring one without the
|
||||||
|
other gives an app full of broken references. Because the PVC is rendered by
|
||||||
|
the chart, helm uninstall deletes it — the external database survives that,
|
||||||
|
which makes an accidental uninstall look recoverable while the files are
|
||||||
|
already gone.
|
||||||
|
|
||||||
|
THE CHART CONFIGURES NO MAIL AND NO AI
|
||||||
|
Sure sends invitations and notifications over SMTP and can run an assistant
|
||||||
|
against an OpenAI-compatible endpoint. This chart sets neither: no SMTP_*
|
||||||
|
variable, no OPENAI_* variable. Mails are therefore quietly never sent, and
|
||||||
|
the AI features stay off — nothing in the UI says so.
|
||||||
|
|
||||||
|
Both are plain environment variables, so they go into sure.web.env and, for
|
||||||
|
anything a background job needs (the assistant runs in the worker), into
|
||||||
|
sure.worker.env as well. The secretKeyRef form is available for the API key
|
||||||
|
and the SMTP password. Note that upstream sizes AI usage at 1 GB of memory
|
||||||
|
per process; the chart's computed memory limit is 3x its request.
|
||||||
|
{{- if or .Values.sure.web.env .Values.sure.worker.env }}
|
||||||
|
|
||||||
|
Currently set: {{ len .Values.sure.web.env }} extra variable(s) on the web container,
|
||||||
|
{{ len .Values.sure.worker.env }} on the worker, rendered after the ones the chart sets itself.
|
||||||
|
An entry repeating a name the chart already sets wins at install time and is
|
||||||
|
silently lost on the next helm upgrade — read it back off the StatefulSet.
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
{{/*
|
||||||
|
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 copied into every chart's templates/_helpers.tpl with the
|
||||||
|
chart's name as define prefix rather than shared through a library chart; the
|
||||||
|
reference implementation is template-chart/templates/_helpers.tpl.
|
||||||
|
*/}}
|
||||||
|
{{- define "sure.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 "sure.resourceKey" $key) $value -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $limits := dict -}}
|
||||||
|
{{- range $key, $value := ($resources.limits | default dict) -}}
|
||||||
|
{{- if not (kindIs "invalid" $value) -}}
|
||||||
|
{{- $_ := set $limits (include "sure.resourceKey" $key) $value -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- range $key, $request := $requests -}}
|
||||||
|
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
||||||
|
{{- $_ := set $limits $key (include "sure.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 "sure.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 "sure.multiplyQuantity" -}}
|
||||||
|
{{- $quantity := include "sure.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 "sure.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 "sure.formatNumber" -}}
|
||||||
|
{{- if kindIs "float64" . -}}
|
||||||
|
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- printf "%v" . -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Render the replica count for the 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.
|
||||||
|
*/}}
|
||||||
|
{{- define "sure.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".
|
||||||
|
*/}}
|
||||||
|
{{- define "sure.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 "sure.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.sure.web.securityContext" can itself be erased, and indexing into nil
|
||||||
|
inside a template is not an error but silently yields nothing.
|
||||||
|
*/}}
|
||||||
|
{{- define "sure.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. Every
|
||||||
|
reason behind a value is written down there, including what each probe actually
|
||||||
|
proves - a probe default without its path, port name or exec command would
|
||||||
|
leave the workload with no working probe at all (README, guideline 12.5).
|
||||||
|
*/}}
|
||||||
|
{{- define "sure.podSecurityContext" -}}
|
||||||
|
{{- $given := (fromYaml (include "sure.subBlock" (dict "block" . "key" "pod" "path" "sure.securityContext"))).value -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"runAsNonRoot" true
|
||||||
|
"runAsUser" 1000
|
||||||
|
"runAsGroup" 1000
|
||||||
|
"fsGroup" 1000
|
||||||
|
"fsGroupChangePolicy" "Always"
|
||||||
|
"seccompProfile" (dict "type" "RuntimeDefault") -}}
|
||||||
|
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" $given "path" "sure.securityContext.pod") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sure.containerSecurityContext" -}}
|
||||||
|
{{- $given := (fromYaml (include "sure.subBlock" (dict "block" .block "key" "container" "path" .path))).value -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"allowPrivilegeEscalation" false
|
||||||
|
"readOnlyRootFilesystem" true
|
||||||
|
"runAsNonRoot" true
|
||||||
|
"capabilities" (dict "drop" (list "ALL")) -}}
|
||||||
|
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" $given "path" (printf "%s.container" .path)) -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Web container probes. /up is Rails' boot check and touches neither Postgres nor
|
||||||
|
Redis, so liveness and startup use it; readiness uses "/", which runs
|
||||||
|
User.count through authenticate_user! in self-hosted mode and therefore fails
|
||||||
|
while the database is unreachable. The long startup budget covers the
|
||||||
|
"./bin/rails db:prepare" the entrypoint runs before Puma binds the port.
|
||||||
|
*/}}
|
||||||
|
{{- define "sure.webLivenessProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"httpGet" (dict "path" "/up" "port" "http")
|
||||||
|
"periodSeconds" 10
|
||||||
|
"timeoutSeconds" 5
|
||||||
|
"failureThreshold" 3 -}}
|
||||||
|
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.livenessProbe") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sure.webReadinessCommand" -}}
|
||||||
|
{{- list "/bin/bash" "-c" "code=\"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3000/)\"; test \"$code\" -ge 200 -a \"$code\" -lt 400" | toJson -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sure.webReadinessProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"exec" (dict "command" (include "sure.webReadinessCommand" . | fromJsonArray))
|
||||||
|
"periodSeconds" 10
|
||||||
|
"timeoutSeconds" 10
|
||||||
|
"failureThreshold" 3 -}}
|
||||||
|
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.readinessProbe") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sure.webStartupProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"httpGet" (dict "path" "/up" "port" "http")
|
||||||
|
"periodSeconds" 10
|
||||||
|
"timeoutSeconds" 5
|
||||||
|
"failureThreshold" 60 -}}
|
||||||
|
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.startupProbe") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Worker container probes. Sidekiq serves no HTTP, so all three ask its own
|
||||||
|
monitor whether THIS process still holds a heartbeat in Redis; the exec command
|
||||||
|
is part of the default, not decoration (README, guideline 12.5).
|
||||||
|
*/}}
|
||||||
|
{{- define "sure.workerProbeCommand" -}}
|
||||||
|
{{- list "/bin/bash" "-c" "bundle exec sidekiqmon processes | grep -q \"^${HOSTNAME}:\"" | toJson -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sure.workerLivenessProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray))
|
||||||
|
"periodSeconds" 60
|
||||||
|
"timeoutSeconds" 15
|
||||||
|
"failureThreshold" 5 -}}
|
||||||
|
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.livenessProbe") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sure.workerReadinessProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray))
|
||||||
|
"periodSeconds" 30
|
||||||
|
"timeoutSeconds" 15
|
||||||
|
"failureThreshold" 3 -}}
|
||||||
|
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.readinessProbe") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sure.workerStartupProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray))
|
||||||
|
"periodSeconds" 20
|
||||||
|
"timeoutSeconds" 15
|
||||||
|
"failureThreshold" 30 -}}
|
||||||
|
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.startupProbe") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
The effective resources blocks, 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. Both containers run the
|
||||||
|
same image and carry the same request; see values.yaml for where the number
|
||||||
|
comes from.
|
||||||
|
*/}}
|
||||||
|
{{- define "sure.webResources" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"limitFactor" 3
|
||||||
|
"requests" (dict "cpu" 0.1 "memory" "512Mi" "ephemeral-storage" "256Mi") -}}
|
||||||
|
{{- $merged := fromYaml (include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.resources")) -}}
|
||||||
|
{{- include "sure.resources" $merged -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sure.workerResources" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"limitFactor" 3
|
||||||
|
"requests" (dict "cpu" 0.1 "memory" "512Mi" "ephemeral-storage" "256Mi") -}}
|
||||||
|
{{- $merged := fromYaml (include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.resources")) -}}
|
||||||
|
{{- include "sure.resources" $merged -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
The environment both containers share: the same image, the same database, the
|
||||||
|
same Redis, the same secrets. Only the command differs.
|
||||||
|
|
||||||
|
Input: the root context.
|
||||||
|
|
||||||
|
ingress.domain is read HERE, outside the Ingress template, three times over
|
||||||
|
(APP_DOMAIN, WEBAUTHN_RP_ID, WEBAUTHN_ALLOWED_ORIGINS), so it carries its own
|
||||||
|
required() and stays mandatory when ingress.enabled is false - README,
|
||||||
|
guideline 12.1. An empty value would not fail the render: it would register
|
||||||
|
passkeys against an empty relying party and build mail links without a host.
|
||||||
|
|
||||||
|
SELF_HOSTED=true is not optional for this chart: it is what turns Sure into the
|
||||||
|
single-family, no-subscription mode, and it is also what makes the readiness
|
||||||
|
probe on "/" reach the database at all (see values.yaml).
|
||||||
|
*/}}
|
||||||
|
{{- define "sure.commonEnv" -}}
|
||||||
|
{{- $domain := required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain -}}
|
||||||
|
- name: SELF_HOSTED
|
||||||
|
value: "true"
|
||||||
|
- name: RAILS_ENV
|
||||||
|
value: "production"
|
||||||
|
# Not cosmetic and not optional: /tmp is an emptyDir, which Kubernetes creates
|
||||||
|
# mode 0777 without a sticky bit, and Ruby skips exactly that shape when it
|
||||||
|
# looks for a temporary directory. Without this the app aborts during boot with
|
||||||
|
# "could not find a temporary directory". The directory is created by the
|
||||||
|
# init container in the StatefulSet, which explains the whole case.
|
||||||
|
- name: TMPDIR
|
||||||
|
value: "/tmp/ruby"
|
||||||
|
# The image's home directory (/home/rails) is part of the read-only root
|
||||||
|
# filesystem, and Bundler reacts to that by creating a throwaway home under
|
||||||
|
# TMPDIR on EVERY invocation ("`/home/rails` is not writable. Bundler will use
|
||||||
|
# `/tmp/ruby/bundler2026...` as your home directory temporarily", measured in
|
||||||
|
# the install-test). That is one directory per `bundle exec` - including every
|
||||||
|
# worker probe, every 30 seconds. Pointing HOME at a writable directory turns
|
||||||
|
# that into one stable path.
|
||||||
|
- name: HOME
|
||||||
|
value: "/tmp/home"
|
||||||
|
- name: PORT
|
||||||
|
value: "3000"
|
||||||
|
# Sure's production.rb defaults BOTH of these to true. The chart renders them
|
||||||
|
# explicitly from ingress.enabled instead: with the Ingress terminating TLS,
|
||||||
|
# assume_ssl makes Rails treat the forwarded request as secure (so force_ssl
|
||||||
|
# adds HSTS and secure cookies without redirecting), while without an Ingress
|
||||||
|
# both have to be off - a port-forwarded plain-HTTP session would otherwise
|
||||||
|
# either be redirected to https or hand out cookies the browser never returns.
|
||||||
|
- name: RAILS_ASSUME_SSL
|
||||||
|
value: "{{ .Values.ingress.enabled }}"
|
||||||
|
- name: RAILS_FORCE_SSL
|
||||||
|
value: "{{ .Values.ingress.enabled }}"
|
||||||
|
# Action Mailer builds absolute URLs from this host, and it is the fallback the
|
||||||
|
# WebAuthn initializer uses when WEBAUTHN_RP_ID is unset.
|
||||||
|
- name: APP_DOMAIN
|
||||||
|
value: {{ $domain | quote }}
|
||||||
|
# Passkeys are bound to the relying party id; changing it later invalidates
|
||||||
|
# every registered credential (upstream docs/hosting/webauthn.md).
|
||||||
|
- name: WEBAUTHN_RP_ID
|
||||||
|
value: {{ $domain | quote }}
|
||||||
|
- name: WEBAUTHN_ALLOWED_ORIGINS
|
||||||
|
value: {{ printf "https://%s" $domain | quote }}
|
||||||
|
- name: SECRET_KEY_BASE
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||||
|
key: secret-key-base
|
||||||
|
- name: DB_HOST
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||||
|
key: host
|
||||||
|
- name: DB_PORT
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||||
|
key: port
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||||
|
key: database
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||||
|
key: username
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||||
|
key: password
|
||||||
|
- name: REDIS_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret
|
||||||
|
key: connection-url
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
The additive env block of one container (README, guideline 10).
|
||||||
|
|
||||||
|
Input: dict "root" <root context> "env" <the container's env list>.
|
||||||
|
Entries are rendered through tpl, so a value may contain Helm templating, and
|
||||||
|
the value, secretKeyRef and configMapKeyRef forms are supported.
|
||||||
|
*/}}
|
||||||
|
{{- define "sure.env" -}}
|
||||||
|
{{- $root := .root -}}
|
||||||
|
{{- range $value := .env }}
|
||||||
|
{{- if $value.value }}
|
||||||
|
- name: {{ $value.name | quote }}
|
||||||
|
# quote is required, not cosmetic: a value like "3" 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".
|
||||||
|
value: {{ tpl $value.value $root | quote }}
|
||||||
|
{{- else if $value.valueFrom }}
|
||||||
|
{{- if $value.valueFrom.secretKeyRef }}
|
||||||
|
- name: {{ $value.name }}
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }}
|
||||||
|
key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }}
|
||||||
|
{{- else if $value.valueFrom.configMapKeyRef }}
|
||||||
|
- name: {{ $value.name }}
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }}
|
||||||
|
key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: onepassword.com/v1
|
||||||
|
kind: OnePasswordItem
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||||
|
spec:
|
||||||
|
itemPath: {{ required "A Secret-Reference for the database must be provided (secrets.dbSecretRef)" .Values.secrets.dbSecretRef }}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: onepassword.com/v1
|
||||||
|
kind: OnePasswordItem
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret
|
||||||
|
spec:
|
||||||
|
itemPath: {{ required "A Secret-Reference for Redis must be provided (secrets.redisSecretRef)" .Values.secrets.redisSecretRef }}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{- 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" .Values.ingress.clusterIssuer }}
|
||||||
|
{{- 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 }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.storage.amount }}
|
||||||
|
{{- if .Values.storage.storageClass }}
|
||||||
|
storageClassName: {{ .Values.storage.storageClass }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: onepassword.com/v1
|
||||||
|
kind: OnePasswordItem
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||||
|
spec:
|
||||||
|
itemPath: {{ required "A Secret-Reference for the app secret must be provided (secrets.appSecretRef)" .Values.secrets.appSecretRef }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 80
|
||||||
|
targetPort: http
|
||||||
|
name: http
|
||||||
|
type: ClusterIP
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
labels:
|
||||||
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
spec:
|
||||||
|
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||||
|
replicas: {{ include "sure.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.sure.replicas) }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
labels:
|
||||||
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
annotations:
|
||||||
|
# The chart has no ConfigMap, so there is no checksum/config here. What
|
||||||
|
# these annotations cover is the SOURCE of the secrets, not their
|
||||||
|
# content: the Kubernetes Secrets are materialized by the 1Password
|
||||||
|
# operator, so Helm never sees what is in them. Repointing one of the
|
||||||
|
# secrets.*SecretRef values at a different item therefore changes the
|
||||||
|
# pod template and rolls the pod, instead of silently leaving the old
|
||||||
|
# credentials in the running container. A change of an item's CONTENT
|
||||||
|
# inside the vault is not covered - see the comment on secrets in
|
||||||
|
# values.yaml for why that gap cannot be closed here.
|
||||||
|
# The name differs from checksum/config on purpose - that one promises
|
||||||
|
# "the config changed", this one only "the config source changed".
|
||||||
|
checksum/secret-ref: {{ include (print $.Template.BasePath "/sure.secret.yaml") . | sha256sum }}
|
||||||
|
checksum/secret-ref-db: {{ include (print $.Template.BasePath "/sure-db.secret.yaml") . | sha256sum }}
|
||||||
|
checksum/secret-ref-redis: {{ include (print $.Template.BasePath "/sure-redis.secret.yaml") . | sha256sum }}
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
{{- include "sure.podSecurityContext" .Values.sure.securityContext | nindent 8 }}
|
||||||
|
initContainers:
|
||||||
|
# Ruby refuses a world-writable directory that has no sticky bit as its
|
||||||
|
# temporary directory (ruby 3.4, tmpdir.rb: a candidate is skipped when
|
||||||
|
# "world_writable? && !sticky?"), and a Kubernetes emptyDir is created
|
||||||
|
# mode 0777 WITHOUT the sticky bit. With /tmp mounted as an emptyDir and
|
||||||
|
# the image's root filesystem read-only, Ruby then finds no candidate at
|
||||||
|
# all and both containers die during boot - measured in this chart's
|
||||||
|
# first install-test, which logged
|
||||||
|
#
|
||||||
|
# system temporary path is world-writable: /tmp
|
||||||
|
# /tmp is world-writable: /tmp
|
||||||
|
# . is not writable: /rails
|
||||||
|
# ArgumentError: could not find a temporary directory
|
||||||
|
#
|
||||||
|
# raised from MiniMagick, which Active Storage loads while Rails boots.
|
||||||
|
# Note where it does NOT come from: nothing in the app asked for a temp
|
||||||
|
# file yet.
|
||||||
|
#
|
||||||
|
# The fix is a directory that is writable without being world-writable.
|
||||||
|
# This init container creates two of them per app container inside the
|
||||||
|
# same emptyDirs (mode 0700, owned by the pod's uid): "ruby" for TMPDIR,
|
||||||
|
# and "home" for HOME - the image's /home/rails is read-only, and
|
||||||
|
# Bundler answers that by creating a throwaway home under TMPDIR on
|
||||||
|
# every single invocation. It runs as the same non-root uid as the app
|
||||||
|
# and needs no capability at all: the emptyDir root is 0777, so creating an
|
||||||
|
# entry in it is allowed, and chmod on a directory one has just created
|
||||||
|
# needs no privilege. That is deliberately weaker than the chown-as-root
|
||||||
|
# init container paperless-ngx needs.
|
||||||
|
#
|
||||||
|
# The image is the app's own, so there is no second image to pin, no
|
||||||
|
# Renovate block to maintain for it and nothing extra to pull.
|
||||||
|
- name: {{ .Release.Name }}-{{ .Chart.Name }}-init-tmp
|
||||||
|
image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}"
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
# command, not args: the image entrypoint would start the application.
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
for dir in /web-tmp/ruby /web-tmp/home /worker-tmp/ruby /worker-tmp/home; do
|
||||||
|
mkdir -p "$dir"
|
||||||
|
chmod 700 "$dir"
|
||||||
|
done
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsNonRoot: true
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
volumeMounts:
|
||||||
|
- name: web-tmp
|
||||||
|
mountPath: /web-tmp
|
||||||
|
- name: worker-tmp
|
||||||
|
mountPath: /worker-tmp
|
||||||
|
# Fixed, deliberately small: this container runs two mkdirs and two
|
||||||
|
# chmods. Not values-configurable, for the same reason as the init
|
||||||
|
# container in paperless-ngx - there is nothing here to tune.
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 32Mi
|
||||||
|
ephemeral-storage: 1Mi
|
||||||
|
limits:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 64Mi
|
||||||
|
ephemeral-storage: 10Mi
|
||||||
|
containers:
|
||||||
|
# The web process. Its command is the image's default
|
||||||
|
# (CMD ["./bin/rails", "server"]), which is exactly what makes the
|
||||||
|
# entrypoint run "./bin/rails db:prepare" first - the migration only
|
||||||
|
# happens for that command, so it happens here and nowhere else.
|
||||||
|
- name: {{ .Release.Name }}-{{ .Chart.Name }}-web
|
||||||
|
image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}"
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
{{- include "sure.commonEnv" . | nindent 12 }}
|
||||||
|
{{- include "sure.env" (dict "root" $ "env" .Values.sure.web.env) | nindent 12 }}
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
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 "sure.webLivenessProbe" .Values.sure.web.livenessProbe | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- include "sure.webReadinessProbe" .Values.sure.web.readinessProbe | nindent 12 }}
|
||||||
|
startupProbe:
|
||||||
|
{{- include "sure.webStartupProbe" .Values.sure.web.startupProbe | nindent 12 }}
|
||||||
|
resources:
|
||||||
|
{{- include "sure.webResources" .Values.sure.web.resources | nindent 12 }}
|
||||||
|
securityContext:
|
||||||
|
{{- include "sure.containerSecurityContext" (dict "block" .Values.sure.web.securityContext "path" "sure.web.securityContext") | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
# Active Storage's local disk service, i.e. every uploaded file and
|
||||||
|
# import, lives here (config/storage.yml: Rails.root.join("storage")).
|
||||||
|
- name: storage
|
||||||
|
mountPath: /rails/storage
|
||||||
|
# Writable paths, so readOnlyRootFilesystem: true can stay on.
|
||||||
|
# Each container gets its own emptyDirs: nothing has to be shared
|
||||||
|
# between the two processes, and a shared scratch directory would
|
||||||
|
# only create coupling nobody asked for.
|
||||||
|
#
|
||||||
|
# /tmp: Rack writes multipart uploads (CSV imports, attachments)
|
||||||
|
# here before Active Storage ever sees them. The app does not use
|
||||||
|
# this path directly, though - TMPDIR points at the private
|
||||||
|
# subdirectory the init container creates inside it, because Ruby
|
||||||
|
# rejects a world-writable emptyDir as a temporary directory.
|
||||||
|
- name: web-tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
# /rails/tmp: Rails' own scratch directory - the Bootsnap cache,
|
||||||
|
# Puma's pidfile when PIDFILE is set, and the combined CA bundle the
|
||||||
|
# 00_ssl initializer writes when SSL_CA_FILE is given. Note the
|
||||||
|
# trade-off, read off the Dockerfile: the image ships a Bootsnap
|
||||||
|
# cache precompiled at build time under this path, and mounting an
|
||||||
|
# emptyDir over it hides that cache, so every pod start recompiles
|
||||||
|
# it. Boot time cost, not correctness; the alternative is a
|
||||||
|
# writable image layer, which the hardening standard rules out.
|
||||||
|
- name: web-rails-tmp
|
||||||
|
mountPath: /rails/tmp
|
||||||
|
# /rails/log: production logs go to STDOUT (config/environments/
|
||||||
|
# production.rb), so under normal operation nothing is written here
|
||||||
|
# at all. It stays writable because Rails' default logger path is
|
||||||
|
# log/<env>.log and any rake or console path that falls back to it
|
||||||
|
# would otherwise hit a read-only filesystem. Not measured.
|
||||||
|
- name: web-rails-log
|
||||||
|
mountPath: /rails/log
|
||||||
|
# The Sidekiq process. Same image, different command - and because that
|
||||||
|
# command is NOT "./bin/rails server", the image entrypoint skips
|
||||||
|
# db:prepare here, so the two containers never migrate against each
|
||||||
|
# other.
|
||||||
|
#
|
||||||
|
# args, NOT command, and that distinction is load-bearing: Kubernetes'
|
||||||
|
# "command" replaces the image's ENTRYPOINT, which here is
|
||||||
|
# /rails/bin/docker-entrypoint - the script that preloads jemalloc.
|
||||||
|
# Upstream counts jemalloc among the three things that make the
|
||||||
|
# documented memory figures hold, so replacing the entrypoint would
|
||||||
|
# silently give this container a different memory profile than the one
|
||||||
|
# its requests are sized for. "args" replaces only the CMD, which is
|
||||||
|
# what upstream's compose file does too.
|
||||||
|
- name: {{ .Release.Name }}-{{ .Chart.Name }}-worker
|
||||||
|
image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}"
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
args:
|
||||||
|
- bundle
|
||||||
|
- exec
|
||||||
|
- sidekiq
|
||||||
|
env:
|
||||||
|
{{- include "sure.commonEnv" . | nindent 12 }}
|
||||||
|
{{- include "sure.env" (dict "root" $ "env" .Values.sure.worker.env) | nindent 12 }}
|
||||||
|
livenessProbe:
|
||||||
|
{{- include "sure.workerLivenessProbe" .Values.sure.worker.livenessProbe | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- include "sure.workerReadinessProbe" .Values.sure.worker.readinessProbe | nindent 12 }}
|
||||||
|
startupProbe:
|
||||||
|
{{- include "sure.workerStartupProbe" .Values.sure.worker.startupProbe | nindent 12 }}
|
||||||
|
resources:
|
||||||
|
{{- include "sure.workerResources" .Values.sure.worker.resources | nindent 12 }}
|
||||||
|
securityContext:
|
||||||
|
{{- include "sure.containerSecurityContext" (dict "block" .Values.sure.worker.securityContext "path" "sure.worker.securityContext") | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
# The worker reads and writes the same Active Storage files as the
|
||||||
|
# web process (imports are processed as background jobs), which is
|
||||||
|
# why upstream's compose file mounts one volume into both and why
|
||||||
|
# both containers live in one pod here: a single ReadWriteOnce PVC
|
||||||
|
# cannot be attached to two pods.
|
||||||
|
- name: storage
|
||||||
|
mountPath: /rails/storage
|
||||||
|
- name: worker-tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
- name: worker-rails-tmp
|
||||||
|
mountPath: /rails/tmp
|
||||||
|
- name: worker-rails-log
|
||||||
|
mountPath: /rails/log
|
||||||
|
restartPolicy: Always
|
||||||
|
volumes:
|
||||||
|
- name: storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||||
|
- name: web-tmp
|
||||||
|
emptyDir: {}
|
||||||
|
- name: web-rails-tmp
|
||||||
|
emptyDir: {}
|
||||||
|
- name: web-rails-log
|
||||||
|
emptyDir: {}
|
||||||
|
- name: worker-tmp
|
||||||
|
emptyDir: {}
|
||||||
|
- name: worker-rails-tmp
|
||||||
|
emptyDir: {}
|
||||||
|
- name: worker-rails-log
|
||||||
|
emptyDir: {}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "sure values",
|
||||||
|
"description": "Strict values schema (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. The objects that go verbatim into the Kubernetes pod spec (probes, security contexts, global, free-form annotations) stay open, because the chart does not interpret their contents. The hardening, probe and resources blocks additionally accept null - the chart's null-safe helpers then fall back to ITS defaults (issue #99). The schema adds to required(), it does not replace it.",
|
||||||
|
"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": {
|
||||||
|
"appSecretRef": {
|
||||||
|
"description": "op:// item path of the app secret. Field: secret-key-base (SECRET_KEY_BASE).",
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"dbSecretRef": {
|
||||||
|
"description": "op:// item path of the PostgreSQL credentials. Fields: host, port, database, username, password.",
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"redisSecretRef": {
|
||||||
|
"description": "op:// item path of the Redis connection. Field: connection-url (REDIS_URL).",
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sure": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"replicas": {
|
||||||
|
"description": "Replicas of the StatefulSet; both containers scale with it, because they share one ReadWriteOnce PVC.",
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"securityContext": {
|
||||||
|
"description": "Pod-level hardening, shared by both containers. May be null / empty: the chart then falls back to ITS defaults (uid/gid 1000, matching the image's USER 1000:1000) instead of rendering an unhardened pod.",
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"securityContext": {
|
||||||
|
"$ref": "#/definitions/containerSecurityContext"
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"$ref": "#/definitions/resources"
|
||||||
|
},
|
||||||
|
"livenessProbe": {
|
||||||
|
"$ref": "#/definitions/probe"
|
||||||
|
},
|
||||||
|
"readinessProbe": {
|
||||||
|
"$ref": "#/definitions/probe"
|
||||||
|
},
|
||||||
|
"startupProbe": {
|
||||||
|
"$ref": "#/definitions/probe"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"$ref": "#/definitions/env"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"securityContext": {
|
||||||
|
"$ref": "#/definitions/containerSecurityContext"
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"$ref": "#/definitions/resources"
|
||||||
|
},
|
||||||
|
"livenessProbe": {
|
||||||
|
"$ref": "#/definitions/probe"
|
||||||
|
},
|
||||||
|
"readinessProbe": {
|
||||||
|
"$ref": "#/definitions/probe"
|
||||||
|
},
|
||||||
|
"startupProbe": {
|
||||||
|
"$ref": "#/definitions/probe"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"$ref": "#/definitions/env"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"description": "The Active Storage PVC, mounted at /rails/storage in both containers. Deliberately NOT null-safe: an erased block yields an invalid PVC, which fails loudly.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"amount": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"storageClass": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ingress": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"description": "Gates the Ingress template (issue #93) and decides whether the chart renders RAILS_ASSUME_SSL/RAILS_FORCE_SSL as true or false. It does NOT free ingress.domain: that value is also read for APP_DOMAIN, WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS and keeps its own required() there (guideline 12.1).",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"containerSecurityContext": {
|
||||||
|
"description": "May be null / empty: the chart then falls back to its container hardening defaults (issue #99) instead of rendering an unhardened container. The pod-level context lives one level up, under sure.securityContext.pod, because it is shared by both containers.",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"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 path for the web container, the exec command for the worker - not just its timings (guideline 12.5).",
|
||||||
|
"type": ["object", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# 1Password item paths (op://<vault>/<item>). The 1Password operator turns each
|
||||||
|
# OnePasswordItem into a Kubernetes Secret whose keys are the FIELD NAMES of the
|
||||||
|
# item; the chart references those field names, so a renamed field is a pod in
|
||||||
|
# CreateContainerConfigError, not a render error. NOTES.txt lists every field.
|
||||||
|
#
|
||||||
|
# Helm never sees the CONTENT of these secrets, so no checksum/config-style
|
||||||
|
# annotation can cover them (README, guideline 9). The pod template does carry a
|
||||||
|
# checksum/secret-ref over the rendered OnePasswordItems, so REPOINTING one of
|
||||||
|
# the *SecretRef values at a different item restarts the pod; editing a value
|
||||||
|
# INSIDE the same item does not, and needs a manual
|
||||||
|
# kubectl rollout restart statefulset/<release>-sure-sfs
|
||||||
|
# The operator's operator.1password.io/auto-restart annotation is deliberately
|
||||||
|
# NOT set: it only ever restarts Deployments, so on this StatefulSet it would
|
||||||
|
# advertise a guarantee that does not exist (README, guideline 9).
|
||||||
|
secrets:
|
||||||
|
# SECRET_KEY_BASE (field: secret-key-base)
|
||||||
|
appSecretRef: null
|
||||||
|
# PostgreSQL connection (fields: host, port, database, username, password)
|
||||||
|
dbSecretRef: null
|
||||||
|
# Redis connection URL (field: connection-url)
|
||||||
|
redisSecretRef: null
|
||||||
|
|
||||||
|
sure:
|
||||||
|
# Replica count for the StatefulSet; 0 is allowed and scales it down. The
|
||||||
|
# chart-global scaleDown flag takes precedence.
|
||||||
|
#
|
||||||
|
# Both containers live in the SAME pod, so this value scales web and worker
|
||||||
|
# together. That is a consequence of the single ReadWriteOnce PVC they share
|
||||||
|
# at /rails/storage (Active Storage): splitting them into two workloads would
|
||||||
|
# need a ReadWriteMany volume. Above 1 the extra pods normally stay Pending on
|
||||||
|
# "Multi-Attach error for volume" - see NOTES.txt.
|
||||||
|
replicas: 1
|
||||||
|
# Pod-level hardening, shared by both containers and by the init container.
|
||||||
|
# The upstream image declares USER 1000:1000 (group and user "rails", created
|
||||||
|
# in the Dockerfile), so the chart needs no deviation from the repo hardening
|
||||||
|
# standard; fsGroup 1000 makes the storage PVC writable for that uid.
|
||||||
|
#
|
||||||
|
# readOnlyRootFilesystem: true holds, and the writable paths are emptyDirs:
|
||||||
|
# /rails/tmp, /rails/log and /tmp, one set per container. /tmp needs one extra
|
||||||
|
# step that is easy to mistake for decoration - a tiny init container creates
|
||||||
|
# two private subdirectories in it, and the containers run with TMPDIR and
|
||||||
|
# HOME pointing there. Ruby refuses a world-writable directory without a
|
||||||
|
# sticky bit as its temporary directory and an emptyDir is exactly that, so
|
||||||
|
# without this the app aborts during boot; HOME follows because the image's
|
||||||
|
# /home/rails is read-only and Bundler otherwise builds a throwaway home per
|
||||||
|
# invocation. The StatefulSet carries the measured evidence for both.
|
||||||
|
securityContext:
|
||||||
|
pod:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
fsGroup: 1000
|
||||||
|
fsGroupChangePolicy: Always
|
||||||
|
# RuntimeDefault applies the container runtime's default seccomp filter,
|
||||||
|
# which blocks the syscalls a normal workload never makes. It sits in the
|
||||||
|
# pod context on purpose: from here it covers every container in the pod
|
||||||
|
# - web and worker alike - 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
|
||||||
|
# <container>.securityContext.container, which wins over this value.
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# web - Puma, the container that also runs the database migrations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
web:
|
||||||
|
securityContext:
|
||||||
|
container:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsNonRoot: true
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
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".
|
||||||
|
#
|
||||||
|
# The request follows upstream's own sizing note (docs/hosting/docker.md
|
||||||
|
# for 0.7.4): steady-state memory "around 352 MB", comfortable under a
|
||||||
|
# 512 MB limit, while "if you enable AI, run at least 1 GB". 512Mi request
|
||||||
|
# x limitFactor 3 = 1536Mi limit, which holds both the everyday figure and
|
||||||
|
# the AI recommendation without reserving the larger number on the node.
|
||||||
|
# Read from upstream's documentation, not measured here.
|
||||||
|
#
|
||||||
|
# ephemeral-storage is NOT the template default of 10Mi, and the reason is
|
||||||
|
# measured: with 10Mi (limit 30Mi) per container the kubelet evicted the
|
||||||
|
# pod within two minutes of every start - "Pod ephemeral local storage
|
||||||
|
# usage exceeds the total limit of containers 60Mi". Both emptyDirs and
|
||||||
|
# the container's stdout log count against that limit, and this image
|
||||||
|
# rebuilds its Bootsnap cache under /rails/tmp on every pod start because
|
||||||
|
# the emptyDir hides the one baked into the image. 256Mi request (768Mi
|
||||||
|
# limit) is headroom for that, not a measurement of it - the steady-state
|
||||||
|
# figure is still unknown.
|
||||||
|
limitFactor: 3
|
||||||
|
requests:
|
||||||
|
cpu: 0.1
|
||||||
|
memory: 512Mi
|
||||||
|
ephemeral-storage: 256Mi
|
||||||
|
# PROBES - what each one actually proves (README, guideline 12.3)
|
||||||
|
#
|
||||||
|
# /up is Rails' own health route (config/routes.rb: get "up" =>
|
||||||
|
# "rails/health#show"). READ FROM THE RAILS SOURCE, NOT MEASURED HERE: that
|
||||||
|
# controller renders 200 as soon as the app has booted without raising and
|
||||||
|
# touches NO database and NO Redis. It is a boot check, and the widespread
|
||||||
|
# belief that it tests the database is exactly what guideline 12.3 exists to
|
||||||
|
# catch - upstream's own Helm chart (charts/sure in the app repository) uses
|
||||||
|
# /up for readiness, which is the mistake this chart does not copy.
|
||||||
|
#
|
||||||
|
# Liveness therefore uses /up: a Postgres outage must not restart the pod.
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /up
|
||||||
|
port: http
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
# Readiness asks for "/" instead, because that path provably reaches the
|
||||||
|
# database - and it does so through curl rather than an httpGet probe, for
|
||||||
|
# a reason that was measured rather than guessed (see below).
|
||||||
|
#
|
||||||
|
# Why "/" reaches the database: every request runs ApplicationController's
|
||||||
|
# authenticate_user! before_action (app/controllers/concerns/
|
||||||
|
# authentication.rb); without a session cookie - which a probe never has -
|
||||||
|
# it falls into self_hosted_first_login?, i.e.
|
||||||
|
# "config.app_mode.self_hosted? && User.count.zero?". User.count is an SQL
|
||||||
|
# query. Measured in the install-test: against an empty database the app
|
||||||
|
# answered 302 to /registration/new, which is the branch that query decides,
|
||||||
|
# so the path demonstrably touches Postgres. With the database down the
|
||||||
|
# query raises and Rails answers 500.
|
||||||
|
#
|
||||||
|
# Why exec + curl and not httpGet: the kubelet's HTTP prober FOLLOWS
|
||||||
|
# redirects to the same host, and with RAILS_ASSUME_SSL=true (which the
|
||||||
|
# chart sets whenever the Ingress is on) Rails builds that redirect as
|
||||||
|
# https://<pod-ip>:3000/... - a scheme Puma does not speak. The install-test
|
||||||
|
# failed with "http: server gave HTTP response to HTTPS client" on a pod
|
||||||
|
# that was answering correctly. curl without -L asks once and reports the
|
||||||
|
# status code; the test accepts 2xx and 3xx exactly as the kubelet would,
|
||||||
|
# and a 500 from a missing database still fails. curl is part of the image
|
||||||
|
# (the Dockerfile installs it), so nothing extra is pulled in for this.
|
||||||
|
#
|
||||||
|
# One condition this depends on: SELF_HOSTED=true, which the chart always
|
||||||
|
# sets. In managed mode the same code short-circuits before User.count and
|
||||||
|
# "/" would redirect without touching Postgres at all.
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- "code=\"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3000/)\"; test \"$code\" -ge 200 -a \"$code\" -lt 400"
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 10
|
||||||
|
failureThreshold: 3
|
||||||
|
# The startup probe gates liveness and readiness, so neither needs an
|
||||||
|
# initialDelaySeconds. Its budget has to cover the first start, which is the
|
||||||
|
# expensive one: the image entrypoint (/rails/bin/docker-entrypoint) runs
|
||||||
|
# "./bin/rails db:prepare" whenever the command is "./bin/rails server", so
|
||||||
|
# the web container creates and migrates the schema before Puma binds the
|
||||||
|
# port. Budget 10s x 60 = 600s. Raise it before a large app-version jump;
|
||||||
|
# a restart in the middle of a migration is the failure mode it prevents.
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /up
|
||||||
|
port: http
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 60
|
||||||
|
# Extra environment variables, appended to the ones the chart always sets.
|
||||||
|
# Values are rendered through tpl; value, secretKeyRef and configMapKeyRef
|
||||||
|
# forms are supported.
|
||||||
|
#
|
||||||
|
# This is also where the AI features go - the chart models none of them as
|
||||||
|
# values on purpose. OPENAI_ACCESS_TOKEN (secretKeyRef form), OPENAI_MODEL,
|
||||||
|
# OPENAI_URI_BASE, LLM_CONTEXT_WINDOW, OPENAI_REQUEST_TIMEOUT,
|
||||||
|
# AI_RESPONSE_TIMEOUT, ASSISTANT_MAX_TOOL_CALL_ITERATIONS and the SMTP_*
|
||||||
|
# block are plain environment variables to this image. Set them on BOTH
|
||||||
|
# containers where a background job needs them as well (the assistant runs
|
||||||
|
# in the worker).
|
||||||
|
#
|
||||||
|
# Additive means additive: an entry repeating a name the chart already sets
|
||||||
|
# reaches the pod twice at install time (the later one, yours, wins) but is
|
||||||
|
# LOST on a later helm upgrade, because Kubernetes merges env by name and
|
||||||
|
# the pair collapses to the chart's own value - with a green upgrade either
|
||||||
|
# way (README, guideline 10).
|
||||||
|
env: []
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# worker - Sidekiq: the same image, started with "bundle exec sidekiq" as
|
||||||
|
# its args (the image entrypoint stays in place; see the StatefulSet)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
worker:
|
||||||
|
securityContext:
|
||||||
|
container:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsNonRoot: true
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
resources:
|
||||||
|
# Same sizing as the web container, for a documented reason: upstream
|
||||||
|
# notes that the sample/demo-data generator runs in the WORKER and is the
|
||||||
|
# one operation that deterministically exceeds 512 MB (observed flat at
|
||||||
|
# ~680 MB on a 2 GB container). The computed limit of 3 x 512Mi = 1536Mi
|
||||||
|
# covers that; the request stays at the everyday figure.
|
||||||
|
#
|
||||||
|
# ephemeral-storage: same 256Mi as the web container and for the same
|
||||||
|
# measured reason - see the comment there.
|
||||||
|
limitFactor: 3
|
||||||
|
requests:
|
||||||
|
cpu: 0.1
|
||||||
|
memory: 512Mi
|
||||||
|
ephemeral-storage: 256Mi
|
||||||
|
# PROBES - the worker speaks no HTTP at all, so all three are exec probes.
|
||||||
|
#
|
||||||
|
# The command asks Sidekiq's own monitor whether THIS process is in the
|
||||||
|
# process set: sidekiqmon prints one line per registered process, each
|
||||||
|
# starting with the identity "<hostname>:<pid>:<nonce>" (sidekiq 8.0.5,
|
||||||
|
# lib/sidekiq/component.rb), and in a pod the hostname is the pod name that
|
||||||
|
# bash exposes as $HOSTNAME. Sidekiq keeps that registration alive with a
|
||||||
|
# heartbeat whose Redis key expires after about 60s, so a process that hangs
|
||||||
|
# disappears from the set while its PID still exists - which is precisely
|
||||||
|
# what a process check like "pgrep sidekiq" could not tell you (and would
|
||||||
|
# not tell you anything at all: Sidekiq is PID 1 here, so if it dies the
|
||||||
|
# container dies with it and the check can never fail - a check a broken
|
||||||
|
# system passes, README).
|
||||||
|
#
|
||||||
|
# READ FROM THE SIDEKIQ 8.0.5 SOURCE, NOT MEASURED: sidekiqmon is a plain
|
||||||
|
# Ruby entry point that requires sidekiq/api only - it does NOT boot Rails -
|
||||||
|
# and it reads REDIS_URL from the environment, which the chart sets on this
|
||||||
|
# container. Cost per check is therefore one Ruby start, not an application
|
||||||
|
# boot; the periods below are chosen on the assumption that this takes a few
|
||||||
|
# seconds, and the PR install-test is the first place it actually runs.
|
||||||
|
#
|
||||||
|
# Liveness hangs on Redis, which guideline 12.3 allows only where no
|
||||||
|
# dependency-free path proves anything - that is the case here (see above) -
|
||||||
|
# and it is given a deliberately generous budget of 60s x 5 = 300s of
|
||||||
|
# missing heartbeat, so a short Redis blip does not restart the pod.
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- bundle exec sidekiqmon processes | grep -q "^${HOSTNAME}:"
|
||||||
|
periodSeconds: 60
|
||||||
|
timeoutSeconds: 15
|
||||||
|
failureThreshold: 5
|
||||||
|
# Readiness uses the same check with a tighter budget: a worker that is not
|
||||||
|
# registered in Redis is not doing any work, whatever its process state.
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- bundle exec sidekiqmon processes | grep -q "^${HOSTNAME}:"
|
||||||
|
periodSeconds: 30
|
||||||
|
timeoutSeconds: 15
|
||||||
|
failureThreshold: 3
|
||||||
|
# The startup probe gates the other two. Sidekiq boots the full Rails app
|
||||||
|
# before it registers, and on a first install it waits for the web container
|
||||||
|
# to finish db:prepare, so the budget matches the web container's: 20s x 30
|
||||||
|
# = 600s.
|
||||||
|
startupProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- bundle exec sidekiqmon processes | grep -q "^${HOSTNAME}:"
|
||||||
|
periodSeconds: 20
|
||||||
|
timeoutSeconds: 15
|
||||||
|
failureThreshold: 30
|
||||||
|
# Extra environment variables for the worker, appended to the ones the chart
|
||||||
|
# always sets. Same rules as sure.web.env above.
|
||||||
|
env: []
|
||||||
|
|
||||||
|
# The Active Storage volume, mounted at /rails/storage in BOTH containers.
|
||||||
|
# Deliberately not null-safe: an erased block yields an invalid PVC, which fails
|
||||||
|
# loudly (README, guideline 12).
|
||||||
|
storage:
|
||||||
|
amount: 10Gi
|
||||||
|
storageClass: null
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
# Gates the whole Ingress template. Note that ingress.domain is read three
|
||||||
|
# more times outside it - APP_DOMAIN (the host Action Mailer builds link URLs
|
||||||
|
# from), WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS (the relying party a
|
||||||
|
# passkey is bound to) - so it keeps its own required() there and stays
|
||||||
|
# mandatory with the Ingress switched off; only clusterIssuer becomes
|
||||||
|
# unnecessary (README, guideline 12.1). Without that guard the app would come
|
||||||
|
# up with WEBAUTHN_RP_ID="" and register passkeys against the wrong relying
|
||||||
|
# party - rendered cleanly, pod green.
|
||||||
|
#
|
||||||
|
# ingress.enabled also decides how the app is told about TLS: enabled renders
|
||||||
|
# RAILS_ASSUME_SSL=true and RAILS_FORCE_SSL=true (the Ingress terminates TLS,
|
||||||
|
# so Rails must trust that and may set secure cookies and HSTS), disabled
|
||||||
|
# renders both as "false" so a plain-HTTP port-forward can log in at all.
|
||||||
|
enabled: true
|
||||||
|
clusterIssuer: null
|
||||||
|
domain: null
|
||||||
|
className: nginx
|
||||||
|
# Extra annotations for the Ingress; merged with the cert-manager
|
||||||
|
# cluster-issuer annotation the chart always sets.
|
||||||
|
annotations: {}
|
||||||
@@ -5,5 +5,5 @@ icon: https://avatars.githubusercontent.com/u/136759148
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 5.1.0+up6.0.0.beta12.1
|
version: 5.1.2+up6.0.0.beta13.1
|
||||||
appVersion: "6.0.0-beta12.1"
|
appVersion: "6.0.0-beta13.1"
|
||||||
@@ -4,5 +4,5 @@ description: A Helm chart to deploy urlaubsverwaltung on Kubernetes
|
|||||||
icon: https://raw.githubusercontent.com/urlaubsverwaltung/urlaubsverwaltung/main/src/main/resources/static/favicons/apple-touch-icon.png
|
icon: https://raw.githubusercontent.com/urlaubsverwaltung/urlaubsverwaltung/main/src/main/resources/static/favicons/apple-touch-icon.png
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 10.0.0+up6.12.1
|
version: 10.0.0+up6.13.1
|
||||||
appVersion: 6.12.1
|
appVersion: 6.13.1
|
||||||
Reference in new issue
Block a user