Skip to content

Helm chart: read the VAPID private key (web push) from an existing Kubernetes Secret - #5090

Merged
simlarsen merged 8 commits into
masterfrom
claude/helm-vapid-existing-secret
Oct 20, 2026
Merged

simlarsen merged 8 commits into
masterfrom
claude/helm-vapid-existing-secret

Conversation

@simlarsen

@simlarsen simlarsen commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The problem

The maintainer's task: "extend the helm chart to take in an existing secret for the vapid.privateKey."

The VAPID private key signs every web push. Until now the chart only took it as vapid.privateKey, rendered as a plain env value, so it sat in the release's values (Helm keeps them in every revision's Secret) and, for Argo CD / Flux users, in their Git repository. The chart already reads other credentials from a Secret the operator manages (slackApp.existingSecret, runner.existingSecret, externalPostgres.existingSecret, ...), and the Kubernetes agent chart does it for its ingestion key, but not this one.

What changed

Chart (HelmChart/Public/oneuptime)

  • New values vapid.existingSecret.name (empty by default) and vapid.existingSecret.privateKeyKey (default privateKey). With a name set, VAPID_PRIVATE_KEY comes from valueFrom.secretKeyRef in every workload that gets it - app, worker, telemetry writer, nginx, home, test server and the migrate Job (also as the pre-upgrade hook) - and no workload renders it as a plain value as well. All of them go through the one helper (oneuptime.env.runtime), so a new workload on that helper gets it too.
  • Two small helpers in _helpers.tpl: oneuptime.vapid.existingSecretName (trims the name; refuses both set) and oneuptime.vapid.privateKeyKey (trims the key, defaults to privateKey). Both read through parentheses, so values from before the option (helm upgrade --reuse-values) still render.
  • values.schema.json: vapid.existingSecret is an object of name and privateKeyKey (string or null) and nothing else, so passwordKey (the name other blocks use), a string in place of the block, a numeric name or a list are refused at helm install / helm upgrade.
  • values.yaml documents it, with the kubectl create secret line.

Server (packages/Common/Server/EnvironmentConfig.ts)

  • VAPID_PRIVATE_KEY is read trimmed. A Secret created from a file (kubectl create secret generic --from-file) keeps the file's last newline, and web-push refuses a key with anything around it ("Vapid private key must be a URL safe Base 64"), so with the key from a Secret every web push would have failed. A key with nothing around it is read exactly as before; one of only whitespace counts as no key, as an empty one always did.

Docs

  • Push Notifications (self-hosted/push-notifications), Kubernetes tab, in all 17 languages, line for line: how to put the key in a Secret and name it, that the chart refuses both, how to name another key, and that the public key stays a value.
  • Chart reference (docs/configuration.md): a new "Web push notifications" section - it named no vapid value at all before - with the five values, the Secret, and how to roll the pods after changing it. docs/production-checklist.md: one item suggesting the Secret when values live in Git.
  • HelmChart/README.md: the new suite in the suites table.

