Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 82 additions & 7 deletions .agents/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,79 @@ editing templates or values.
name + instance + component. `commonLabels` / `podLabels` must never leak
into a selector. `app.kubernetes.io/component` separates the two services'
Services within one release.
9. **Component fullnames truncate the base BEFORE suffixing**
(`trunc 52` then `-server` / `-ui` / engine suffix), so long release names
cannot collapse every resource onto one identical name.
10. **The migration Job shares the deployment's env by construction.**
`authup.server.configEnv` (map) and `authup.server.secretEnv` (list) are
the single sources consumed by both `server/deployment.yaml` and
9. **Component fullnames truncate the base BEFORE suffixing, on a budget
derived from the suffix.** `authup.component.fullname`
(`dict "context" $ "suffix" "server"`) is the single implementation; every
component name and the migration Job go through it. Truncating first is what
keeps names DISTINCT (a 63-char fullname would otherwise collapse every
component onto one name); deriving the budget is what keeps them LEGAL.

The ceiling is 63, not the 253 a ConfigMap allows, wherever a name becomes a
DNS-1035 label (Service) or a label value (a Job name is copied into the
`job-name` pod labels). The old flat `trunc 52` ignored that: `-admin-console`
rendered a 66-char Service, so any release name from ~43 characters up could
not install at all, and appending `-migration` to the `-server` name reached
69. Both are now `min 52 (63 - len(suffix) - 1)`.

