diff --git a/.gitignore b/.gitignore index b876f6f..9fd4227 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ helm/*.yaml !helm/*-databases.yaml !helm/*-backup.yaml !helm/*-probe.yaml +!helm/vergabe-demo-company-role.yaml +!helm/vergabe-demo-company-database.yaml # ArgoCD repository credentials — encrypt locally, never commit argocd/repositories/*.repository.sops.yaml diff --git a/assurance/admission-baseline.json b/assurance/admission-baseline.json index 45b28bb..ac76005 100644 --- a/assurance/admission-baseline.json +++ b/assurance/admission-baseline.json @@ -5,7 +5,8 @@ "apps-pg": { "consumers": [ "coulomb_social", - "vergabe" + "vergabe", + "vergabe_demo_company" ], "ceiling": 3, "retention_days": 30, @@ -14,15 +15,18 @@ "max_connections": 100, "service_classes": { "coulomb_social": "interactive", - "vergabe": "interactive" + "vergabe": "interactive", + "vergabe_demo_company": "interactive" }, "connection_limits": { "vergabe": 20, - "coulomb_social": 20 + "coulomb_social": 20, + "vergabe_demo_company": 20 }, "owners": { "coulomb_social": "coulomb-social", - "vergabe": "vergabe-teilnahme" + "vergabe": "vergabe-teilnahme", + "vergabe_demo_company": "vergabe-demo-company" } }, "apps-pg-2": { @@ -63,6 +67,7 @@ }, "platform-pg-2": { "consumers": [ + "canned-prompts", "sbom-nexus" ], "ceiling": 4, @@ -71,23 +76,26 @@ "memory_limit": "1Gi", "max_connections": 100, "service_classes": { + "canned-prompts": "interactive", "sbom-nexus": "interactive" }, "owners": { + "canned-prompts": "canned-prompts", "sbom-nexus": "sbom-nexus" } } }, "sources": { - "railiance-platform/helm/apps-pg-cluster.yaml": "9885c81338e1c3bdb0558839552315720068c5d355819e4a1320fbd677d0ffcf", + "railiance-platform/helm/apps-pg-cluster.yaml": "81e594601238f9c39bead395ffe5fde41e72377a5cdc704b63e40ed201d2b6f5", "railiance-platform/helm/apps-pg-2-cluster.yaml": "e9e615a0b5de8597a5501b2d8740a763ba3622cbebb765ce651236c2175de942", "rapp-postgres/consumers/audit-core.yaml": "a2db53f901df056546b2c028f287f8fc50486e6e6cd81026b9e926b6b64272b3", + "rapp-postgres/consumers/canned-prompts.yaml": "97d4b439cec7e788b7c1fa2c7181c2606ed098dcf37a38c1f73f7393e6046429", "rapp-postgres/consumers/core-hub.yaml": "8fe3e8b4488548b2f5e01f1d46edd9fd5fe4f0e4f65a3821f172adbfb5ae1d84", "rapp-postgres/consumers/isolation-probe.yaml": "8ea24d3bd04ab683b47ceff2586932ea4fdb0d45c49896167bb5b352a67a5cfb", "rapp-postgres/consumers/sbom-nexus.yaml": "0b21d05fe7b9d21cbc9eb761f455329739ceb71f8dd3d1200971426962c11236", "rapp-postgres/consumers/tenant-engine.yaml": "92f36d587f001c86ff401aedd25cb49fa171d49e000b0a6ab551e182e5c11448", "railiance-platform/tools/verify_apps_pg_capacity.py": "b571ec63b0a7501dabba37a84af9d2cfcb7737ed891a7ea2ab05c54010b1893e", - "railiance-platform/assurance/placement-owners.json": "b9a52f755552dc4bb83536dca8aadca691a4f8bc47089e9e1962902abcec7aa2", + "railiance-platform/assurance/placement-owners.json": "e71037f868e3264d43ff5c42511558272a89926dc8dc2f7ee952e018020b4891", "rapp-postgres/scripts/render_consumers.py": "2dcfd68f94a3d6c7e1072c4a55a497b1697eab30acaecae53865c9b1a596773a", "rapp-postgres/helm/platform-pg-cluster.yaml": "461da0d43e61a4ddca29d6e98080b8b1642bd3303f3395da1b0beb28775c2c5c", "rapp-postgres/helm/platform-pg-2-cluster.yaml": "06b92a76189b1a33d1eee3f5c835a3e94269ad7b040383c04e8028c2c3fe766a" diff --git a/assurance/placement-owners.json b/assurance/placement-owners.json index 12e5d85..8f70a62 100644 --- a/assurance/placement-owners.json +++ b/assurance/placement-owners.json @@ -11,6 +11,12 @@ "service_class": "interactive", "evidence": "docs/evidence/RPF-WP-0019-isolation-2026-08-20.md" }, + "vergabe_demo_company": { + "owner": "vergabe-demo-company", + "cell": "apps-pg", + "service_class": "interactive", + "evidence": "workplans/RPF-WP-0039-vergabe-demo-company-database.md" + }, "audit-core": { "owner": "audit-core", "cell": "platform-pg", diff --git a/assurance/service-records.json b/assurance/service-records.json index 4f07b98..e6ff22e 100644 --- a/assurance/service-records.json +++ b/assurance/service-records.json @@ -11,7 +11,8 @@ "operator_role": "attended platform operator; application proof by consumer owner", "consumers": [ "vergabe", - "coulomb_social" + "coulomb_social", + "vergabe_demo_company" ], "failure_domain": "single-node railiance01", "availability": { @@ -76,7 +77,8 @@ "package_or_consumer_owner": "rapp-postgres", "operator_role": "attended platform operator; application proof by consumer owner", "consumers": [ - "sbom-nexus" + "sbom-nexus", + "canned-prompts" ], "failure_domain": "single-node railiance01", "availability": { diff --git a/docs/apps-pg.md b/docs/apps-pg.md index 790c604..e95f9a6 100644 --- a/docs/apps-pg.md +++ b/docs/apps-pg.md @@ -23,7 +23,8 @@ it as a consumer credential. **Two reachable clusters each carry a CNPG `Cluster` named `apps-pg` in a namespace named `databases`.** The one this document describes is **railiance01** (k3s v1.35.1) — it also carries `platform-pg` and `forgejo-db`, -and holds both apps-pg consumers (`vergabe_db`, `coulomb_social_db`). The other +and holds the three apps-pg consumers (`vergabe_db`, `coulomb_social_db`, +`vergabe_demo_company`). The other cluster carries `gitea-db` and only one apps-pg consumer. Selecting the right one by kubeconfig filename is not safe: `KUBECONFIG` is an @@ -115,6 +116,7 @@ the standard PostgreSQL, instance manager, and metrics ports. | --- | --- | --- | --- | --- | | vergabe-teilnahme | `vergabe` | `vergabe_db` | `vergabe-app-credentials` | `vergabe-teilnahme` | | coulomb-social | `coulomb_social` | `coulomb_social_db` | `coulomb-social-app-credentials` | `coulomb-social` | +| vergabe-demo-company | `vergabe_demo_company` | `vergabe_demo_company` | `vergabe-demo-company-credentials` | `vergabe-demo-company` | Bootstrapped 2026-08-09 on railiance01: cluster healthy; both Database CRs applied; coulomb-social connectivity smoke from labeled consumer ns OK. @@ -152,3 +154,23 @@ labor, allocation drivers) is published under isolation evidence captured on 2026-08-20 is in `docs/evidence/RPF-WP-0019-backup-restore-2026-08-20.md` and `docs/evidence/RPF-WP-0019-isolation-2026-08-20.md`. + + +## Fresh Vergabe demo consumer — 2026-09-12 + +RPF-WP-0039 admits the third consumer, vergabe_demo_company, in namespace +vergabe-demo-company. Database and role both use that name; the provider Secret +is databases/vergabe-demo-company-credentials and the exact consumer runtime +bundle is vergabe-demo-company/vergabe-demo-env. The existing vergabe_db remains +separate. No bootstrap or other consumer credentials are reused. + +`tools/provision-vergabe-demo.py inspect` returns metadata only. The dry-run/apply +modes require the observed cluster resourceVersion, append only the fixed third +role, and refuse conflicting existing state. Credentials are generated in memory +and delivered through captured child stdin/stdout, never arguments or files. +This is the documented interim platform-administered Kubernetes/CNPG custody +lane; it does not claim OpenBao lifecycle automation. Source declarations are +helm/vergabe-demo-company-{role,database}.yaml; scoped role/database controls are +helm/vergabe-demo-company-controls.sql. Do not apply historical consumer SQL to +accomplish this onboarding. See RAPPS-WP-0014 for live deployment and remaining +coherent recovery acceptance. The declared cell is now full at three consumers. diff --git a/docs/placement-policy.md b/docs/placement-policy.md index c5047e9..c9716c7 100644 --- a/docs/placement-policy.md +++ b/docs/placement-policy.md @@ -83,6 +83,7 @@ triggers. It is not the same role as the operator of the cluster. | `forgejo` | `forgejo-db` | P2 | this repo | — | single consumer | | `vergabe` | `apps-pg` | P1 | `vergabe-teilnahme` | this repo | 14 live boundary/control probes | | `coulomb_social` | `apps-pg` | P1 | `coulomb-social` | this repo | 14 live boundary/control probes | +| `vergabe_demo_company` | `apps-pg` | P1 | `vergabe-demo-company` | this repo | RPF-WP-0039 provisioning and SQL isolation readback | ### 3.1 The two apps-pg P1 levels are now evidenced @@ -110,7 +111,7 @@ artifact. Saturating `coulomb_social` bound at 20 and rejected an excess connection while five `vergabe` queries remained available. Five-query operator-path wall time rose from 5,285ms to 8,146ms (54.1%). -Both consumers are `interactive`, but neither publishes a numeric database +The three consumers are `interactive`, but none publishes a numeric database latency objective, and the measurement includes five separate `kubectl exec` and `psql` startups. The artifact therefore evidences P1 boundary and continued availability, not a latency SLO or CPU/memory fairness. That limit is part of @@ -148,11 +149,11 @@ one". "Provision a second cluster" is an answer; "revisit at the time" is not. | --- | --- | --- | --- | | `platform-pg` | 4 declarations (package ADR-0004) | `platform-pg-2`, deployed | 4/4 including probe and Core Hub; fifth refused | | `platform-pg-2` | 4 declarations (same package validator) | No third cell approved | 1/4: sbom-nexus; owner admission and restore completed 2026-08-22 | -| `apps-pg` | 3 consumers; 20 connections each, 1Gi memory binds first | `apps-pg-2` | 2/3; overflow must exist before a 4th | +| `apps-pg` | 3 consumers; 20 connections each, 1Gi memory binds first | `apps-pg-2` | 3/3; overflow must exist before a 4th | | `forgejo-db` | n/a — single consumer | n/a | — | -Source and live metadata reviewed 2026-09-05: platform-pg is full; apps-pg -remains 2/3 and apps-pg-2 is still unapplied. The generated +Source and live metadata reviewed 2026-09-15: platform-pg is full; apps-pg +is 3/3 after RPF-WP-0039 and apps-pg-2 is still unapplied. The generated `assurance/admission-baseline.json` binds these disclosures to the actual owner validators/declarations. `make assurance-admission` refuses source drift. Package acceptance, rather than this table, authorizes consumer provisioning. diff --git a/docs/s3-consumer-interfaces.md b/docs/s3-consumer-interfaces.md index 02a4d8f..f159c05 100644 --- a/docs/s3-consumer-interfaces.md +++ b/docs/s3-consumer-interfaces.md @@ -62,7 +62,7 @@ before the corresponding live rollout: | Property | Value | Note | | --- | --- | --- | | `max_connections` | 100 | Explicit aggregate cap. Forty connections remain outside the declared three-consumer ceiling for operator/CNPG overhead. | -| Per-consumer connections | 20 | CNPG `managed.roles[].connectionLimit`, for both current roles. | +| Per-consumer connections | 20 | CNPG `managed.roles[].connectionLimit`, for all three current roles. | | `statement_timeout` | 15s | Per role; idempotent operator SQL because CNPG 1.28 has no managed-role settings field. | | `idle_in_transaction_session_timeout` | 15s | Per role, same enforcement path. | | Database boundary | own database only | `CONNECT` and `TEMPORARY` are revoked from `PUBLIC`; each consumer role receives them only on its own database. | @@ -125,7 +125,7 @@ freshness budgets are not SLAs or approval to deploy schedules. Consumers that require stronger guarantees need a placement/recovery decision. Current source occupancy: platform-pg 4/4 including the isolation probe, -platform-pg-2 1/4 for sbom-nexus, apps-pg 2/3, apps-pg-2 0/3 and unapplied. +platform-pg-2 2/4 for sbom-nexus and canned-prompts, apps-pg 3/3, apps-pg-2 0/3 and unapplied. All three deployed cells declare one instance, 1Gi memory limit, 100 aggregate connections and 30-day retention; live metadata matched on 2026-09-05. The second platform cell's approved restore took 51 seconds on 2026-08-22; this diff --git a/docs/service-assurance.md b/docs/service-assurance.md index 156c664..5eb2731 100644 --- a/docs/service-assurance.md +++ b/docs/service-assurance.md @@ -92,8 +92,8 @@ between effective package retention and the actual CR. Hashes pin every consumed source artifact; changing package code or declarations requires review before refreshing the baseline. Do not blindly regenerate it in CI. -Source occupancy: apps-pg 2/3, apps-pg-2 0/3 (not deployed), platform-pg 4/4 -including the probe, platform-pg-2 1/4 for sbom-nexus. Tenant-engine's PostgreSQL +Source occupancy: apps-pg 3/3, apps-pg-2 0/3 (not deployed), platform-pg 4/4 +including the probe, platform-pg-2 2/4 for sbom-nexus and canned-prompts. Tenant-engine's PostgreSQL cutover completed 2026-08-21; its old SQLite-current claim was stale. Platform-pg-2 was accepted and restored on 2026-08-22; it is no longer merely named overflow. Core Hub is latency-critical alongside audit-core batch work; no service-class diff --git a/helm/apps-pg-cluster.yaml b/helm/apps-pg-cluster.yaml index b009627..2d84ae4 100644 --- a/helm/apps-pg-cluster.yaml +++ b/helm/apps-pg-cluster.yaml @@ -59,6 +59,16 @@ spec: connectionLimit: 20 passwordSecret: name: coulomb-social-app-credentials + - name: vergabe_demo_company + ensure: present + login: true + inherit: false + superuser: false + createdb: false + createrole: false + connectionLimit: 20 + passwordSecret: + name: vergabe-demo-company-credentials postgresql: parameters: max_connections: "100" diff --git a/helm/apps-pg-consumer-controls.sql b/helm/apps-pg-consumer-controls.sql index 125c557..5ee600c 100644 --- a/helm/apps-pg-consumer-controls.sql +++ b/helm/apps-pg-consumer-controls.sql @@ -1,6 +1,8 @@ -- Idempotent controls not expressible in the installed CNPG managed.roles CRD. -- Apply as the controlled apps-pg cluster administrator only after notifying --- both consumers of the published limits in docs/s3-consumer-interfaces.md. +-- bound consumers of the published limits in docs/s3-consumer-interfaces.md. +-- The third consumer is scoped separately in helm/vergabe-demo-company-controls.sql; +-- do not reuse this historical file to onboard it. ALTER ROLE vergabe SET statement_timeout = '15s'; ALTER ROLE vergabe SET idle_in_transaction_session_timeout = '15s'; ALTER ROLE coulomb_social SET statement_timeout = '15s'; diff --git a/helm/apps-pg-databases.yaml b/helm/apps-pg-databases.yaml index afb28a7..f5bb57e 100644 --- a/helm/apps-pg-databases.yaml +++ b/helm/apps-pg-databases.yaml @@ -36,3 +36,20 @@ spec: name: coulomb_social_db owner: coulomb_social ensure: present +--- +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: vergabe-demo-company-db # RPF-WP-0039 + namespace: databases + labels: + app.kubernetes.io/name: apps-pg + app.kubernetes.io/component: database-instance + railiance.io/layer: s3-platform + railiance.io/consumer: vergabe-demo-company +spec: + cluster: + name: apps-pg + name: vergabe_demo_company + owner: vergabe_demo_company + ensure: present diff --git a/helm/vergabe-demo-company-controls.sql b/helm/vergabe-demo-company-controls.sql new file mode 100644 index 0000000..ed5fdbf --- /dev/null +++ b/helm/vergabe-demo-company-controls.sql @@ -0,0 +1,5 @@ +-- Only the fresh invited demo consumer. Never touches historical vergabe_db. +ALTER ROLE vergabe_demo_company SET statement_timeout = '15s'; +ALTER ROLE vergabe_demo_company SET idle_in_transaction_session_timeout = '15s'; +REVOKE CONNECT, TEMPORARY ON DATABASE vergabe_demo_company FROM PUBLIC; +GRANT CONNECT, TEMPORARY ON DATABASE vergabe_demo_company TO vergabe_demo_company; diff --git a/helm/vergabe-demo-company-database.yaml b/helm/vergabe-demo-company-database.yaml new file mode 100644 index 0000000..5fed1bf --- /dev/null +++ b/helm/vergabe-demo-company-database.yaml @@ -0,0 +1,10 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: vergabe-demo-company-db + namespace: databases +spec: + cluster: + name: apps-pg + name: vergabe_demo_company + owner: vergabe_demo_company diff --git a/helm/vergabe-demo-company-role.yaml b/helm/vergabe-demo-company-role.yaml new file mode 100644 index 0000000..1275a89 --- /dev/null +++ b/helm/vergabe-demo-company-role.yaml @@ -0,0 +1,11 @@ +# Exact managed.roles entry; append through the bounded owner helper. +name: vergabe_demo_company +ensure: present +login: true +inherit: false +superuser: false +createdb: false +createrole: false +connectionLimit: 20 +passwordSecret: + name: vergabe-demo-company-credentials diff --git a/tests/test_apps_pg_capacity.py b/tests/test_apps_pg_capacity.py index 973137b..d70efbf 100644 --- a/tests/test_apps_pg_capacity.py +++ b/tests/test_apps_pg_capacity.py @@ -22,7 +22,7 @@ MANIFESTS = [ROOT / "helm/apps-pg-cluster.yaml", ROOT / "helm/apps-pg-2-cluster. def test_reviewed_apps_pg_cells_fit_capacity_and_use_distinct_backups() -> None: - assert verify(MANIFESTS) == {"apps-pg": 2, "apps-pg-2": 0} + assert verify(MANIFESTS) == {"apps-pg": 3, "apps-pg-2": 0} def test_fourth_role_is_rejected(tmp_path: Path) -> None: diff --git a/tests/test_vergabe_demo_provisioning.py b/tests/test_vergabe_demo_provisioning.py new file mode 100644 index 0000000..f4b1642 --- /dev/null +++ b/tests/test_vergabe_demo_provisioning.py @@ -0,0 +1,87 @@ +import importlib.util +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('provision', Path(__file__).resolve().parents[1] / 'tools/provision-vergabe-demo.py') +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + + +class DemoProvisioningTests(unittest.TestCase): + def cluster(self): + return {'metadata': {'uid': 'synthetic', 'resourceVersion': '7'}, + 'spec': {'managed': {'roles': [{'name': 'vergabe', 'connectionLimit': 20}, + {'name': 'coulomb_social', 'connectionLimit': 20}]}}} + + def test_patch_only_appends_reviewed_third_role_with_revision_tests(self): + role, _ = m.declarations() + before = self.cluster() + result = m.role_patch(before, role) + self.assertEqual([x['op'] for x in result], ['test', 'test', 'test', 'add']) + self.assertEqual(result[-1], {'op': 'add', 'path': '/spec/managed/roles/-', 'value': role}) + self.assertEqual(len(before['spec']['managed']['roles']), 2) + + def test_refuses_fourth_consumer_duplicates_and_relaxed_limits(self): + role, _ = m.declarations() + for change in ['fourth', 'duplicate', 'limit']: + before = self.cluster() + roles = before['spec']['managed']['roles'] + if change == 'fourth': + roles.append({'name': 'another', 'connectionLimit': 20}) + elif change == 'duplicate': + roles.append(roles[0]) + else: + roles[0]['connectionLimit'] = -1 + with self.subTest(change=change), self.assertRaises(m.Refused): + m.role_patch(before, role) + + def test_exact_owned_role_is_noop_but_conflict_refused(self): + role, _ = m.declarations() + before = self.cluster() + before['spec']['managed']['roles'].append(dict(role)) + self.assertIsNone(m.role_patch(before, role)) + before['spec']['managed']['roles'][-1]['superuser'] = True + with self.assertRaises(m.Refused): + m.role_patch(before, role) + + def test_existing_secret_requires_owner_and_exact_keys(self): + obj = {'metadata': {'labels': dict(m.OWNER)}, 'data': {'username': 'x', 'password': 'x'}} + m.valid_owned_secret(obj, ['username', 'password']) + for changed in [dict(obj, data=dict(obj['data'], extra='x')), + dict(obj, metadata={'labels': {}}), + dict(obj, metadata={'labels': m.OWNER, 'ownerReferences': [{'uid': 'other'}]})]: + with self.assertRaises(m.Refused): + m.valid_owned_secret(changed, ['username', 'password']) + + def test_sensitive_child_error_is_not_forwarded(self): + result = SimpleNamespace(returncode=1, stdout='synthetic-secret', stderr='synthetic-secret') + with patch.object(m.subprocess, 'run', return_value=result), self.assertRaises(m.Refused) as caught: + m.kube(['create', '-f', '-'], {'stringData': {'password': 'synthetic-secret'}}) + self.assertEqual(str(caught.exception), 'kubernetes_operation_failed') + + def test_credentials_travel_on_stdin_not_arguments(self): + result = SimpleNamespace(returncode=0, stdout='{}', stderr='') + with patch.object(m.subprocess, 'run', return_value=result) as run: + m.kube(['create', '-f', '-'], {'stringData': {'password': 'synthetic-secret'}}) + args, kwargs = run.call_args + self.assertNotIn('synthetic-secret', str(args)) + self.assertIn('synthetic-secret', kwargs['input']) + self.assertTrue(kwargs['capture_output']) + + def test_source_declarations_match_tracked_cluster_and_database_manifests(self): + import yaml + role, database = m.declarations() + cluster = yaml.safe_load((m.ROOT / 'helm/apps-pg-cluster.yaml').read_text()) + tracked = [item for item in cluster['spec']['managed']['roles'] if item['name'] == m.NAME] + self.assertEqual(tracked, [role]) + documents = list(yaml.safe_load_all((m.ROOT / 'helm/apps-pg-databases.yaml').read_text())) + named = next(item for item in documents if item['metadata']['name'] == 'vergabe-demo-company-db') + self.assertEqual(named['spec']['name'], database['spec']['name']) + self.assertEqual(named['spec']['owner'], database['spec']['owner']) + self.assertEqual(named['spec']['cluster'], database['spec']['cluster']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/provision-vergabe-demo.py b/tools/provision-vergabe-demo.py new file mode 100644 index 0000000..d432663 --- /dev/null +++ b/tools/provision-vergabe-demo.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Bounded apps-pg demo consumer provisioning; never emits credential values.""" +import argparse +import base64 +import json +from pathlib import Path +import secrets +import subprocess +from urllib.parse import quote + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' +NAME = 'vergabe_demo_company' +NS = 'vergabe-demo-company' +SECRET = 'vergabe-demo-company-credentials' +RUNTIME = 'vergabe-demo-env' +OWNER = {'railiance.io/provisioning-workplan': 'RPF-WP-0039'} +KUBE = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope', + '--server', 'https://127.0.0.1:16444', '--request-timeout=20s'] + + +class Refused(Exception): + pass + + +def require(condition, reason): + if not condition: + raise Refused(reason) + + +def kube(args, value=None, *, raw=False): + # kubectl error bodies may contain Secret material; neither stream escapes. + result = subprocess.run(KUBE + args, input=None if value is None else json.dumps(value), + capture_output=True, text=True, timeout=30) + require(result.returncode == 0, 'kubernetes_operation_failed') + if raw: + return result.stdout + return json.loads(result.stdout) if result.stdout.strip() else None + + +def get(kind, name, namespace): + return kube(['-n', namespace, 'get', kind, name, '--ignore-not-found', '-o', 'json']) + + +def metadata(obj): + return {k: obj['metadata'][k] for k in ['name', 'namespace', 'uid', 'resourceVersion']} + + +def declarations(): + role = yaml.safe_load((ROOT / 'helm/vergabe-demo-company-role.yaml').read_text()) + database = yaml.safe_load((ROOT / 'helm/vergabe-demo-company-database.yaml').read_text()) + require(role == {'name': NAME, 'ensure': 'present', 'login': True, 'inherit': False, + 'superuser': False, 'createdb': False, 'createrole': False, + 'connectionLimit': 20, 'passwordSecret': {'name': SECRET}}, + 'role_declaration_changed') + require(database['metadata'] == {'name': 'vergabe-demo-company-db', 'namespace': 'databases'} + and database['spec'] == {'cluster': {'name': 'apps-pg'}, 'name': NAME, 'owner': NAME}, + 'database_declaration_changed') + return role, database + + +def valid_owned_secret(obj, keys): + require(obj['metadata'].get('labels', {}).get(next(iter(OWNER))) == next(iter(OWNER.values())) + and not obj['metadata'].get('ownerReferences') and set(obj['data']) == set(keys), + 'existing_secret_is_not_exact_owned_bundle') + + +def secret_object(name, namespace, fields): + return {'apiVersion': 'v1', 'kind': 'Secret', + 'metadata': {'name': name, 'namespace': namespace, + 'labels': dict(OWNER, **{'cnpg.io/reload': 'true'})}, + 'type': 'kubernetes.io/basic-auth' if name == SECRET else 'Opaque', 'stringData': fields} + + +def role_patch(cluster, role): + roles = cluster['spec']['managed']['roles'] + names = [x['name'] for x in roles] + require(len(names) == len(set(names)) and len(roles) <= 3, 'consumer_ceiling_or_duplicate') + require(all(x.get('connectionLimit') == 20 for x in roles), 'consumer_limits_changed') + if NAME in names: + require(roles[names.index(NAME)] == role, 'existing_role_differs') + return None + require(set(names) == {'vergabe', 'coulomb_social'} and len(roles) == 2, + 'unexpected_consumers') + return [{'op': 'test', 'path': '/metadata/uid', 'value': cluster['metadata']['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': cluster['metadata']['resourceVersion']}, + {'op': 'test', 'path': '/spec/managed/roles', 'value': roles}, + {'op': 'add', 'path': '/spec/managed/roles/-', 'value': role}] + + +def run(mode, expected_revision): + require(kube(['get', 'namespace', 'kube-system', '-o', 'json'])['metadata']['uid'] == CLUSTER_UID, + 'wrong_cluster') + namespace = get('namespace', NS, NS) + require(namespace and namespace['metadata']['labels'].get('railiance.io/postgres-client') == 'apps-pg', + 'namespace_not_bound') + role, database = declarations() + cluster = get('cluster', 'apps-pg', 'databases') + patch = role_patch(cluster, role) + db = get('database', 'vergabe-demo-company-db', 'databases') + require(not db or all(db['spec'].get(k) == v for k, v in database['spec'].items()), + 'database_resource_conflict') + primary = cluster['status']['currentPrimary'] + require(primary.startswith('apps-pg-'), 'unexpected_primary') + sql = ("SELECT 'role' FROM pg_roles WHERE rolname='vergabe_demo_company'; " + "SELECT 'database' FROM pg_database WHERE datname='vergabe_demo_company';") + existing = kube(['-n', 'databases', 'exec', primary, '-c', 'postgres', '--', + 'psql', '-U', 'postgres', '-d', 'postgres', '-At', '-c', sql], raw=True).splitlines() + require('role' not in existing or patch is None, 'unmanaged_existing_role') + require('database' not in existing or db is not None, 'unmanaged_existing_database') + # These are the two newly named bundles for this lane, never bootstrap or historical secrets. + provider = get('secret', SECRET, 'databases') + consumer = get('secret', RUNTIME, NS) + if provider: + valid_owned_secret(provider, ['username', 'password']) + require(base64.b64decode(provider['data']['username']).decode() == NAME, 'username_conflict') + if consumer: + valid_owned_secret(consumer, ['SECRET_KEY', 'DATABASE_URL']) + require(provider is not None, 'runtime_without_provider') + receipt = {'mode': mode, 'cluster': metadata(cluster), 'managed_consumers': len(cluster['spec']['managed']['roles']), + 'role_change_needed': patch is not None, 'database_exists': db is not None, + 'provider_exists': provider is not None, 'runtime_exists': consumer is not None, + 'values_emitted': False} + if mode == 'inspect': + return receipt + require(expected_revision == cluster['metadata']['resourceVersion'], 'cluster_revision_changed') + password = base64.b64decode(provider['data']['password']).decode() if provider else secrets.token_urlsafe(48) + uri = f'postgresql://{NAME}:{quote(password, safe="")}@apps-pg-rw.databases.svc.cluster.local:5432/{NAME}' + if consumer: + require(base64.b64decode(consumer['data']['DATABASE_URL']).decode() == uri, + 'runtime_database_binding_conflict') + objects = [] + if not provider: + objects.append(secret_object(SECRET, 'databases', {'username': NAME, 'password': password})) + if not consumer: + objects.append(secret_object(RUNTIME, NS, {'SECRET_KEY': secrets.token_urlsafe(64), 'DATABASE_URL': uri})) + dry = ['--dry-run=server'] if mode == 'dry-run' else [] + for obj in objects: + result = kube(['create', '-f', '-', '-o', 'json', *dry], obj) + expected = {k: base64.b64encode(v.encode()).decode() for k, v in obj['stringData'].items()} + require(result['data'] == expected, 'secret_delivery_mismatch') + if patch: + result = kube(['-n', 'databases', 'patch', 'cluster', 'apps-pg', '--type=json', + '--patch-file=/dev/stdin', '-o', 'json', *dry], patch) + require(result['spec'] == dict(cluster['spec'], managed=dict(cluster['spec']['managed'], + roles=cluster['spec']['managed']['roles'] + [role])), 'unrelated_cluster_spec_changed') + if not db: + kube(['create', '-f', '-', '-o', 'json', *dry], database) + receipt.update(result='dry-run-passed' if mode == 'dry-run' else 'provisioned', + provider_secret='databases/' + SECRET, runtime_secret=NS + '/' + RUNTIME, + next='Wait for Database applied, then apply only the demo controls SQL and verify consumer login.') + return receipt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=['inspect', 'dry-run', 'apply']) + parser.add_argument('--expected-cluster-resource-version') + args = parser.parse_args() + try: + print(json.dumps(run(args.mode, args.expected_cluster_resource_version), sort_keys=True)) + return 0 + except Refused as error: + print(json.dumps({'result': 'refused', 'reason': str(error)})) + except Exception: + print(json.dumps({'result': 'failed', 'reason': 'contained_operation_failed'})) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/workplans/RPF-WP-0039-vergabe-demo-company-database.md b/workplans/RPF-WP-0039-vergabe-demo-company-database.md new file mode 100644 index 0000000..4ca7096 --- /dev/null +++ b/workplans/RPF-WP-0039-vergabe-demo-company-database.md @@ -0,0 +1,71 @@ +--- +id: RPF-WP-0039 +type: workplan +title: "Provision the isolated Vergabe demo-company database consumer" +domain: financials +repo: railiance-platform +status: finished +owner: the-custodian +topic_slug: railiance +created: "2026-09-12" +updated: "2026-09-15" +related: [RAPPS-WP-0014, VERGABE-WP-0019, CUST-WP-0071] +state_hub_workstream_id: "fc52be19-470c-572e-92a6-661e054db50a" +--- + +The operator selected a fresh demo tenant, accepted the 60m application request, +and supplied the product DNS record. The platform-owned apps-pg lane permits +controlled provisioning until end-to-end OpenBao onboarding exists. This consumer +is the third of the declared maximum three; no bootstrap or historical consumer +credential is reused. No additional OpenBao control-plane login is needed for +this existing Kubernetes/CNPG lane. + +## Provision the fresh database and contained runtime custody + +```task +id: RPF-WP-0039-T01 +status: done +priority: high +state_hub_task_id: "de793b19-b509-5de5-b816-5ca54246e901" +``` + +Bind only Railiance01 UID a553c742-0115-43d4-99a4-a5ca56fe0786, databases/apps-pg, +role/database vergabe_demo_company, databases/vergabe-demo-company-credentials, +and vergabe-demo-company/vergabe-demo-env. Generate new credentials in memory, +transport only through captured child stdin/stdout, and print metadata only. +Refuse existing conflicting objects, unmanaged matching database/role, changed +cluster revision, more than three consumers and unrelated configuration changes. +Apply the managed role and Database declaration, 20-connection ceiling and +15-second role timeouts; revoke PUBLIC database access. Preserve vergabe_db and +coulomb_social_db. Record the delivery and actual consumer connection evidence. + +## Hand application recovery and measured sizing to their existing owners + +```task +id: RPF-WP-0039-T02 +status: done +priority: high +state_hub_task_id: "152a50e1-da02-5a18-a796-0919edeb2b46" +``` + +Verify CNPG application and role isolation and current apps-pg backup metadata. +RAPPS-WP-0014-T03 owns the consistent database/media/issue-state recovery point +and isolated restore, VERGABE-WP-0019-T06 owns identity handoff, and CUST-WP-0071 +owns demand measurement and weekly resource review. Keep these records live; +provisioning success alone does not admit customer data or complete recovery. + + +Completed 2026-09-12. Six synthetic custody/guard tests, server dry-run and the +capacity checker pass (apps-pg 3/3). Initial Cluster UID +834d8a5e-ea2c-4b12-ad97-5561af348953/resourceVersion 59692221 guarded the append. +The Database CR reports applied, all fresh migrations completed, and actual +application connection reports database/role vergabe_demo_company. SQL readback +confirms non-privileged role, limits/timeouts, own-database CONNECT true and +historical/bootstrap database CONNECT false. No bootstrap credential was read. +The latest base backup is 2026-09-11T02:15:11Z and predates this new database; +continuous archival and that receipt are not a coherent demo recovery proof. + +Residuals remain live before this plan is finished: RAPPS-WP-0014-T03 for matched +backup/restart/isolated restore, VERGABE-WP-0019-T06 for welcome/identity mapping, +and CUST-WP-0071 for measured demand and final weekly review. The prototype keeps +its approved 60m request; no fourth apps-pg consumer is admitted.