Skip to content

Commit 6ff08cb

Browse files
Release 11 juni: API-key 401 fix, taaklog-fixes, orphan/backup-sweeps, SOPS skip, domeinvalidatie (#125)
* feat(reconciliation): rapport-eerst service-orphan sweep met purge-veiligheidslagen Implementeert features/service-orphan-reconciliation.md en repareert de fundamenten van de reconciliation die het nooit voorbij dry-run schopte: 1. _build_expected_resources leest nu alle schema-generaties: catalog- componenten/helm-charts/helmfiles (zelfde resolutie als de delete-flow) PLUS legacy deployment-level blokken, met echte servicenamen (postgresql-database, minio-storage) en clone-generaties (_vN op de database, nooit op de user). Voorheen was de verwachte set leeg voor vrijwel elk project, waardoor de restored->unmark beveiliging dood was (waggl-9et near-miss: live prod-DB 30 dagen gemarkeerd). 2. Purge-veiligheidslagen, gecontroleerd op purge-moment: - mark waarvan de resource in de verwachte set zit -> unmark, nooit purge (geldt nu ook voor project-scoped cleanup/trigger, dat blind purgde) - database met actieve connecties -> geweigerd + gerapporteerd; alleen expliciete projectverwijdering mag connecties termineren 3. Read-only sweep (GET /api/v2/admin/orphans/report): inventariseert databases, Keycloak realms/clients en MinIO-buckets, classificeert expected/system/orphan_candidate/in_use_anomaly/unknown. Gevalideerd tegen de echte productie-inventaris (55 DB's, 374 clients): waggl en regel_k4c_pr748_v1 correct expected, 54 regel-k4c + 20 wies dode PR-clients als kandidaat, custom clients veilig unknown. 4. Bevestiging (POST /api/v2/admin/orphans/confirm): expliciete lijst, server-side hervalidatie (alleen actueel orphan_candidate), daarna normale grace-periode. Nieuw resource-type keycloak_client met eigen purge-handler (realm in mark-metadata). Automatisch markeren vanuit een scan blijft bewust verboden. * docs(orphan-sweep): documenteer unknown-classificatie van verwijderde projecten (bouwm) + geparkeerd git-history-idee * Weekly updated * fix(tasks): voorkom valse 'stuck RUNNING' meldingen en maak change-logs leesbaar Vier observability-fixes naar aanleiding van de 530-delete nacht van 11 juni: 1. Succesvolle async tasks markeerden de legacy in-memory _projects entry nooit COMPLETED (alleen fail_project en de backup-handler deden dat), waardoor elke geslaagde task 2 uur later als 'stuck RUNNING' werd gerapporteerd. De worker synct nu elk terminaal pad via mark_legacy_completed/mark_legacy_failed; de sweep-warning noemt voortaan projectnaam en betekenis. 2. Change-analysis logt deployment-namen in plaats van kale DeepDiff-paden ('deployments.7' wordt 'deployments.7 (pr-372)') en de samenvatting meldt expliciet wanneer een schema auto-migratie de eenmalige alle-deployments diff veroorzaakte. 3. Commit-messages van image-bumps vergeleken de nieuwe image met project_data dat al gemuteerd was, waardoor elke bump het generieke ': configuration' kreeg. De delen worden nu tijdens de update-loop verzameld, voor de overschrijving. 4. extract_deployment_components logde een identieke DEBUG-regel per aanroep (14x per deployment per run); nu een keer per deployment per handler-instantie. * fix(auth): decrypt api-key bij herregistratie via load_project_from_data Portal-saves (modal wizard, wizard, subdomain admin, backup tasks) herregistreren een project via load_project_from_data, dat config.api-key rauw uit de YAML registreerde - de AGE-ciphertext. Elke API-key-vergelijking verwacht plaintext, dus een portal-save vergiftigde de in-memory key en 401'de alle API-calls van het project (~1 minuut, tot de save-getriggerde process-run de gedecrypte key herregistreerde). Zo verloor de wies pr-394 CI-deploy van 11 juni 11:30 de race met een gelijktijdige portal-edit: upsert geslaagd, maar de eerstvolgende task-poll kreeg 401 met een misleidende 'verify your ZAD_API_KEY' melding. load_project_from_data decrypt de api-key nu voor registratie (zelfde keten als ProjectManager.get_api_key: globale key -> project-key -> api-key). Bij decrypt-falen blijft de eerder geregistreerde plaintext staan zodat een mislukte save authenticatie niet kan breken; plaintext waarden (legacy/test) passeren ongewijzigd. * feat(backup): daily retention sweep for orphaned snapshots Kopia retention only runs at the end of a backup, scoped to that run's source identity, so snapshots of deployments that no longer get backed up (deleted deployments, removed schedules, broken legacy uid@podname identities) never expire. Add a daily sweep that classifies every source and deletes orphaned non-manual snapshots past a grace period. - BACKUP_SWEEP_ENABLED / BACKUP_SWEEP_DRY_RUN / BACKUP_ORPHAN_RETENTION_DAYS - dry-run by default: logs a manifest, deletes nothing until reviewed - manual backups and actively-scheduled sources are never touched - thread kopia source identity (user@host) through to SnapshotInfo - bulk delete_snapshots over a single repo connection * fix(backup): don't clone backup schedule into new deployments upsert_deployment with cloneFrom deep-copies the source deployment's config. backup was missing from the exclude list, so every PR preview cloned from production inherited the nightly schedule and accumulated database snapshots that nothing ever pruned. Add backup to clone_exclude_keys; the data clone (clone-from) is unaffected. * feat(sops): skip re-encryption when secret plaintext is unchanged SOPS encryption is non-deterministic (fresh nonces/MAC/lastmodified per run), so re-encrypting unchanged secrets rewrote every *.sops.yaml on every deployment and churned the GitOps repos. encrypt_to_sops_files now takes an optional private_key: it decrypts the existing ciphertext, compares parsed YAML against the freshly generated plaintext, and keeps the existing file verbatim when unchanged (dropping only the plaintext source). Fails safe by re-encrypting on any doubt: no existing file, decrypt failure, unparseable YAML, or no key. Threaded through encrypt_to_sops_files_or_fail and all managers: project secrets/helm/helmfile/infra use the project key via _sops_private_key_for (None for legacy projects), ArgoCD repo secrets use SOPS_AGE_PRIVATE_KEY. Adds real sops/age round-trip tests and a feature doc. * fix(domains): cluster-default resolutie + domeinvalidatie in de API-upsert Twee samenhangende domein-fixes uit de wizard/editables-hoek: 1. ClusterDefaultDomain resolvede naar het eerste nice-URL domein in plaats van het ingress-default domein van het cluster. Gevolg: de request-checkbox verscheen onterecht voor de clusterstandaard en de PRE_SAVE hook materialiseerde een verkeerd base-domain (plus phantom domain request) in het projectbestand. De resolver geeft nu het ingress-postfix domein terug en faalt hard op een onbekende CLUSTER_MANAGER; de hook schrijft de clusterstandaard nooit meer uit (afwezigheid van base-domain betekent 'gebruik de default'). 2. De API-upsert draait nu dezelfde DomainConfigEnforcer als de wizard, zodat een dot-formaat URL niet gecombineerd kan worden met een dash-only domein (onbereikbare multi-label hostname). Alleen actief wanneer de call domeinvelden wijzigt, zodat image-only updates nooit stuklopen op bestaande configuratie. * docs(openproject): EE-token is hostnamegebonden — sandbox en productie elk een eigen token * docs(weekly): redactieslag 11 juni * docs(weekly): tweede release van 11 juni toegevoegd * test(wizard): cluster-default base-domain creates no phantom domain request The pre-existing test_flow_with_none_base_domain_resolves_default asserted the OLD buggy behaviour: leaving the base-domain select on 'cluster default' (empty) materialised the last-selected nice-URL domain plus a phantom domain request, deploying to a domain the user never chose. The committed wizard fix (ClusterDefaultDomain resolves the ingress default; _resolve_missing_base_domains does not materialise it) correctly stops that. is_deployment_domain_approved returns True for an empty base-domain, so the cluster default needs no request. Rewritten as a regression guard: cluster default stays None and creates no domains entry. * test(orphans): make confirm-endpoint test robust to settings-reference pollution test_only_current_orphan_candidates_accepted passed in isolation but failed in the full suite: admin_router and endpoint_util bind 'settings' by reference at import, and in the full suite they were first-imported while another test had opi.core.config.settings replaced (mock_settings fixture) or reloaded (test_secret_key_failclosed). That left their module-level reference pointing at a stale mock, surfacing as a 501 (ADMIN_API_KEY unset) and then a MagicMock in the JSON response (DELETION_GRACE_PERIOD_DAYS). Re-point both module references to the live settings before patching the attributes the endpoint reads. * fix(reconciliation+domains): sluit twee review-gaten van de 11-juni release Twee correctheids-/veiligheidsgaten gevonden bij de review van deze release-bundel, plus de bijbehorende nice-to-haves. 1. _build_expected_resources resolvede de database-generatie altijd onder POSTGRESQL_DATABASE, terwijl een namespace-postgres deployment zijn generatie onder NAMESPACE_POSTGRESQL_DATABASE opslaat. Voor een gekloonde/herstelde namespace-postgres DB viel daardoor de _vN-naam uit de verwachte set en kon de purge-tijd unmark-beveiliging de live database niet meer beschermen (waggl-9et-klasse). Nu wordt tegen beide DB-servicetypes geresolved. 2. De DomainConfigEnforcer draaide de domein-goedkeuringscheck alleen voor het wizard-sentinel "__custom__". Een directe API-upsert met een letterlijk, niet-ondersteund base-domain omzeilde de check volledig en kon zo een domein buiten het cluster (of van een andere tenant) in het projectbestand zetten. De check geldt nu voor elk niet-platform domein, ongeacht herkomst; API-callers kunnen geen aanvraag-vinkje zetten dus een onbevestigd domein wordt hard geweigerd (conform de docstring). Nice-to-haves uit dezelfde review: - keycloak_client expliciet gedocumenteerd als bewust afwezig in de verwachte set (leunt op de confirm-hervalidatie, niet op unmark). - backup-retention-sweep doc: waarschuwing dat backup.enabled: false alle geplande snapshots na de grace-periode laat verwijderen. - sops.py: function-local "import yaml" naar de top verplaatst. Regressietests toegevoegd voor beide gaten (falen op de oude code, slagen nu) plus de centrale-postgres generatievariant.
1 parent 7774cf4 commit 6ff08cb

42 files changed

Lines changed: 3429 additions & 108 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/openproject-on-zad.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,23 @@ components:
189189
## Replacing the bypass image with a real Enterprise Token
190190

191191
Once you have a valid OpenProject Enterprise Token, the wrap image is no
192-
longer needed. Two ways to install the token:
192+
longer needed.
193+
194+
**Note on requesting the token**: OpenProject EE tokens (v2.0+) are bound
195+
to a specific hostname. OpenProject's sales/onboarding team will ask for
196+
the deployment URL — it ends up embedded in the token and validated at
197+
runtime against `Setting.host_name` (= our `OPENPROJECT_HOST__NAME` env
198+
var, set to `$PUBLIC_HOSTNAME`). Implication:
199+
200+
- Sandbox needs its own token (e.g. for `productie-openp-7lh.sandbox.rijksapp.dev`)
201+
- Production needs a separate token for its final hostname
202+
- Changing the hostname after the fact → request a new token
203+
- Wildcard / multi-domain tokens exist but must be explicitly requested
204+
205+
Source: `app/models/enterprise_token.rb` — `invalid_domain?` calls
206+
`token_object.valid_domain?(Setting.host_name)`.
207+
208+
Two ways to install the token:
193209

194210
1. **Via env var** (declarative, survives DB resets):
195211

features/backup-retention-sweep.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Backup Retention Sweep
2+
3+
## What it is
4+
5+
A daily background job in the Operations Manager that deletes **orphaned**
6+
backup snapshots — snapshots that no backup run will ever clean up again.
7+
8+
Kopia retention is applied by the backup pod at the end of each backup run,
9+
scoped to that run's source identity. That means retention silently stops for:
10+
11+
- deployments that were deleted (e.g. PR previews),
12+
- deployments whose `backup.schedule` was removed from the project file,
13+
- projects with `backup.enabled: false`,
14+
- legacy snapshots written under a broken Kopia source identity
15+
(`<uid>@<pod-name>` instead of the stable `opi-backup@...`), which no
16+
`kopia snapshot expire` ever matched.
17+
18+
Without the sweep, those snapshots live forever. With it, a source that stops
19+
being backed up ages out to zero after a grace period.
20+
21+
## How it works
22+
23+
The sweep runs once per day from the backup scheduler loop, on the first tick
24+
at or after 06:00 Europe/Amsterdam (after the default 02:00 backups and their
25+
catch-up window). Per project on this cluster it lists all Kopia snapshots and
26+
classifies each one (first matching rule wins):
27+
28+
| Rule | Verdict |
29+
|---|---|
30+
| `trigger:manual` tag, or source host ends in `-manual` | **Protected** — never touched. Manual backups are only removed explicitly by an operator. |
31+
| Source identity or timestamp missing/unparseable | **Unclassifiable** — skipped with a warning. The sweep only deletes what it can positively classify. |
32+
| Identity `opi-backup@...` and the deployment currently has a `backup.schedule` | **Active** — left to the backup pod's per-run retention. |
33+
| Anything else, newer than the grace period | **Young orphan** — kept for now. |
34+
| Anything else, older than the grace period | **Orphan** — deleted (or logged in dry-run). |
35+
36+
Note that the classification is identity-aware: a legacy `uid@podname`
37+
snapshot is treated as an orphan even when its deployment tag points at an
38+
actively scheduled deployment, because per-run retention can never match it.
39+
40+
Whole-project deletion is out of scope: deleting a project already marks its
41+
entire backup prefix for deferred deletion.
42+
43+
> [!WARNING]
44+
> Setting `backup.enabled: false` on a project that still exists makes the
45+
> sweep treat **all** of that project's scheduled snapshots as orphans. They
46+
> are protected only by the grace period: every snapshot older than
47+
> `BACKUP_ORPHAN_RETENTION_DAYS` (default 30) is deleted on the first sweep
48+
> after the flag is flipped, and the rest age out as they cross the boundary.
49+
> If you intend to keep historical backups while pausing new ones, do not rely
50+
> on this flag — the snapshots are not retained indefinitely. Keep the first
51+
> production sweep in dry-run and review the manifest before arming deletion.
52+
53+
## Configuration
54+
55+
| Setting | Default | Meaning |
56+
|---|---|---|
57+
| `BACKUP_SWEEP_ENABLED` | `true` | Master switch for the daily sweep. |
58+
| `BACKUP_SWEEP_DRY_RUN` | `true` | Log a manifest of what would be deleted, delete nothing. |
59+
| `BACKUP_ORPHAN_RETENTION_DAYS` | `30` | Grace period: orphans younger than this are kept. Mirrors `BACKUP_RETENTION_KEEP_DAILY`, so an orphaned source's backups survive as long as an active one's would. |
60+
61+
## Rollout
62+
63+
The sweep ships with `BACKUP_SWEEP_DRY_RUN=true`. Review at least one sweep
64+
manifest in the OPI logs (`grep "Sweep "` / `grep "would delete"`), confirm
65+
the candidates are right, then set `BACKUP_SWEEP_DRY_RUN=false`.
66+
67+
Example log output:
68+
69+
```
70+
Backup retention sweep starting (cluster=odcn-production, grace=30d, dry_run=True)
71+
Sweep wies/rig-prd-wies: 226 snapshots — {'active': 55, 'orphan-expired': 54, 'orphan-young': 117}
72+
Sweep wies/rig-prd-wies: would delete orphan snapshot 3f1f... (1001730000@db-backup-production-postgresq-20260422-012810, deployment=production, ts=2026-04-22T01:28:10Z)
73+
...
74+
Backup retention sweep finished (would delete 54 snapshots)
75+
```
76+
77+
## Dependencies
78+
79+
- Backup scheduler (`opi/core/backup_scheduler.py`) — hosts the daily trigger.
80+
- Kopia CLI in the OPI image — the sweep queries and deletes directly, no
81+
pods are spawned.
82+
- Namespace SOPS key — repository passwords are derived per namespace, so the
83+
sweep covers any namespace that still exists. Snapshots of fully deleted
84+
projects are handled by the project-deletion flow instead.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Service-orphan reconciliation (rapport-eerst opruiming van DB's, Keycloak-clients, MinIO-buckets)
2+
3+
## Probleem
4+
5+
Deletes uit het pre-#123 tijdperk rapporteerden succes terwijl service-resources
6+
bleven staan. De idempotente delete-API ("already absent" o.b.v. het projectbestand)
7+
ruimt die historische wezen nooit op. Geïnventariseerd op productie 2026-06-11:
8+
9+
- **Keycloak realm `regel-k4c-odcn-production`: 74 PR-genummerde clients vs 11
10+
actieve previews → ~63 wezen, allemaal `public` clients** (PR's uit de 200-300
11+
reeks, maanden dood). Mild security-relevant: live OIDC-entrypoints met
12+
redirect-URI's naar dode preview-hosts.
13+
- Realm `wies-odcn-production`: 50 clients — zelfde patroon, nog niet geauditeerd.
14+
- rig-db: 7 wees-databases voor regel-k4c (`regel_k4c_pr104` t/m `pr128`) +
15+
`regel_k4c_pr748_v1` (clone-restant); cluster-totaal 55 databases incl. de
16+
bekende `marked_for_deletion`-backlog die geen purge-scheduler heeft.
17+
- MinIO-buckets: nog niet geïnventariseerd, zelfde verdenking.
18+
19+
De huidige delete-flow is sinds #123 WEL schoon (bewijs: pr777 op 2026-06-11 —
20+
ArgoCD-app, pods, database én Keycloak-clients allemaal verwijderd en geverifieerd).
21+
Dit gaat puur om historisch afval.
22+
23+
## Ontwerp-eisen
24+
25+
1. **Rapport-eerst, nooit direct purgen.** `waggl_9et_productie` (live prod-DB!)
26+
stond ooit onterecht als marked_for_deletion — een blinde purge had hem
27+
gedropt. Sweep produceert een rapport; verwijdering alleen vanaf een
28+
bevestigde lijst.
29+
2. Inventariseer per service: pg_database (rig-db), Keycloak clients per realm
30+
(admin-API of read-only SQL op de keycloak-DB), MinIO buckets (mc ls).
31+
3. Match tegen de waarheid: live deployments uit alle projectbestanden
32+
(zad-projects repo) — zelfde bron als de delete-API gebruikt.
33+
4. Let op naamgevingsvarianten: `_v1`-suffixen (clone-generaties),
34+
`-public`/`-private` clientparen, deployment- vs componentnamen.
35+
5. Het bestaande orphan-detect is een stub (zie geheugen rig-db reconciliation)
36+
— dit vervangt/implementeert die.
37+
6. Hergebruik de verificatie-aanpak van #123: k8s/service-API is ground truth,
38+
niet de eigen administratie.
39+
40+
## Inventarisatie-queries (de specificatie)
41+
42+
```bash
43+
# Databases
44+
kubectl -n rig-prd-operations exec rig-db-1 -- psql -U postgres -tAc \
45+
"SELECT datname FROM pg_database WHERE datistemplate=false ORDER BY datname"
46+
47+
# Keycloak clients per realm (read-only)
48+
kubectl -n rig-prd-operations exec rig-db-1 -- psql -U postgres -d keycloak -tAc \
49+
"SELECT r.name, c.client_id, c.public_client FROM client c
50+
JOIN realm r ON c.realm_id=r.id ORDER BY r.name, c.client_id"
51+
```
52+
53+
## Relatie met bestaand werk
54+
55+
- #123 (honest delete) — de flow vooruit is schoon; dit ruimt het verleden op.
56+
- Geheugen: `project_rigdb_restarts_reconciliation` (purge-gap, waggl-waarschuwing),
57+
`project_incident_20260610_netpol` (context van deze week).
58+
- Nachtelijke cleaner bestaat al voor deployments; dit is de service-laag eronder.
59+
60+
## Implementatie (2026-06-11)
61+
62+
### Sweep (rapport, nul mutaties)
63+
64+
```bash
65+
curl -X GET "https://<opi>/api/v2/admin/orphans/report" -H "X-API-Key: $ADMIN_API_KEY"
66+
```
67+
68+
Inventariseert databases (rig-db), Keycloak realms/clients en MinIO-buckets en
69+
classificeert tegen de live projectbestanden:
70+
71+
| Classificatie | Betekenis | Verwijderbaar |
72+
|---|---|---|
73+
| `expected` | hoort bij een live deployment | nee |
74+
| `system` | platform-infrastructuur (system-DB's, Keycloak built-ins, backup-buckets) | nee |
75+
| `orphan_candidate` | projectnaamgeving, geen live deployment | **alleen via confirm** |
76+
| `in_use_anomaly` | lijkt wees maar heeft actieve connecties | nee — onderzoeken |
77+
| `unknown` | matcht geen naamgevingsconventie | nee |
78+
79+
Het rapport bevat ook `stale_marks`: marks waarvan de resource al weg is of
80+
juist weer in de verwachte set zit.
81+
82+
### Bevestigen (start grace-periode)
83+
84+
```bash
85+
curl -X POST "https://<opi>/api/v2/admin/orphans/confirm" \
86+
-H "X-API-Key: $ADMIN_API_KEY" -H "Content-Type: application/json" \
87+
-d '{"items": [{"type": "postgresql_database", "name": "regel_k4c_pr104"},
88+
{"type": "keycloak_client", "name": "regel-k4c-pr250-public", "realm": "regel-k4c-odcn-production"}]}'
89+
```
90+
91+
De sweep wordt server-side opnieuw uitgevoerd; alleen items die op dat moment
92+
`orphan_candidate` zijn worden geaccepteerd. Daarna geldt de normale
93+
grace-periode (`DELETION_GRACE_PERIOD_DAYS`, 7 dagen) en verwijdert
94+
`POST /api/v2/admin/reconciliation/trigger?dry_run=false` ze definitief.
95+
96+
### Veiligheidslagen (lessen van waggl-9et)
97+
98+
1. `_build_expected_resources` leest nu schema v1 én v2/v2.2 (catalog-
99+
componenten + legacy deployment-level blokken + clone-generaties).
100+
2. Purge hercontroleert de verwachte set op het moment van verwijderen:
101+
een mark waarvan de resource weer in de YAML staat wordt ge-unmarkt.
102+
3. Een database met actieve connecties wordt NOOIT gedropt door de purge
103+
(geweigerd + gerapporteerd); alleen de expliciete projectverwijdering
104+
mag connecties termineren.
105+
4. `cleanup/trigger` (project-scoped) heeft dezelfde bescherming als de
106+
volledige reconcile.
107+
108+
### Bekende beperking (geparkeerd)
109+
110+
Resources van **volledig verwijderde projecten** (geen projectbestand meer)
111+
classificeren als `unknown` en zijn dus niet via confirm op te ruimen — er is
112+
geen actuele waarheid die de naam claimt. Concreet op productie:
113+
`bouwm_75c_main`, `bouwm_75c_production`, `bouwm_nr1_main` (+ bijbehorende
114+
Keycloak-realms) van de verwijderde projecten bouwm-75c/bouwm-nr1
115+
(pre-#123 deletes; verwijdering aantoonbaar in zad-projects git-history:
116+
`git log --diff-filter=D --name-only -- projects/`).
117+
118+
Geparkeerd idee: die git-history als extra waarheidsbron voeden aan de sweep,
119+
zodat resources van aantoonbaar-verwijderde projecten `orphan_candidate`
120+
worden met de delete-commit als bewijs. Tot die tijd: handmatige opruiming.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# SOPS Skip-If-Unchanged Re-encryption
2+
3+
## What it is
4+
5+
An optimization in the Operations Manager's SOPS encryption step that **keeps
6+
the existing ciphertext when a secret's plaintext has not changed**, instead of
7+
re-encrypting it on every deployment.
8+
9+
SOPS encryption is non-deterministic: every run generates fresh nonces, a new
10+
MAC, and a new `lastmodified` timestamp. So re-encrypting an unchanged secret
11+
produces a byte-different `*.sops.yaml` file every time. Before this change,
12+
every deployment rewrote every generated secret, which churned the GitOps repos
13+
(`zad-deployments`, ArgoCD repository secrets) with meaningless diffs and grew
14+
the commit history with noise on each push.
15+
16+
With this change, a secret whose decrypted content is identical to what's
17+
already committed is left untouched — no re-encryption, no git diff.
18+
19+
## How it works
20+
21+
`encrypt_to_sops_files()` in `opi/utils/sops.py` now takes an optional
22+
`private_key`. When the key is provided, for each `*.to-sops.yaml` it:
23+
24+
1. Locates the existing `*.sops.yaml` output (if any).
25+
2. Decrypts it with the private key.
26+
3. Parses **both** the existing decrypted content and the freshly generated
27+
plaintext as YAML and compares the parsed documents.
28+
4. If they are equal, it keeps the existing ciphertext verbatim, deletes only
29+
the plaintext `*.to-sops.yaml` source (so nothing leaks), and skips
30+
encryption. Otherwise it re-encrypts as before.
31+
32+
The comparison is on **parsed YAML**, not raw bytes, so key-order or formatting
33+
differences in the generated plaintext never count as a change.
34+
35+
It **fails safe — re-encrypting — on any doubt**:
36+
37+
- no existing `*.sops.yaml` (first deployment),
38+
- a decrypt failure (e.g. after AGE key rotation, or a wrong key),
39+
- unparseable YAML on either side,
40+
- no `private_key` passed at all (the original always-re-encrypt behaviour).
41+
42+
So a missing or mismatched key can never cause a stale secret to be kept — at
43+
worst it re-encrypts unnecessarily, exactly as before.
44+
45+
## Configuration
46+
47+
There is nothing to configure. The behaviour is enabled automatically wherever a
48+
private key is available:
49+
50+
- **Project secrets** (deployment secrets, helm values, helmfile values,
51+
infrastructure secrets) use the project's own AGE key, resolved via
52+
`ProjectManager._sops_private_key_for()`, which returns `None` for legacy
53+
projects without an `age-private-key` (those fall back to always
54+
re-encrypting).
55+
- **ArgoCD repository secrets** use the cluster's `SOPS_AGE_PRIVATE_KEY` from
56+
settings.
57+
58+
`encrypt_to_sops_files_or_fail()` (the fail-closed wrapper that all managers
59+
use) threads the `private_key` straight through.
60+
61+
## Examples
62+
63+
```python
64+
# Skip-if-unchanged enabled (project key resolved per project):
65+
encrypt_to_sops_files_or_fail(
66+
target_path,
67+
public_key,
68+
f"secrets voor deployment '{deployment_name}' (namespace '{prefixed_namespace}')",
69+
private_key=await self._sops_private_key_for(project_data),
70+
)
71+
72+
# Always re-encrypt (no key) — original behaviour:
73+
encrypt_to_sops_files(directory, public_key)
74+
```
75+
76+
## Dependencies
77+
78+
- `sops` and `age` binaries (already required by the Operations Manager image).
79+
- `get_decoded_project_private_key()` in `opi/utils/age.py` for the project key.
80+
- `settings.SOPS_AGE_PRIVATE_KEY` for ArgoCD repository secrets.
81+
82+
## Tests
83+
84+
- `tests/test_sops_skip_unchanged.py` — real round-trip tests using the actual
85+
`sops`/`age` binaries with a throwaway keypair: byte-identical ciphertext on
86+
unchanged input, re-encryption on change, semantics-vs-formatting, no-key and
87+
wrong-key fall back to re-encrypt, and first-time encryption. Skipped when the
88+
binaries are absent.
89+
- `tests/test_sops_fail_abort.py` — includes a guard test asserting the managers
90+
never call the bare `encrypt_to_sops_files` (the fail-closed wrapper must
91+
always be used).

0 commit comments

Comments
 (0)