`min 52` is the load-bearing half. The derived budget is WIDER than 52 for
short suffixes, and widening RENAMES resources on releases whose fullname
lands between 53 and 55 characters. A renamed Secret carrying
`helm.sh/resource-policy: keep` orphans the old one and generates a new admin
password: a silent credential rotation on upgrade. **The budget may only ever
tighten**, which by construction touches only names too long to exist. Assert
that when changing it (see testing.md), do not assume it.
10. **The migration Job shares the deployment's env by construction, minus
what a hook cannot see.** `authup.server.configEnv` (map),
`authup.server.secretEnv` (list) and the two volume helpers are the single
sources consumed by both `server/deployment.yaml` and
`server/migration-job.yaml`; the Job INLINES the config map (a pre-upgrade
hook would otherwise run against the previous release's ConfigMap). The
Job is pre-upgrade ONLY (never pre-install: hooks run before backing
services exist; authup migrates at boot on fresh installs). With
`useHelmHooks=false` it renders ArgoCD `PreSync` hook annotations instead.
`useHelmHooks=false` it renders ArgoCD `PreSync` hook annotations instead,
which is an ArgoCD-only mode: see rule 19.

Helm applies a pre-upgrade hook BEFORE the release manifest, so every
NON-HOOK resource the Job references must already exist from the PREVIOUS
release. A hook resource at a lower weight is the one exception: it is
created earlier in the same hook phase, which is exactly what the config
copy below relies on. Four
helpers take a `hook` flag (`secretEnv`, the two volume helpers and
`configurationConfigMapName`; `configEnv` does not, it is inlined instead)
and drop what `migration run` does not read. That flag is the ONE mechanism
for this: the theme volume used to be a pair of deployment-only defines
carved out for the same reason, and two conventions in one `volumeMounts:`
block is how the next mount ends up on the wrong side. `themeEnv` stays
separate because it splits along a different axis. Dropped:
`REDIS`, `SMTP` (their Secrets are release resources, and the migration
builds no cache or mail module) and the provisioning mount (`ProvisionerModule`
is registered by the start command only). What stays, stays for a reason:
the writable directory, because under the image's `NODE_ENV=production` the
logger opens `<writable>/http.log` before the first query and an uncreatable
path is a hard ENOENT; and the config file, because `migration run` loads
`authup.server.core.conf` unconditionally and its file-only db keys (`ssl`,
`socketPath`, `replication`, `extensions`) decide how the migration connects.
The Job reads that file from a hook-scoped COPY
(`server/configmap-migration-configuration.yaml`, weight -5) for the same
reason it inlines the env: the release ConfigMap is either absent or one
release stale when the hook runs. `USER_ADMIN_PASSWORD` and
`CLIENT_SYSTEM_SECRET` go the same way: no identity or provisioning module
on the migration path, and the auth Secret they read is itself a release
resource. `SECRETS_ENCRYPTION_KEY` deliberately does NOT, even though its
key is conditional too and the migration does not read it today: rule 6's
fail-closed posture outranks the one-off break, so a write-once KEK gets its
own upgrade.

What the flag cannot reach, i.e. the residuals to keep in mind when adding
anything to the Job: `DB_PASSWORD` (the Secret behind it changes on an engine
switch, on adopting a built-in engine after `externalDatabase`, and on a
first inline `externalDatabase.password`, since `secret-db.yaml` is a release
resource too); the `serviceAccountName`, whose ServiceAccount renders only
under `serviceAccount.create`, so flipping that on fails pod ADMISSION with
no container status to read; and the `extraEnvVarsCM` / `extraEnvVarsSecret`
/ `extraVolumes` passthroughs, whose targets are operator-owned unless the
operator ships them through `extraDeploy`, which renders them into the
release manifest and therefore after the hook.
11. **Checksum annotations roll pods on config or secret changes.** The server
deployment checksums the env map plus every chart-managed secret it
consumes (auth, external-db, redis, smtp, provisioning, configuration),
Expand Down Expand Up @@ -114,6 +176,19 @@ editing templates or values.
`validations.yaml` fails that combination; `route.matches` / `route.filters`
are the raw passthroughs that express it (authup always serves at `/`, so
the prefix must be matched AND rewritten away).
19. **`useHelmHooks=false` is an ArgoCD-only mode.** ArgoCD renders with
`helm template` and never executes Helm hooks, so it needs its own
`argocd.argoproj.io/hook` annotations. Flux is the opposite: helm-controller
runs a real `helm upgrade` and honours Helm hooks natively. Turning them off
there applies the migration Job as an ordinary release resource, and
`Job.spec.template` is immutable, so the next upgrade that touches the pod
template (image tag, `appVersion` label, a new env) fails to patch it. A
content-hashed Job name would make that apply-able but not correct: helm
orders a plain Job AFTER the Deployment and does not wait for it, which is
the ordering the Job exists to provide. So the value stays doc-scoped to
ArgoCD and NOTES warns when it is set. ArgoCD also maps Helm hooks onto its
own sync phases, so `true` works there as well; the flag only chooses which
annotation family drives the Job.

## Values conventions

Expand Down
21 changes: 21 additions & 0 deletions .agents/references/authup.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,27 @@ unsupported per `.agents/architecture.md` in the monorepo),
- server-core auto-runs migrations + provisioning at boot
(`app/modules/database/module.ts`; no off-switch) -> generous startupProbe;
optional pre-upgrade migration Job for multi-replica DDL serialization.
- `migration run` (`cli/commands/migration.ts`, `defineCLIMigrationCommand`)
builds only three modules: config, logger, database. No http, cache, mail,
identity or provisioning module. It therefore ignores `REDIS` / `SMTP`, and
never scans `<writable>/provisioning` (`ProvisionerModule` is registered by
`createApplication()`, i.e. the `start` command only) -> the chart drops all
three from the migration Job.
- `migration run` DOES read the config file, unconditionally: `createCLIConfigModule`
passes `fs: {}` (truthy) into `readConfig`, so `readConfigRawFromFS` runs
(`config/read/fs.ts`). Env wins per key, but the db keys typeorm-extension's
env reader does not name survive: `ssl`, `socketPath`, `replication`,
`poolSize`, `charset`, `extensions` (postgres `CREATE EXTENSION` during
`initialize()`). So the config file decides how the migration connects and
what it creates -> the chart MUST mount it on the Job. (`entities` and
`subscribers` are NOT in that set: `DB_ENTITIES` / `DB_SUBSCRIBERS` exist.
Dump the real list with
`grep -rhoE "DB_[A-Z_]+" node_modules/typeorm-extension/dist | sort -u`.)
- Under `NODE_ENV=production` (baked into the image) `migration run` needs the
writable directory before it touches the database: the logger adds winston
File transports for `<writable>/http.log` and `<writable>/error.log`, and the
transport does `mkdirSync` + open eagerly. An unwritable path is a hard ENOENT
failure of the command, not a degradation -> the Job keeps the writable mount.
- Replicas > 1 without redis: per-process MemoryCache breaks auth codes,
revocations, MFA challenges (`app/modules/cache/module.ts`) -> hard
validation in the chart.
Expand Down
81 changes: 78 additions & 3 deletions .agents/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ matrix.
| Render matrix | `make template` | template errors across every `ci/*-values.yaml` |
| Values coverage | `make lint-values-coverage` | `.Values.*` paths missing from values.yaml (strict-schema dead features) |
| Drift gates (CI) | `make docs` / `make schema` + `git status --porcelain` | uncommitted regenerations of README.md / values.schema.json |
| ct install (CI) | kind cluster, one install per `ci/*-values.yaml` | real boot: DB provisioning, probes, migrations |
| ct install (CI) | kind cluster, per `ci/*-values.yaml`: install, plus two upgrades | real boot: DB provisioning, probes, migrations, and pre-upgrade hooks |

`make test` runs lint + template + coverage locally.

