sure: chart page for the new we-promise Sure chart
Adds the chart page following the outline reference (same sections, same table shapes, the complete values reference), a row in the chart index and the sidebar entry, and pulls the two publish-workflow counts up by one where the new chart changes them.
4 files changed
+218
-3
No files matched your search
+2
-2
@@ -4,7 +4,7 @@ Two pipelines: one that **validates** a pull request, one that **publishes** a c
|
||||
|
||||
## Publishing
|
||||
|
||||
- **`publish-<chart>.yml`** — one thin workflow per chart (23 of them). It triggers on pushes to `main` touching `<chart>/**`, or manually, and calls the reusable workflow below.
|
||||
- **`publish-<chart>.yml`** — one thin workflow per chart (24 of them). It triggers on pushes to `main` touching `<chart>/**`, or manually, and calls the reusable workflow below.
|
||||
- **A push to a chart directory on `main` publishes that chart.** The chart version must be bumped in the same change; the PR pipeline enforces it.
|
||||
- **`publish-helm-chart.yml`** — the reusable workflow: fetch dependencies, `helm package`, then a single HTTP `POST` of the `.tgz` to the registry.
|
||||
|
||||
@@ -119,7 +119,7 @@ Both matrices follow the changed **chart directories**, so a PR that only change
|
||||
|
||||
All measured on this instance, not taken from documentation, and the reason for every deviation in `validate-charts.yml`.
|
||||
|
||||
**Reusable workflows with `secrets: inherit` work**, so the 23 thin publish hulls run unchanged. One quirk: a *missing* secret does not fail the job, it fails job **creation** — the run then shows no steps at all.
|
||||
**Reusable workflows with `secrets: inherit` work**, so the 24 thin publish hulls run unchanged. One quirk: a *missing* secret does not fail the job, it fails job **creation** — the run then shows no steps at all.
|
||||
|
||||
**Runner labels mean different machines.** `ubuntu-latest` has **no Docker socket** here; `ubuntu-docker` has one. Exactly one job cares — the install-test, because k3d is a Docker client — so its `runs-on` is an expression rather than a fixed label, and the same file still works on a GitHub runner where `ubuntu-docker` does not exist.
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Chart index
|
||||
|
||||
24 directories: 23 installable charts plus the `template-chart` scaffold. Each page holds prerequisites, an installation walkthrough, the complete list of configurable values, and the chart's operating traps.
|
||||
25 directories: 24 installable charts plus the `template-chart` scaffold. Each page holds prerequisites, an installation walkthrough, the complete list of configurable values, and the chart's operating traps.
|
||||
|
||||
**Versions are not listed here on purpose.** Renovate moves app versions continuously; a table of numbers in a wiki is wrong within days. Read the current pair off the chart:
|
||||
|
||||
@@ -28,6 +28,7 @@ grep -E '^(version|appVersion):' <chart>/Chart.yaml
|
||||
| [samba](samba) | Samba | StatefulSet | no | needs capabilities — a documented hardening deviation |
|
||||
| [satisfactory](satisfactory) | Satisfactory game server | StatefulSet | no | **excluded from the install-test**; `limitFactor: 2` and `OnRootMismatch` are deliberate |
|
||||
| [searxng](searxng) | SearXNG | Deployment + ConfigMap | yes | `ingress.domain` is read twice, so it stays required with the Ingress off |
|
||||
| [sure](sure) | Sure (the maintained Maybe Finance fork) | StatefulSet, **two containers** + upload PVC | yes | web and Sidekiq worker share one RWO volume; readiness avoids Rails' `/up`, which proves only that the app booted |
|
||||
| [teamspeak-server](teamspeak-server) | TeamSpeak server | StatefulSet | no | probes address the TCP port by name; the voice service is UDP and unprobeable |
|
||||
| [template-chart](template-chart) | — | scaffold | — | **not installable**; the reference implementation of every convention |
|
||||
| [urlaubsverwaltung](urlaubsverwaltung) | Urlaubsverwaltung | Deployment | yes | readiness names its health-group members explicitly after a silent outage |
|
||||
|
||||
+1
@@ -37,6 +37,7 @@
|
||||
- [samba](samba)
|
||||
- [satisfactory](satisfactory)
|
||||
- [searxng](searxng)
|
||||
- [sure](sure)
|
||||
- [teamspeak-server](teamspeak-server)
|
||||
- [template-chart](template-chart)
|
||||
- [urlaubsverwaltung](urlaubsverwaltung)
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
# sure
|
||||
|
||||
Deploys [Sure](https://github.com/we-promise/sure), the maintained community fork of Maybe Finance, as a single-replica StatefulSet with **two containers** — the Puma web process and a Sidekiq worker — sharing one upload volume. Postgres and Redis are **not** part of this chart.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `1.0.0+up0.7.4` |
|
||||
| App version | `0.7.4` (image `ghcr.io/we-promise/sure`) |
|
||||
| Workloads | StatefulSet (1 replica, 2 containers: `web` + `worker`) |
|
||||
| Storage | one RWO PVC at `/rails/storage`, mounted into **both** containers |
|
||||
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required |
|
||||
| External dependencies | Postgres, Redis |
|
||||
| Secrets | 3 × `OnePasswordItem` |
|
||||
| Install-test | `ci/sure/` (fixtures + test values, `INSTALL_TIMEOUT=900`) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus three 1Password items — see [the fields below](#what-the-1password-items-must-contain).
|
||||
- **Postgres and Redis reachable before the first start.** The web container creates and migrates the schema at start, its readiness probe runs a query, and all three worker probes ask Redis for the Sidekiq heartbeat. A missing dependency shows up as a pod that never turns Ready — read the container log, not the pod events.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- Nothing else: there is no OIDC provider to prepare, and the first account created on a fresh instance becomes the super admin.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install sure jlab/sure \
|
||||
--namespace sure --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
appSecretRef: op://vault/sure-app
|
||||
dbSecretRef: op://vault/sure-db
|
||||
redisSecretRef: op://vault/sure-redis
|
||||
|
||||
ingress:
|
||||
domain: finance.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password items must contain
|
||||
|
||||
The chart renders `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.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.appSecretRef` | `<release>-sure-secret` | `secret-key-base` — `SECRET_KEY_BASE`, 64 random bytes hex (`openssl rand -hex 64`). See [Rotating `SECRET_KEY_BASE`](#rotating-secret_key_base-destroys-more-than-sessions) before ever changing it |
|
||||
| `secrets.dbSecretRef` | `<release>-sure-db-secret` | `host`, `port`, `database`, `username`, `password` — rendered into `DB_HOST`, `DB_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` |
|
||||
| `secrets.redisSecretRef` | `<release>-sure-redis-secret` | `connection-url` — `REDIS_URL`, e.g. `redis://:password@host:6379/0` |
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `sure.replicas`. PVC, Secrets, Service and Ingress stay. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.appSecretRef` | string | `null` | **Required.** `op://` path of the app item (`SECRET_KEY_BASE`). |
|
||||
| `secrets.dbSecretRef` | string | `null` | **Required.** `op://` path of the Postgres item. |
|
||||
| `secrets.redisSecretRef` | string | `null` | **Required.** `op://` path of the Redis item. |
|
||||
|
||||
### `sure`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `sure.replicas` | int | `1` | Replica count of the StatefulSet; `0` is allowed. Scales **both** containers, since they live in one pod. See [One pod, one volume, two containers](#one-pod-one-volume-two-containers) before raising it. |
|
||||
| `sure.securityContext.pod` | object | non-root 1000, `fsGroup: 1000`, `fsGroupChangePolicy: Always`, `seccompProfile: RuntimeDefault` | Passed verbatim into the pod spec; shared by both containers. uid/gid 1000 is what the image declares (`USER 1000:1000`). The seccomp profile sits here on purpose: it covers every container including init containers. |
|
||||
|
||||
### `sure.web`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `sure.web.securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation, non-root | Passed verbatim into the container spec. The writable paths are emptyDirs — see [Which paths have to be writable](#which-paths-have-to-be-writable). |
|
||||
| `sure.web.livenessProbe` | object | `GET /up:http`, period 10s, timeout 5s, 3 failures | Boot check only; it touches no database. See [What each probe actually proves](#what-each-probe-actually-proves). |
|
||||
| `sure.web.readinessProbe` | object | `GET /:http`, period 10s, timeout 5s, 3 failures | The path that reaches Postgres. |
|
||||
| `sure.web.startupProbe` | object | `GET /up:http`, period 10s, 60 failures (= 600s) | Gates liveness and readiness. The budget covers `db:prepare` on the first start. |
|
||||
| `sure.web.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `sure.web.resources.requests` | object | `cpu: 0.1`, `memory: 512Mi`, `ephemeral-storage: 10Mi` | Upstream reports ~352 MB steady state and asks for at least 1 GB with AI enabled; 512Mi × 3 covers both. |
|
||||
| `sure.web.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
| `sure.web.env` | list | `[]` | Extra environment variables appended to the chart's own. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). This is where [mail and AI](#the-chart-configures-no-mail-and-no-ai) go. |
|
||||
|
||||
### `sure.worker`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `sure.worker.securityContext.container` | object | same hardening as the web container | Passed verbatim into the container spec. |
|
||||
| `sure.worker.livenessProbe` | object | `exec` sidekiqmon heartbeat check, period 60s, timeout 15s, 5 failures (= 300s) | Deliberately generous: the check needs Redis, so a short outage must not restart the pod. |
|
||||
| `sure.worker.readinessProbe` | object | same `exec` check, period 30s, timeout 15s, 3 failures | |
|
||||
| `sure.worker.startupProbe` | object | same `exec` check, period 20s, 30 failures (= 600s) | The worker registers only after the schema the web container creates exists. |
|
||||
| `sure.worker.resources.limitFactor` | number | `3` | |
|
||||
| `sure.worker.resources.requests` | object | `cpu: 0.1`, `memory: 512Mi`, `ephemeral-storage: 10Mi` | The sample-data generator runs **here** and is the one operation upstream measured above 512 MB (~680 MB); the computed 1536Mi limit covers it. |
|
||||
| `sure.worker.resources.limits` | object | unset | |
|
||||
| `sure.worker.env` | list | `[]` | Same rules as `sure.web.env`. Anything a background job needs — the AI assistant included — belongs here as well as on the web container. |
|
||||
|
||||
### `storage`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `storage.amount` | string | `10Gi` | Size of the Active Storage PVC. |
|
||||
| `storage.storageClass` | string | `null` | Storage class; `null` uses the cluster default. |
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Switches the Ingress object off — and with it `RAILS_ASSUME_SSL`/`RAILS_FORCE_SSL`, which the chart renders from this flag. **`ingress.domain` stays required**: see [`ingress.domain` is read four times](#ingressdomain-is-read-four-times). |
|
||||
| `ingress.domain` | string | `null` | **Required.** Ingress host, `APP_DOMAIN` (mail links), `WEBAUTHN_RP_ID` and `WEBAUTHN_ALLOWED_ORIGINS`. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. |
|
||||
|
||||
TLS is stored in `tls-<release>-sure-ingress`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### What each probe actually proves
|
||||
|
||||
Rails' `/up` route (`get "up" => "rails/health#show"`) returns 200 as soon as the app has booted without raising. It touches **no** database and **no** Redis. The widespread assumption that it tests the database is exactly what [style guide §12.3](Chart-Style-Guide#12-values-that-must-not-lie) exists to catch — upstream's own Helm chart, which ships inside the application repository, uses `/up` for readiness, and this chart deliberately does not copy that.
|
||||
|
||||
- **web liveness and startup: `/up`.** A Postgres outage therefore does not restart the pod.
|
||||
- **web readiness: `/`.** Every request runs `authenticate_user!`; without a session cookie — which a kubelet probe never has — it evaluates `self_hosted_first_login?`, i.e. `User.count.zero?`, which is an SQL query. With the database up the request ends in a `302` to the login or registration page, which the kubelet counts as success; with the database down it answers `500` and the pod leaves the Service until Postgres is back.
|
||||
- **worker: all three are `exec` probes** running `bundle exec sidekiqmon processes | grep -q "^${HOSTNAME}:"`. Sidekiq registers each process under the identity `<hostname>:<pid>:<nonce>` and keeps that entry alive with a heartbeat whose Redis key expires after about 60s — so a hung worker disappears from the list while its PID still exists. A plain process check could not tell that, and would in fact never fail at all: Sidekiq is PID 1 in its container, so if it dies the container dies with it.
|
||||
|
||||
Two things this depends on, both set by the chart: `SELF_HOSTED=true` (in managed mode `/` redirects without the `User.count`), and `RAILS_ASSUME_SSL` matching `RAILS_FORCE_SSL`. Sure's `production.rb` defaults **both** to `true`; with `assume_ssl` off and `force_ssl` on, a plain-HTTP probe is answered with a `301` before the controller runs — a "success" that proves nothing. Overriding `RAILS_ASSUME_SSL` to `"false"` while leaving `RAILS_FORCE_SSL` on turns the readiness probe back into a lie.
|
||||
|
||||
> Everything in this section was read off the source of `0.7.4` (the Rails health controller, `app/controllers/concerns/authentication.rb`, `config/environments/production.rb`, Sidekiq 8.0.5's `lib/sidekiq/component.rb`) rather than measured in a running container. The PR install-test is where it first runs for real.
|
||||
|
||||
### One pod, one volume, two containers
|
||||
|
||||
Active Storage writes uploads, imports and exports to `/rails/storage`, and **both** processes use them: the web process serves the files a background job produced. Upstream's compose file mounts one volume into both services, and a ReadWriteOnce PVC cannot be attached to two pods — so the two containers share one pod instead of being two workloads. Raising `sure.replicas` above 1 normally leaves the extra pods `Pending` on `Multi-Attach error for volume`.
|
||||
|
||||
### Only the web container migrates
|
||||
|
||||
The image entrypoint (`/rails/bin/docker-entrypoint`) runs `./bin/rails db:prepare` **only** when the command is `./bin/rails server`. That is the image's default `CMD`, so the web container migrates; the worker is started with `args: ["bundle", "exec", "sidekiq"]` and therefore skips it. The two can never race.
|
||||
|
||||
`args`, not `command`, and the distinction is load-bearing: Kubernetes' `command` would replace the **entrypoint**, which is also what preloads jemalloc — one of the three tunings upstream names as the reason its memory figures hold.
|
||||
|
||||
### Which paths have to be writable
|
||||
|
||||
`readOnlyRootFilesystem: true` holds for both containers; every writable path is an emptyDir, one set per container:
|
||||
|
||||
| Path | Why |
|
||||
|---|---|
|
||||
| `/rails/storage` | the PVC — Active Storage's local disk service |
|
||||
| `/tmp` | Rack writes multipart uploads (CSV imports, attachments) to `Dir.tmpdir` before Active Storage ever sees them |
|
||||
| `/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 |
|
||||
| `/rails/log` | production logs go to STDOUT, so nothing is expected here; it stays writable because Rails' default logger path is `log/<env>.log` and a rake or console path falling back to it would otherwise hit a read-only filesystem |
|
||||
|
||||
One trade-off worth knowing: the image ships a Bootsnap cache precompiled at build time under `/rails/tmp`, and the emptyDir hides it, so every pod start recompiles it. That is boot time, not correctness.
|
||||
|
||||
### `ingress.domain` is read four times
|
||||
|
||||
Once as the Ingress host and three more times in the environment of both containers: `APP_DOMAIN` (the host Action Mailer builds absolute URLs from), `WEBAUTHN_RP_ID` and `WEBAUTHN_ALLOWED_ORIGINS`. It therefore keeps its own `required()` outside the Ingress template and stays mandatory with `ingress.enabled: false` — the [§12.1](Chart-Style-Guide#12-values-that-must-not-lie) case. Without that guard the app would come up with an empty relying party id and register passkeys against it, rendered cleanly and with a green pod.
|
||||
|
||||
Changing the domain of a running instance invalidates every registered passkey, because the relying party id is part of the credential.
|
||||
|
||||
### Rotating `SECRET_KEY_BASE` destroys more than sessions
|
||||
|
||||
In self-hosted mode Sure derives its Active Record encryption keys from `SECRET_KEY_BASE` — three SHA-256 digests over that one value (`config/initializers/active_record_encryption.rb`) — unless `ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY` and its two companions are set explicitly. Every encrypted column (provider tokens, OAuth credentials, MFA secrets) is then readable only with the value it was written under. Replacing it does not just log everyone out: it makes that data undecryptable, and no rotation brings it back.
|
||||
|
||||
Treat the field as permanent for the life of the database, or pin the three encryption keys through `sure.web.env` / `sure.worker.env` **before** the first start.
|
||||
|
||||
### Rotating a secret does not restart the pod
|
||||
|
||||
Helm never sees a `OnePasswordItem`'s content, so it cannot hash it ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). The pod template carries `checksum/secret-ref` annotations over the three rendered `OnePasswordItem`s, so pointing a `*SecretRef` at a **different** item rolls the pod; editing a value **inside** the same item does not. After such a change:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart statefulset/<release>-sure-sfs
|
||||
```
|
||||
|
||||
The operator's `operator.1password.io/auto-restart` annotation is deliberately not set: it restarts Deployments only, so on a StatefulSet it would advertise a guarantee that does not exist.
|
||||
|
||||
### The chart configures no mail and no AI
|
||||
|
||||
No `SMTP_*` variable and no `OPENAI_*` variable is set, so invitations and notifications are silently never sent and the assistant stays off — nothing in the UI says so. Both are plain environment variables and belong in `sure.web.env`, plus `sure.worker.env` for anything a background job needs (the assistant runs in the worker). `secretKeyRef` is available for the API key and the SMTP password.
|
||||
|
||||
Modelling the AI options as chart values was considered and rejected: they are a dozen variables that the app reads directly, and a values surface for them would have to be maintained against every upstream change without buying anything the `env` block does not already give.
|
||||
|
||||
### The alpha channel is not tracked
|
||||
|
||||
The registry carries releases (`0.7.4`), prereleases (`-alpha.N`, `-rc.N`, `-hotfix.N`), the moving names `stable`, `latest` (which upstream calls the alpha channel) and `nightly`, and one `sha-<commit>` tag per build. The chart pins the release tag, and [Renovate](Renovate) uses `versioning: semver` for it: prereleases are unstable and never offered, the moving names and `sha-` tags are unparseable and never selected.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up the database before every app-version bump.** Migrations are one-way; a `helm rollback` returns the old image to a database the new one has already rewritten.
|
||||
- **Raise `sure.web.startupProbe.failureThreshold` before a large jump or a first import**, not after the crash loop has started: a restart lands in the middle of a migration.
|
||||
- **Backup is two things at once.** The database holds the accounts and one row per attachment, the PVC holds the bytes. Snapshot them together. Because the PVC is a chart object, `helm uninstall` deletes it — the external database survives, which makes an accidental uninstall look recoverable while the files are already gone.
|
||||
- The first account created on a fresh instance becomes the super admin. Sign in yourself before handing out the URL.
|
||||
|
||||
## CI
|
||||
|
||||
`ci/sure/` holds `test-values.yaml` (op:// refs against the stub CRD, `sure.ci.local`), `fixtures.yaml` (throwaway Postgres 16 — the version upstream's compose file uses — plus Redis and the dummy secrets) and `settings.env` with `INSTALL_TIMEOUT=900`, because the rollout wait has to outlast a first start that creates the whole schema. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements
|
||||
- [Versioning](Versioning) — what a bump of `1.0.0+up0.7.4` means
|
||||
- [Chart index](Charts)
|
||||
Reference in new issue
Block a user