feat(bulwark): new chart for Bulwark Webmail (1.0.0+up1.12.0)
A JMAP webmail client for the stalwart chart (Gitea #65): StatefulSet, one PVC at /app/data, Service plus headless Service, Ingress at a host root, two OnePasswordItems, three probes, computed limits, replicas plus scaleDown, strict values.schema.json. Everything below was measured against the source at tag 1.12.0 rather than read out of the documentation, which was wrong about three of them. THE DEFINING PROPERTY: THE VOLUME BEATS THE CHART, FOR EVERY VALUE, SILENTLY. configManager.get resolves "admin override > env var > default", the admin dashboard may write any key of CONFIG_ENV_MAP, and nothing reports that it just disconnected the chart - so a helm upgrade can succeed and change nothing. Upstream documents this for the server URL only; it is true of everything. Exactly three values escape it (SESSION_SECRET(_FILE), ADMIN_PASSWORD, BULWARK_TELEMETRY). The value that closes it, bulwark.adminConfigReadOnly, defaults to false because its price is threefold: it freezes the settings policy (and with it the only way to move Web Push off the vendor relay), it freezes an override that ALREADY exists instead of removing it, and on a fresh install it stops admin.json from being written at all - so the admin dashboard never comes up and its login answers HTTP 500 while the pod stays Ready. OIDC-only sign-in, with the real way back out of a lockout: not helm upgrade with OAUTH_ONLY=false (oauthOnly goes through the same precedence chain) but removing the override in the admin dashboard. That is why ADMIN_PASSWORD is set - the dashboard is the door. It generates no random password and logs nothing, so the #65 analogy does not apply. SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE are both used - the better path garage (#81) and keycloak (#83) did not have - in one projected volume mounted WITHOUT subPath, because both are re-read on every use and a subPath mount is never refreshed, and at mode 0440 because secret files are owned by root and fsGroup only supplies the group. Two backup statements, both at the value: without the session secret the synchronized settings are unreadable out of an intact volume, and if the dashboard's OAuth setup was ever used the client secret sits in config.json on that volume in cleartext. Probes: all three a plain GET on /api/health, with the limit written at the readiness value - it carries process, HTTP acceptance, a free event loop and Next.js routing, and nothing about the reported state or about Stalwart. No ConfigMap, so the checksum/config rule (#82) has nothing to attach to, and values.yaml says so explicitly; the op:// paths are covered by checksum/secret-ref. Renovate: no versioningTemplate, measured rather than assumed. 315 tags in eight shapes (releases, a second v-prefixed family of the same images, an -always flavour, floating minors and names, commit SHAs, betas, attestations) and plain docker versioning excludes all six unwanted ones. Dry runs at 1.12.0, 1.11.0 and 1.10.0 are recorded in the block. Part of #89 - deliberately not "Fixes", because one binding decision of the issue was changed out of necessity and wants ratification rather than an automatic close: the plan specifies HEAD /api/health for all three probes, and a Kubernetes probe cannot send HEAD at all (HTTPGetAction has no method field; the kubelet always issues GET). The alternative, an exec probe, would mean starting a Node process every few seconds in an image that carries no curl and no wget. GET is used instead and is marginally more capable than HEAD, so every limit the issue argues is unchanged - but the decision is the maintainer's to confirm.
This commit was merged in pull request #90.
This commit is contained in:
1 parent
40e29f7f72
commit
b39f36ff5c
17 files changed
+2719
-1
No files matched your search
@@ -0,0 +1,69 @@
|
||||
# Fixtures for the bulwark install-test: the two Secrets the 1Password operator
|
||||
# would otherwise materialize from the OnePasswordItems the chart renders
|
||||
# (release name: ci, stub CRD in ci/common/), plus a name for the JMAP endpoint
|
||||
# to resolve to.
|
||||
#
|
||||
# NO DATABASE, NO CACHE, NO BROKER AND NO MAIL SERVER - and that is a property of
|
||||
# the chart rather than a shortcut taken here. Bulwark is a client: it holds no
|
||||
# mail and contacts the JMAP server per request, not at boot, so it starts and
|
||||
# becomes Ready with nothing behind that URL. The flip side is the limitation the
|
||||
# chart documents at bulwark.readinessProbe, and this file is the clearest
|
||||
# illustration of it: everything needed to make this chart green is in these few
|
||||
# objects, and none of it is a mail server.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-bulwark-app-secret
|
||||
stringData:
|
||||
# The field NAMES are what matter here, not the values: the operator
|
||||
# materializes one Secret key per item field, and the chart consumes these two
|
||||
# in DIFFERENT ways - session-secret as a projected FILE, admin-password
|
||||
# through a secretKeyRef.
|
||||
#
|
||||
# Only the second of those is actually asserted by the run. The projected
|
||||
# volume is `optional: true` by design (so a first install does not hang
|
||||
# waiting for the 1Password operator), so a misspelled session-secret yields a
|
||||
# missing file and a pod that starts fine. A misspelled admin-password leaves
|
||||
# the container in CreateContainerConfigError and fails the run.
|
||||
#
|
||||
# A real base64 32-byte value rather than a placeholder word: Bulwark derives
|
||||
# encryption keys from it, and a value of the wrong shape would be the kind of
|
||||
# thing that works in CI and not in production.
|
||||
session-secret: Y2ktb25seS1zZXNzaW9uLXNlY3JldC1ub3QtYS1yZWFsLW9uZQ==
|
||||
# Visibly throwaway. It is hashed with scrypt into admin.json on the first
|
||||
# start and the cluster is deleted with the job; the only thing it protects is
|
||||
# a dashboard that exists for a few minutes.
|
||||
admin-password: ci-bulwark-admin-password
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-bulwark-oauth-secret
|
||||
stringData:
|
||||
# The OIDC client secret. Nothing in CI performs a token exchange - no identity
|
||||
# provider runs here - so this exists to exercise the SECOND source of the
|
||||
# chart's projected volume and the OAUTH_CLIENT_SECRET_FILE path, not the OAuth
|
||||
# flow.
|
||||
oauth-client-secret: ci-bulwark-oauth-client-secret
|
||||
---
|
||||
# The JMAP endpoint's name. A Service with NO SELECTOR and therefore no
|
||||
# endpoints: the in-cluster DNS name resolves, and a connection to it is refused.
|
||||
#
|
||||
# DELIBERATELY NOT A DEPLOYMENT. ci/run-install-test.sh waits for every fixture
|
||||
# Deployment and StatefulSet to roll out BEFORE it installs the chart, so a stub
|
||||
# web server here would be one more workload able to fail a run that is not about
|
||||
# it - and it would buy nothing, because a refused connection and a served 404
|
||||
# are indistinguishable to a chart that never contacts this address at boot.
|
||||
#
|
||||
# It exists at all so the URL in test-values.yaml is a real name rather than an
|
||||
# invented hostname, which keeps the fixture honest about what the chart does
|
||||
# with it.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: jmap-stub
|
||||
spec:
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
@@ -0,0 +1,162 @@
|
||||
# CI values for the bulwark install-test.
|
||||
#
|
||||
# WHY THIS CHART IS TESTED RATHER THAN EXCLUDED, and what a green run is worth.
|
||||
# Bulwark needs no database, no cache and no broker, and it does not need a
|
||||
# reachable JMAP server to START - the connection is made per request, not at
|
||||
# boot - so a dummy URL is enough to bring the pod up. That makes this one of
|
||||
# the cheapest install-tests in the repo and the only place where the two
|
||||
# riskiest assumptions of the chart are exercised at all. Neither is observable
|
||||
# in a render.
|
||||
#
|
||||
# WHAT A GREEN RUN PROVES:
|
||||
#
|
||||
# 1. readOnlyRootFilesystem: true HOLDS, WITH EXACTLY THE TWO emptyDirs THE
|
||||
# CHART RENDERS - /tmp and /app/.next/cache. This is the measurement point
|
||||
# the plan for Gitea #89 left open, and it is the reason this fixture
|
||||
# exists: the set of writable paths was DERIVED from next.config.ts
|
||||
# (output: "standalone", and images.unoptimized applies only in the isLite
|
||||
# branch, so image optimization is active and writes under
|
||||
# /app/.next/cache) and could not be measured without running the image.
|
||||
# A missing writable path is not subtle - it is an EROFS or EACCES on the
|
||||
# first write - so a pod that reaches Ready and stays Ready is the proof.
|
||||
# A SUPERFLUOUS emptyDir would NOT be caught here; that half stays open and
|
||||
# is named in the chart's wiki page.
|
||||
# 2. THE PROBES CAN REALLY BE SATISFIED. All three are an HTTP GET on
|
||||
# /api/health, which has to exist in the built bundle and answer 200
|
||||
# unauthenticated. If it did not, the startup probe would exhaust its budget
|
||||
# and the rollout would fail - and no render could have told us, because a
|
||||
# path only exists in the built bundle.
|
||||
# 3. The uid/gid 1001 read off the Dockerfile is right, and fsGroup makes a
|
||||
# freshly provisioned volume writable for it: Bulwark creates its four
|
||||
# subdirectories under /app/data itself on start, and the image's own mkdir
|
||||
# ran in a layer this mount covers. Wrong uid, or no fsGroup, and the
|
||||
# instrumentation hook fails to write admin.json.
|
||||
# 4. ADMIN_PASSWORD is really consumed on the first start - the hook hashes it
|
||||
# with scrypt and writes /app/data/admin/admin.json. That write is inside
|
||||
# the startup budget, so a budget that was too short would fail here.
|
||||
# Plus the mechanical half: the projected volume assembles, the Service port
|
||||
# name matches the containerPort, the Ingress is accepted with its extra
|
||||
# annotation alongside the cert-manager one, the resource quantities are valid,
|
||||
# and the schema accepts a realistic non-default values file.
|
||||
#
|
||||
# ONE CLAIM THIS FIXTURE DELIBERATELY DOES NOT MAKE, because it would be
|
||||
# false: it does not prove that the two FILE-form secret keys are spelled
|
||||
# right. The projected volume marks both sources `optional: true` on purpose -
|
||||
# so that a first install does not hang in ContainerCreating while the
|
||||
# 1Password operator catches up - and the consequence is that a misspelled
|
||||
# session-secret or oauth-client-secret key yields a MISSING FILE and a pod
|
||||
# that starts perfectly. Only `admin-password` is proven, because it arrives
|
||||
# through a non-optional secretKeyRef and a wrong name there leaves the
|
||||
# container in CreateContainerConfigError.
|
||||
#
|
||||
# WHAT IT DOES NOT PROVE, and the first two matter most:
|
||||
# - ANYTHING ABOUT JMAP. The URL below points at a Service that answers
|
||||
# nothing useful. Bulwark comes up regardless, which is precisely the
|
||||
# property the readiness probe cannot see (chart values.yaml,
|
||||
# bulwark.readinessProbe) - so this test demonstrates the limitation rather
|
||||
# than closing it. A green run here says nothing about mail working.
|
||||
# - ANYTHING ABOUT OIDC. No identity provider runs in CI, so the sign-in path
|
||||
# is never walked. What IS checked is that the chart's render guards fire on
|
||||
# a missing client id or issuer (unit-testable at render time) and that the
|
||||
# secret file is mounted where OAUTH_CLIENT_SECRET_FILE points.
|
||||
# - THE PRECEDENCE TRAP. config.json never exists in this run, so the
|
||||
# "volume beats the chart" behaviour - the chart's single most important
|
||||
# operating caveat - is not exercised. It cannot be: it needs an admin
|
||||
# writing an override, which needs a browser.
|
||||
# - anything about an upgrade, or about a volume that already carries data.
|
||||
# This installs once, from nothing.
|
||||
|
||||
secrets:
|
||||
# Materialized by ci/bulwark/fixtures.yaml as ci-bulwark-app-secret and
|
||||
# ci-bulwark-oauth-secret (the OnePasswordItem stub CRD comes from ci/common/).
|
||||
appSecretRef: op://ci/bulwark-app
|
||||
oauthSecretRef: op://ci/bulwark-oauth
|
||||
|
||||
bulwark:
|
||||
# A dummy JMAP endpoint. It does NOT have to answer - Bulwark connects per
|
||||
# request - but it does have to be SET, because an empty value puts the app
|
||||
# into bootstrap mode and serves the setup wizard instead. So this value also
|
||||
# exercises the path that keeps the chart declarative.
|
||||
#
|
||||
# It points at the selector-less Service in fixtures.yaml: a name that RESOLVES
|
||||
# and then refuses the connection. That is deliberately not a Deployment -
|
||||
# fixtures with a workload have to roll out before the chart is installed, so a
|
||||
# stub serving nothing would be one more thing able to fail a run that is not
|
||||
# about it. A refused connection and a served 404 are indistinguishable to this
|
||||
# chart, because nothing here contacts the JMAP server at boot.
|
||||
jmapServerUrl: http://jmap-stub.default.svc.cluster.local:8080
|
||||
|
||||
appName: Bulwark CI
|
||||
|
||||
# OIDC LEFT ON, i.e. the chart's default, although no provider answers in CI.
|
||||
# That is deliberate: it is what makes the run exercise the OAuth
|
||||
# OnePasswordItem, the second source of the projected volume and the
|
||||
# OAUTH_CLIENT_SECRET_FILE path. Bulwark does not contact the issuer at boot,
|
||||
# so an unreachable one costs nothing here - it would only fail a sign-in, and
|
||||
# nothing signs in.
|
||||
oauth:
|
||||
enabled: true
|
||||
only: true
|
||||
clientId: bulwark-ci
|
||||
issuerUrl: https://keycloak.ci.example.com/realms/ci
|
||||
|
||||
# ON, i.e. the chart's default, and it is what makes /app/data/settings get
|
||||
# created and the session secret actually read: with the sync off, a missing or
|
||||
# misnamed session-secret field would pass unnoticed.
|
||||
settingsSync: true
|
||||
|
||||
# OFF - the chart's default, spelled out because it is load-bearing for what
|
||||
# this test means. With it ON, a fresh install cannot write admin.json and the
|
||||
# admin dashboard never comes up; the pod would still be Ready, so the run
|
||||
# would be green while the chart's documented rescue door was missing. Leaving
|
||||
# it off is what makes step 4 of "what a green run proves" an assertion.
|
||||
adminConfigReadOnly: false
|
||||
|
||||
# Both off, as in the chart - and here with a second reason: a CI runner should
|
||||
# not send a telemetry heartbeat or an update check to a vendor endpoint on
|
||||
# every pull request.
|
||||
telemetry: false
|
||||
updateCheck: false
|
||||
|
||||
# debug, so that a failed run has something to read. The install-test dumps
|
||||
# container logs on failure, and Bulwark's info level says very little about
|
||||
# why a start went wrong.
|
||||
logLevel: debug
|
||||
# text rather than the chart's json: a human reads these logs out of a failed
|
||||
# job, not a collector.
|
||||
logFormat: text
|
||||
|
||||
resources:
|
||||
# Small, to stay inside the runner's budget, while still exercising the limit
|
||||
# derivation: 192Mi x limitFactor 3 = 576Mi. Not smaller than this on
|
||||
# purpose - the memory LIMIT is what V8's heap limit is derived from, and a
|
||||
# Next.js server with too little heap fails in a way that looks like an
|
||||
# application fault.
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 192Mi
|
||||
ephemeral-storage: 64Mi
|
||||
|
||||
storage:
|
||||
amount: 256Mi
|
||||
# k3d's dynamic provisioner. The intended production value does not exist on
|
||||
# the runner - and a pre-bound hostPath PV would be worse than this one,
|
||||
# because the kubelet does not apply fsGroup to a hostPath volume at all, so
|
||||
# the test would silently stop exercising the ownership path that matters most
|
||||
# here: Bulwark has to CREATE four subdirectories in a volume it did not make.
|
||||
storageClass: local-path
|
||||
|
||||
ingress:
|
||||
# Left ON although no ingress controller answers in CI: the point is that the
|
||||
# object is accepted and that the Service port it names BY NAME ("http")
|
||||
# actually exists - a typo there is otherwise invisible until someone opens the
|
||||
# URL.
|
||||
enabled: true
|
||||
domain: bulwark.ci.example.com
|
||||
clusterIssuer: ci-selfsigned
|
||||
annotations:
|
||||
# Exercises the annotation merge: this has to arrive ALONGSIDE the
|
||||
# cert-manager annotation the chart always sets, not instead of it. It is
|
||||
# also the annotation this chart's NOTES.txt tells operators to add, so the
|
||||
# fixture is the shape a real install has.
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "25m"
|
||||
Reference in new issue
Block a user