Expand Down Expand Up @@ -49,6 +49,7 @@ helm template t charts/authup --set server.config.PUBLIC_URL=http://x # f
helm template t charts/authup --set server.config.WRITABLE_DIRECTORY_PATH=/x # ditto; the chart pins this one to the path it mounts
helm template t charts/authup --set 'server.route.enabled=yes' # flag that is neither true nor false
helm template t charts/authup --set adminConsole.enabled=false --set adminConsole.route.enabled=yes # ditto: validated even with the component off
helm template t charts/authup --set 'server.configuration=logger: true' --set server.existingConfigmap=cm # both config carriers
helm template t charts/authup --set server.theme.enabled=true # theme with no carrier
helm template t charts/authup --set server.theme.enabled=true --set server.theme.title=X --set server.theme.existingConfigMap=cm # manifest + existing CM
helm template t charts/authup --set server.theme.enabled=true --set server.theme.logo=logo.svg # asset outside assets/
Expand Down Expand Up @@ -83,6 +84,70 @@ flag, so all six read sites convert together: leave one raw and an umbrella-driv
route renders unguarded. `ci/default-values.yaml` carries the false direction as
the in-repo regression guard.

The pre-upgrade migration Job must stay narrower than the Deployment. Helm
applies a hook before the release manifest, so anything the Job references has
to exist from the previous release:

```bash
helm template t charts/authup --set server.migration.enabled=true \
--set valkey.enabled=true --set smtp.connectionString=smtp://u:p@mail:25 \
--set auth.systemClientEnabled=true \
--set server.provisioning.enabled=true --set 'server.provisioning.files.realms\.json=[]' \
--set 'server.configuration=db: {ssl: true}' \
-s templates/server/migration-job.yaml
```

The Job's only secret-backed env must be `DB_PASSWORD` (plus
`SECRETS_ENCRYPTION_KEY` when the KEK is set): no `REDIS`, no `SMTP`, no
`USER_ADMIN_PASSWORD`, no `CLIENT_SYSTEM_SECRET`. Volumes `writable` / `tmp` /
`configuration` but NO `provisioning`; and the configuration volume must name
`<fullname>-server-migration-configuration`
(the hook-scoped copy at weight -5), never `<fullname>-server-configuration`. The
server Deployment in the same render must still carry all of them. Dropping the
config file from the Job is NOT a valid simplification: `migration run` reads it
and its file-only db keys (`ssl`, `socketPath`, `extensions`) govern the
connection, so a missing mount migrates over a plaintext connection instead of
failing.

Names have two ceilings, not one (rule 9). 63 applies to a Service (DNS-1035
label) and to a Job (its name becomes a `job-name` label value); 253 applies to
ConfigMaps and Secrets. Audit every rendered name at the longest release name
helm accepts:

```bash
helm template $(python3 -c "print('n'*53)") charts/authup \
--set valkey.enabled=true --set server.migration.enabled=true | python3 -c "
import sys, yaml
for d in yaml.safe_load_all(sys.stdin):
if d and d['kind'] in ('Service','Job') and len(d['metadata']['name']) > 63:
print('OVER 63:', d['kind'], d['metadata']['name'])
"
```

Must print nothing. The stronger property, and the one to assert whenever the
budget in `authup.component.fullname` changes, is that **no name changes for a
release that could already install**: render every release-name length 3..53 on
both `origin/master` and the branch, and check that the two name sets differ only
at lengths where master already emitted an over-63 Service or Job. Widening the
budget silently renames resources, and a renamed `resource-policy: keep` Secret
regenerates the admin password.

`useHelmHooks=false` must print the Flux/plain-helm warning in NOTES.txt, and
must not print it with hooks on. NOTES is not reachable through `helm template`,
and `.Files.Get "templates/NOTES.txt"` does NOT work either (helm excludes
`templates/` from `.Files`, so the wrapper renders empty and BOTH directions
"pass"). Inline the raw template text into a generated template instead:

```bash
cp -r charts/authup /tmp/nc
{ printf 'apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: notes\ndata:\n notes: |\n'; \
sed 's/^/ /' /tmp/nc/templates/NOTES.txt; } > /tmp/nc/templates/zz-notes.yaml
helm template t /tmp/nc --set server.migration.enabled=true --set useHelmHooks=false \
-s templates/zz-notes.yaml | grep -c 'useHelmHooks=false' # must be >0
helm template t /tmp/nc --set server.migration.enabled=true \
-s templates/zz-notes.yaml | grep -c 'useHelmHooks=false' # must be 0
```