Decisions (made by the chart's own precedents)

  • Both vapid.privateKey and vapid.existingSecret.name set: refused, not resolved. The message says which to clear: vapid.privateKey and vapid.existingSecret.name are both set. Set one of them: to read the VAPID private key from the Secret <name>, clear privateKey (--set vapid.privateKey=""). This follows the maintainer's own newest precedent (the Kubernetes agent chart refuses oneuptime.apiKey next to oneuptime.existingSecretName, 8778402) and its reason: a Secret of your own is there to keep the key out of the release's values, and a key left in privateKey would stay in every revision Helm stores. "Secret wins silently" (the older app-integration blocks) would leave the key there without telling anyone, and two sources of truth for one key.
  • Key name left out: a default, privateKey. It is the name npx web-push generate-vapid-keys --json gives the field and the chart's own value name, so kubectl create secret generic oneuptime-vapid --from-literal=privateKey=... plus existingSecret.name is the whole setup. Empty, null or only spaces fall back to it.
  • The public key is not read from the Secret. Every browser is given it (it is in the Dashboard's page), so there is nothing to hide, and one place per key keeps setup to one decision. The chart's cloud-marketplace secrets and the agent chart likewise put only the secret half in the Secret.
  • Nothing is copied into the release's own <release>-secrets Secret, and the plain-value path is unchanged (it never used that Secret either).
  • No optional: true on the reference: like every other operator-managed Secret in this chart, a missing Secret or key stops the pods with a clear CreateContainerConfigError, rather than web push silently staying off. A rolling update keeps the old pods serving meanwhile.
  • Docker Compose (config.env) is unchanged - not part of the ask.

What changes for existing customers

Nothing, unless they set the new value. An install that sets none of it renders byte for byte as before: I rendered master's chart and this branch with every workload on - defaults, a plain key, the operator database layout, migrate.hook, deployment.disableDeployments - and diffed (normalising only the chart's random secrets): identical. A VAPID_PRIVATE_KEY with whitespace around it (from any source) now works instead of failing every web push.

Tests

  • HelmChart/Public/oneuptime/tests/vapid-existing-secret_test.yaml (56 helm-unittest cases): for each of the 7 workloads - plain value; from the Secret with exactly one VAPID_PRIVATE_KEY entry and no value on it; neither set (renders the empty value as before); key name left empty (reads privateKey); both set refused with the exact message (each refusal test leaves its workload the only one rendering the helper, since a render stops at the first failing template). Plus: the migrate hook; a named key; name and key trimmed; key only spaces / null; quoting of names YAML would read as other types; empty and only-spaces names = not configured; a key name alone changes nothing; existingSecret: null (values from before the option); a private key of only spaces is not counted as set; the refusal names the trimmed Secret; the public key and subject stay plain; the e2e CronJob gets the public key only; probes, the shared Runner and the global Runner never get the private key; the schema refuses 4 wrong shapes.
  • HelmChart/Tests/suites/vapid-existing-secret.sh (new cluster-free suite, 40 checks, registered in run.sh so the helm-test job runs it): renders the whole chart with every workload on, in both database layouts: naming no Secret (empty, spaces, key only, block from before) renders exactly as the plain key does; the plain key lands only in VAPID_PRIVATE_KEY entries (no copy in any Secret, ConfigMap or annotation); with the Secret, exactly the same workloads get it, each from the Secret and none as a plain value; a workload not in its list that gains the key fails (so a new one is added on purpose); another key name reaches every workload; helm template refuses both set with the exact message and without printing the key; the schema refuses each wrong shape and names the property (Helm-version independent).
    • I checked that both test files catch regressions on scratch copies: a plain value rendered beside the Secret, no default key name, both set resolved silently, an untrimmed name, a workload that bypasses the helper, and a workload that gains it - all caught.
  • packages/Common/Tests/Server/EnvironmentConfigVapidPrivateKey.test.ts (10 cases): the key with a trailing \n / \r\n, spaces and tabs is read trimmed; untouched otherwise (every base64url character kept); only whitespace or unset = no key; web-push refuses the raw newline-ended key and takes the trimmed one (with a real generated key pair); and PushNotificationService.initializeWebPush sets web push up with a newline-ended key, and leaves it off for a newline-only one. The service is imported in beforeAll hooks with a time budget of their own, since its first import compiles for ~30 s on a cold cache.
  • packages/App/Tests/FeatureSet/Docs/PermissionsAndSelfHostedDocsFacts.test.ts: a new test holds the page's paragraphs to the chart (values and defaults, the schema's two keys and additionalProperties: false, the helper's Secret / key / refusal, the public key never from a Secret, and the page's two examples naming values the chart has and the default key). Its chartRendersFrom now reads a variable that also has a Secret branch.