Umbrella use is part of the contract: `global` must stay open. Render a throwaway
parent chart with authup in `charts/` and an unrelated global (`global.myOrgKey`)
whenever the schema generation changes; `ci/default-values.yaml` carries a stray
Expand Down Expand Up @@ -110,6 +175,16 @@ The generated `values.schema.json` must keep catching typos
(the external-db scenario's throwaway postgres + secrets live there).
- The kind job only runs when `ct list-changed` reports chart changes, so
docs-only PRs stay fast.
- `--timeout 600s` accounts for first-pull of the authup image plus boot-time
migrations; server-core's startupProbe budget (60 x 5s) covers create-db +
- `upgrade: true` (in `.github/configs/ct.yaml`) is what puts the pre-upgrade
migration Job on a real cluster at all: a plain `helm install` skips
`pre-upgrade` hooks entirely, so without it the Job and its hook-scoped
ConfigMap are render-tested only. Per values file ct then runs the chart on
`master` and upgrades to this revision, then installs this revision and
upgrades it to itself. The first leg is skipped once a release bumps the
middle digit, because ct reads that as a breaking change for a 0.x chart
(`~0.x.y` constraint); the self-upgrade leg always runs. Budget roughly 3x
the install-only runtime.
- `--timeout 600s` is passed to install AND upgrade (ct hands `helm-extra-args`
to both), so it also has to cover hook execution. It accounts for first-pull
of the authup image plus boot-time migrations; server-core's startupProbe budget (60 x 5s) covers create-db +
migrate + provision on first boot.
6 changes: 6 additions & 0 deletions .github/configs/ct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ target-branch: master
chart-dirs:
- charts
helm-extra-args: --timeout 600s
# Runs two real `helm upgrade`s per ci values file: master's chart -> this
# revision, then this revision -> itself. Without it no pre-upgrade hook is ever
# created on a cluster, so the migration Job and its hook-scoped ConfigMap are
# render-tested only. ct skips the first leg once a release bumps the middle
# digit (0.x treats that as breaking); the self-upgrade leg always runs.
upgrade: true
Comment thread
tada5hi marked this conversation as resolved.
check-version-increment: false
validate-maintainers: false
lint-conf: .github/configs/lintconf.yaml
10 changes: 9 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,15 @@ first-class templating, in v1.
Job serializes DDL before new pods roll — recommended (and referenced by the
replicas>1 validation) for multi-replica deployments, since MySQL DDL is
non-transactional and concurrent boot migrations can race.
- `useHelmHooks: false` support (ArgoCD/Flux users get a plain Job).
- `useHelmHooks: false` support: ArgoCD only. ArgoCD renders with
`helm template` and never runs Helm hooks, so it gets `argocd.argoproj.io`
annotations instead. Flux runs a real `helm upgrade` and honours Helm hooks,
so a plain Job there hits the immutable `spec.template` on the next upgrade.
- The hook Job sees only the PREVIOUS release's ConfigMaps and Secrets, so it
carries a narrowed env/mount set (DB_PASSWORD and the encryption key only, no
provisioning mount) plus a
hook-scoped copy of `authup.server.core.conf`, which `migration run` does
read.
- Value reshuffles get authentik-style tripwires: a `deprecations.yaml` template
fails loudly naming the moved key. BREAKING.md tracks migrations; chart
versioning is independent SemVer (0.major.minor pre-1.0), `appVersion` tracks
Expand Down
17 changes: 17 additions & 0 deletions charts/authup/BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ land on the middle digit. Every entry lists the value migrations required.

## Next release (unreleased)

- Component resource names are truncated on a budget derived from their suffix
(`min 52 (63 - len(suffix) - 1)`) rather than a flat `trunc 52`, so the
63-character limit that applies to a Service name and to a Job name is
respected. Only names that were already too long to exist change: with a
release name from roughly 43 characters up, the admin-console Service was 66
characters and the API server rejected it, so the release could not install at
all; the migration Job reached 69. Nothing to migrate, since no cluster can
hold a release in that range. Verified by rendering every release-name length
from 3 to 53 against the previous revision: the name sets differ at no length
where the old chart was installable.
- Setting BOTH `server.configuration` and `server.existingConfigmap` now fails
the render. It never worked: the existing ConfigMap is the one that gets
mounted, so the inline content was silently dropped, and that content is
typically where `db.ssl` / `socketPath` / `replication` live, i.e. how the
server pods and the pre-upgrade migration hook connect to the database. Move
the inline content into the referenced ConfigMap, or drop
`server.existingConfigmap`.
- The writable directory moves from `/usr/src/app/writable` to `/var/lib/authup`,
following the image (authup/authup#3474, shipped in v1.0.0-beta.63). The chart
mounts an emptyDir there, so nothing persists across the change; only a
Expand Down
Loading
Loading