What I ran

  • HelmChart/Tests/run.sh lint unit extra-values vapid-existing-secret marketplace (private helm 3.19.0, sha256-checked): all pass (oneuptime chart unit tests 602, agent chart 384).
  • The byte-for-byte render comparison against master's chart (5 value sets).
  • App: the general docs suites (Tests/FeatureSet/Docs/Docs*) and every docs suite naming the page or the self-hosted category - 86 suites, 16,251 tests, all pass, before and after merging master; npm run docs:localize-anchors -- --apply --page self-hosted/push-notifications (nothing to localize), npm run docs:check-anchors, npm run i18n:validate. App's root-level tests (17 suites, 263 tests).
  • Common: the new suite (also with --no-cache); the EnvironmentConfig* and WebPush* suites with BILLING_ENABLED off and on (11 suites, 239 tests each way); npx jest Guard (151 suites, 4,764 tests).
  • The two suites I added or changed, again with ee/ moved away, as Common Test and App Test run them.
  • Repo-root Tests/Ops (64 suites pass, 1 skipped), before and after merging master.
  • npm run compile in Common and App, npm run compile-tests in Common; tsc --listFiles in both shows no file from Scripts/ or another package; eslint and prettier on the changed code files; the ReDoS checker (no new patterns in product code). The docs pages and HelmChart/README.md were not prettier-formatted on master either; CI lints code only, and reformatting the pages would break the docs' line-for-line translation checks.
  • Not run: the cluster-backed helm suites (secrets-lifecycle, keda-bootstrap; they need KinD and nothing they cover changed), a live install, and the full App and Common jest runs (CI runs them). No failures seen that also fail on master.

UI review

What I looked at. The Push Notifications page's Kubernetes tab, served by the docs' real routes and templates (a jest harness around DocsFeatureSet, with the docs' own stylesheet, fonts and scripts) and drawn in Chromium at 1280 and 390 px wide, light and dark, with the fold closed and open, in all 17 languages (German for length, Persian right to left, Japanese, Hindi). The chart reference section and the production checklist, drawn as GitHub draws Markdown, at 1280 and 390 px. The helm template refusal and the schema errors as helm 3.19.0 prints them, the values.yaml comment, and the helm test runner's summary.

What I changed, and why.

  • The Secret is a fold under the Kubernetes tab's values (all 17 languages, line for line). The tab showed the plain values.yaml and then spent twice that length on the Secret, a route most installs never take. Now the tab is one values.yaml, like the Docker Compose tab, and Keep the private key in a Secret opens the Secret route. In each language the title is the Kubernetes agent page's heading Keep the ingestion key in a Secret, using this page's word for the private key. The paragraphs inside the fold are unchanged.
  • The kubectl line fits the article column. At 1280 px it was 722 px wide in a 592 px column, so the end of the command, with the key, was cut off and you had to scroll sideways. Now each flag gets its own line, with --namespace oneuptime, as the Microsoft Foundry and Kubernetes agent pages write it. The text says "in OneUptime's namespace", and the command now shows where to put yours.
  • A fold's marker turns when it opens on Persian pages. The right-to-left rule turned only the closed marker. The open rule outranked it with the left-to-right turn, so on every Persian page a fold's marker pointed left whether the fold was open or closed. Now an open fold's marker points down in both directions. The rule follows the fold's own direction (:dir(rtl)), so when an untranslated page is shown in English at a Persian address, its folds keep the left-to-right marker. This fixes every Persian fold, not just this one.
  • Chart reference: the values table's columns line up in the source. The kubectl line has -n oneuptime, like the CA example on the same page. The note about restarting pods is now a paragraph of its own with one command (kubectl rollout restart deployment -n oneuptime) instead of "deployment/-app, and so on". The production checklist names vapid.existingSecret.name, the value you actually set. The values.yaml comment says to create the Secret in the release's namespace.
  • HelmChart/Tests/run.sh: the summary's name column is now 22 characters wide, so vapid-existing-secret no longer pushes its count out of line.

Left as built. The refusal text: it matches the agent chart's word for word (8778402) and says what to clear. The schema errors, which Helm writes. The settings table on the page, which the fold sits right under. On a phone, a long code span such as vapid.existingSecret.privateKeyKey still breaks inside the fold, as long code spans do across the docs.

Tests.

  • PermissionsAndSelfHostedDocsFacts: the Secret paragraphs open the fold. Inside the Kubernetes tab, the fold comes after the plain example and closes with the tab set. The command must stay on three lines and keep the namespace.
  • RightToLeftDocsLayout: works out from the stylesheet's borders and rotations which way a fold's marker points: right or left when closed, down when open, in both directions. It also checks that no fold rule is keyed on [dir=], with a control test for the reader. Both new checks fail on master's stylesheet and on a fix keyed on the attribute.
  • PermissionsAndSelfHostedDocsTranslations already holds the fold line for line in every language (containers, fences, inline code).

What I ran. The 88 docs suites that read the page, the stylesheet or the self-hosted category, plus the general Docs* suites: 22,623 tests. docs:localize-anchors, docs:check-anchors and i18n:validate. The helm suites lint, unit, extra-values, vapid-existing-secret and marketplace, with a private, sha256-checked helm 3.19.0. Tests/Ops (64 suites), App's root-level tests, and Common's npx jest Guard (151 suites). App's npm run compile and the tsc --listFiles check. The changed suites again with ee/ hidden. Prettier and eslint on every changed code file, and the ReDoS checker. Master had not moved since the builder's merge.

🤖 Generated with Claude Code

valeriamustafaeva and others added 7 commits October 20, 2026 07:08
vapid.existingSecret.name names a Secret the operator manages; every pod
given VAPID_PRIVATE_KEY (app, worker, telemetry writer, nginx, home, test
server and the migrate Job) then reads it from there, under privateKey
unless vapid.existingSecret.privateKeyKey names another key, and the chart
renders it nowhere as a plain value. Setting vapid.privateKey as well is
refused, saying which one to clear, as the Kubernetes agent chart refuses
apiKey next to existingSecretName: a key left in the values would stay in
every revision Helm stores. The public key and the subject stay plain
values. An install that sets none of this renders byte for byte as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cluster-free suite renders the chart with every workload on, in both
database layouts: with vapid.existingSecret, exactly the workloads that get
VAPID_PRIVATE_KEY from vapid.privateKey get it from the Secret, none as a
plain value; a new workload that is handed the key must be added to the
suite's list on purpose. Naming no Secret renders exactly as before, the
plain key is copied nowhere else, both set and wrong shapes are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reference named no vapid value at all. It now lists the three, the
existing Secret and its key, with how to create the Secret, and the
production checklist suggests the Secret for installs whose values are in Git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Kubernetes tab of Push Notifications shows how to put the private key
in a Secret of your own and name it in vapid.existingSecret, that the chart
refuses it beside vapid.privateKey, and that the public key stays a value.
In all 17 languages, line for line. PermissionsAndSelfHostedDocsFacts holds
the paragraph to the chart: the values, the schema, the helper's Secret,
key and refusal, and the example's names; its chart check reads a variable
that has a Secret branch too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Kubernetes Secret created from a file keeps the file's last newline, and
web-push refuses a private key with anything around it ('Vapid private key
must be a URL safe Base 64'), so with the key from vapid.existingSecret every
web push would fail. The server now reads the key trimmed; a key with nothing
around it is read as before, and one of only whitespace is no key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xisting-secret

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simlarsen

simlarsen commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

UI review of the vapid.existingSecret docs.

- Push Notifications, Kubernetes tab, all 17 languages: the tab leads with
  the one values.yaml most installs need; keeping the private key in a
  Secret is a fold under it ("Keep the private key in a Secret", worded as
  the Kubernetes agent page's own "Keep the ingestion key in a Secret"
  heading in each language). The kubectl line no longer scrolls sideways
  in the 590px article column: a flag a line, with --namespace oneuptime,
  as the Foundry and agent pages write it.
- A fold on a Persian page kept its marker pointing left once opened (the
  right-to-left rule turned only the closed marker, and the open rule
  outranked it with the left-to-right turn). The marker now points down
  when open either way round, keyed on the fold's own direction (:dir),
  so the English copy of a page on a Persian address keeps the
  left-to-right marker. RightToLeftDocsLayout reads where each marker
  points.
- Chart reference: an aligned values table, -n oneuptime on the kubectl
  line (as the CA example on the same page), one restart command in a
  paragraph of its own instead of "and so on"; the checklist names
  vapid.existingSecret.name; values.yaml says to create the Secret in the
  release's namespace.
- HelmChart/Tests/run.sh: the summary column fits the new suite's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simlarsen
simlarsen merged commit 26d5cf3 into master Oct 20, 2026
17 of 62 checks passed
@simlarsen
simlarsen deleted the claude/helm-vapid-existing-secret branch October 20, 2026 08:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant