Prepare durable isolated Vergabe pilot deployment and recovery

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-11 16:35:14 +02:00
parent eff457ef52
commit 959eba637f
11 changed files with 408 additions and 9 deletions

View file

@ -4,7 +4,7 @@ description: |
Vergabe Teilnahme — internal Django tender/bid management web app.
Single-instance v1 deployment; HA and canary are deferred.
type: application
version: 0.1.0
version: 0.2.0
appVersion: "0.1.0"
keywords:
- django

View file

@ -21,8 +21,34 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "vergabe.image" -}}
{{- if .Values.image.digest -}}
{{- if not (regexMatch "^sha256:[a-f0-9]{64}$" .Values.image.digest) -}}
{{- fail "image.digest must be a sha256 OCI digest" -}}
{{- end -}}
{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}}
{{- else -}}
{{- if not .Values.image.tag -}}
{{- fail "image.tag is required — pin it in helm/vergabe-teilnahme-values.yaml" -}}
{{- end -}}
{{- printf "%s:%s" .Values.image.repository .Values.image.tag -}}
{{- end -}}
{{- end -}}
{{- define "vergabe.validatePilot" -}}
{{- if .Values.pilot.enabled -}}
{{- if ne (int .Values.replicaCount) 1 -}}
{{- fail "invited pilot requires exactly one application replica" -}}
{{- end -}}
{{- if or (not .Values.persistence.media.enabled) (not .Values.persistence.appState.enabled) -}}
{{- fail "invited pilot requires persistent media and appState" -}}
{{- end -}}
{{- if not .Values.image.digest -}}
{{- fail "invited pilot requires an immutable image.digest" -}}
{{- end -}}
{{- $mediaClaim := default (printf "%s-media" (include "vergabe.fullname" .)) .Values.persistence.media.existingClaim -}}
{{- $stateClaim := default (printf "%s-app-state" (include "vergabe.fullname" .)) .Values.persistence.appState.existingClaim -}}
{{- if eq $mediaClaim $stateClaim -}}
{{- fail "media and appState must use distinct claims; operational state must not be downloadable" -}}
{{- end -}}
{{- end -}}
{{- end -}}

View file

@ -1,3 +1,4 @@
{{- include "vergabe.validatePilot" . }}
apiVersion: apps/v1
kind: Deployment
metadata:
@ -8,10 +9,14 @@ spec:
selector:
matchLabels: {{- include "vergabe.selectorLabels" . | nindent 6 }}
strategy:
{{- if or .Values.persistence.media.enabled .Values.persistence.appState.enabled }}
type: Recreate
{{- else }}
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
{{- end }}
template:
metadata:
labels: {{- include "vergabe.selectorLabels" . | nindent 8 }}
@ -59,16 +64,29 @@ spec:
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
{{- end }}
resources: {{- toYaml .Values.resources | nindent 12 }}
{{- if .Values.persistence.media.enabled }}
{{- if or .Values.persistence.media.enabled .Values.persistence.appState.enabled }}
volumeMounts:
{{- if .Values.persistence.media.enabled }}
- name: media
mountPath: /app/media
{{- end }}
{{- if .Values.persistence.appState.enabled }}
- name: app-state
mountPath: /app/.issue-facade
{{- end }}
{{- end }}
{{- if .Values.persistence.media.enabled }}
{{- if or .Values.persistence.media.enabled .Values.persistence.appState.enabled }}
volumes:
{{- if .Values.persistence.media.enabled }}
- name: media
persistentVolumeClaim:
claimName: {{ include "vergabe.fullname" . }}-media
claimName: {{ default (printf "%s-media" (include "vergabe.fullname" .)) .Values.persistence.media.existingClaim }}
{{- end }}
{{- if .Values.persistence.appState.enabled }}
- name: app-state
persistentVolumeClaim:
claimName: {{ default (printf "%s-app-state" (include "vergabe.fullname" .)) .Values.persistence.appState.existingClaim }}
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View file

@ -11,12 +11,14 @@ spec:
protocol: TCP
name: http
selector: {{- include "vergabe.selectorLabels" . | nindent 4 }}
{{- if .Values.persistence.media.enabled }}
{{- if and .Values.persistence.media.enabled (not .Values.persistence.media.existingClaim) }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "vergabe.fullname" . }}-media
annotations:
helm.sh/resource-policy: keep
labels: {{- include "vergabe.labels" . | nindent 4 }}
spec:
storageClassName: {{ .Values.persistence.media.storageClass }}
@ -25,3 +27,19 @@ spec:
requests:
storage: {{ .Values.persistence.media.size }}
{{- end }}
{{- if and .Values.persistence.appState.enabled (not .Values.persistence.appState.existingClaim) }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "vergabe.fullname" . }}-app-state
annotations:
helm.sh/resource-policy: keep
labels: {{- include "vergabe.labels" . | nindent 4 }}
spec:
storageClassName: {{ .Values.persistence.appState.storageClass }}
accessModes: [{{ .Values.persistence.appState.accessMode }}]
resources:
requests:
storage: {{ .Values.persistence.appState.size }}
{{- end }}

View file

@ -1,10 +1,15 @@
image:
repository: forgejo.coulomb.social/coulomb/vergabe-teilnahme
tag: "" # required; pinned via helm/vergabe-teilnahme-values.yaml
digest: "" # preferred; takes precedence over tag
pullPolicy: IfNotPresent
replicaCount: 1 # v1 is single-instance; HA is deferred (RAILIANCE-WP-0002 Notes)
# An invited company pilot requires an immutable image and both data volumes.
pilot:
enabled: false
service:
type: ClusterIP
port: 80
@ -19,8 +24,7 @@ resources:
memory: 1Gi
# Env from the K8s Secret created out-of-band (vergabe-teilnahme-env).
# Holds SECRET_KEY + DATABASE_URL. Created by the operator with kubectl
# create secret generic vergabe-teilnahme-env --from-literal=...
# Holds SECRET_KEY + DATABASE_URL. Deliver via the admitted platform custody lane.
envSecretName: vergabe-teilnahme-env
# Non-secret env injected directly into the Deployment.
@ -45,14 +49,21 @@ probes:
timeoutSeconds: 5
failureThreshold: 3
# PVC for media uploads is deferred — Django MEDIA is in-pod ephemeral
# for v1. Switch to true + a storageClass once media uploads land.
# Existing installations remain opt-in. Pilot mode refuses ephemeral state.
# PVCs are retained on Helm uninstall; a verified off-host backup is still required.
persistence:
media:
enabled: false
storageClass: local-path
size: 5Gi
accessMode: ReadWriteOnce
existingClaim: ""
appState:
enabled: false
storageClass: local-path
size: 1Gi
accessMode: ReadWriteOnce
existingClaim: ""
podSecurityContext:
runAsNonRoot: true

View file

@ -0,0 +1,12 @@
{
"schema": "railiance.vergabe-pilot-inventory.v1",
"observed_at": "2026-09-11T14:27:23.565171+00:00",
"cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786",
"historical_namespace_present": false,
"matching_deployments": [],
"query_scope": "Deployment names/images on connected cluster only; databases and other hosts not inventoried",
"live_mutations": 0,
"customer_data_read": false,
"credentials_read": false,
"claim": "Historical runbook does not establish current deployment; no data-loss conclusion."
}

View file

@ -0,0 +1,96 @@
# Invited company pilot
User decision, 2026-09-11: one company, several users, manual onboarding; pricing
later. VERGABE-WP-0019 owns product acceptance. RAPPS-WP-0014 owns placement,
release and recovery. This is a review and execution contract, not a claim that
the historical deployment is still live.
## Exact release packet
Before native admission, record the following non-secret values in the company
binding. Company/host/data selection is pending; do not treat example values as
an assigned tenant.
| Binding | Required evidence |
| --- | --- |
| Company and data | Named company contact, expected users, empty workspace or explicit import source/owner |
| Placement | Cluster UID, dedicated namespace/release, admitted operator and resource capacity |
| Release | Login-protected source commit, live CI result, published OCI digest, chart commit/version |
| Public route | Assigned HTTPS hostname, TLS receipt, ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, edge login abuse control |
| Custody | Dedicated database/role, runtime Secret reference and platform delivery receipt; no values in the packet |
| Durable data | Media PVC and distinct issue-facade state PVC, database backup owner, storage class/capacity |
| Recovery/support | Consistent off-host backup, isolated restore and rollback receipt, cadence/retention, incident contact |
Use `helm/vergabe-teilnahme-pilot-values.example.yaml` as the starting point.
It intentionally has no image digest and cannot render until one is supplied.
The old published image `main-fa9f082` does not contain the new access gate.
Do not promote it merely because the older source suite passed.
```sh
helm lint charts/vergabe-teilnahme -f path/to/reviewed-company-values.yaml
helm template vergabe-teilnahme charts/vergabe-teilnahme \
--namespace reviewed-company-namespace -f path/to/reviewed-company-values.yaml
python3 -m unittest discover -s tests -p 'test_vergabe_pilot_chart.py'
```
The default resource names are intentionally unchanged; company isolation is by
separate namespace/database/claims. Do not install two company releases in the
same namespace. Resolve the registry digest before setting `image.digest`.
Run the existing owner-approved server dry-run and deployment lane against the
reviewed placement after its packet is complete.
## Persistence and recovery
Mount media at `/app/media` and issue state at `/app/.issue-facade` on distinct
claims. Never expose the issue database through MEDIA_ROOT, ingress file serving
or an object bucket used for public assets. Production document downloads route
through Django authentication. All active users belong to this single company;
there is no per-tender ACL or shared-application tenant discriminator in v1.
The chart uses Recreate with persistence to avoid overlapping old/new pods and
RWO attachment conflicts. UID/GID/fsGroup 999 match the runtime image. Helm
uninstall retains claims; namespace deletion or node loss can still destroy
local-path data. Retention annotations are not a backup.
Before customer data is admitted, create a synthetic tender, lot, task, uploaded
document and issue-facade record; capture identifiers/content checksums. Replace
the application pod and verify all data and both user accounts remain usable.
For a coherent backup, quiesce application writes through the operator's
maintenance procedure, capture PostgreSQL plus media and a consistent SQLite
backup (including any required journal state), and retain the matched recovery
point off-host. Restore to an isolated database and new claims; `existingClaim`
can select those restored claims. Repeat login, document checksum and workflow
checks there. Record elapsed recovery time, backup age, image/schema revision
and operator. Never rehearse by overwriting historical `vergabe_db`.
Inspect every release's migrations before rollback; reverting an image alone
cannot undo an incompatible schema or restore lost data. Record the previously
accepted digest and demonstrated data recovery path. Short planned interruption
is acceptable for the invited pilot once the operator and company contact agree;
HA is not claimed.
## Manual onboarding and acceptance
Use the existing Django administration via the admitted operator path to create
ordinary active members. Keep staff/superuser access with the designated
operator. Supply initial credentials over the existing private human channel;
never paste them into Git, State Hub, command arguments or chat. Members can
change passwords in the UI; operators handle reset and deactivation manually.
Do not run `seed_dev` on a pilot/customer database.
Before admitting the first users, verify HTTPS login, CSRF failure behavior,
anonymous document refusal, two separate user sessions and deactivation of an
already logged-in account. Complete the tender → lot → task/document → domain
approval → submission workflow and feedback with the company contact. Customer
support contact, incident routing and backup responsibility must be recorded.
Pricing, automated invitation email, SSO and shared tenancy can be considered
later; none is implied by this initial pilot contract.
## Current inventory — 2026-09-11
Read-only checks on the connected Railiance cluster found no namespace
`vergabe-teilnahme` and no Deployment whose name/image contains vergabe or
teilnahme. The older `docs/vergabe-teilnahme.md` is historical deployment and
recovery evidence, not proof of current placement. Database contents and other
hosts have not been inventoried; no data-loss conclusion follows from namespace
absence. RAPPS-WP-0014-T02 retains that exact inventory/admission step.

View file

@ -1,3 +1,9 @@
> Current status, 2026-09-11: this is historical deployment evidence. The checked
> Railiance cluster has no `vergabe-teilnahme` namespace or matching Deployment.
> Use [the invited-pilot contract](vergabe-teilnahme-pilot.md) and RAPPS-WP-0014
> for fresh placement, release, custody and recovery. Do not execute the older
> credential/bootstrap recipes against an unverified target.
# vergabe-teilnahme — operator runbook
Production deployment of the Django tender-management app, shipped

View file

@ -0,0 +1,20 @@
# Review-only template. Copy into the exact admitted company binding, fill the
# tested image digest/host/Secret, and use a dedicated namespace and database.
# This does not upgrade or reuse the historical vergabe_db installation.
pilot:
enabled: true
image:
digest: "" # Required; the old published image lacks the pilot access gate.
replicaCount: 1
envSecretName: vergabe-pilot-env
env:
DJANGO_SETTINGS_MODULE: vergabe_teilnahme.settings.prod
ALLOWED_HOSTS: pilot.example.invalid,localhost
CSRF_TRUSTED_ORIGINS: https://pilot.example.invalid
probes:
hostHeader: pilot.example.invalid
persistence:
media:
enabled: true
appState:
enabled: true

View file

@ -0,0 +1,110 @@
"""Render-level checks for data durability and separation; no cluster mutations.
Run with: python3 -m unittest discover -s tests -p 'test_vergabe_pilot_chart.py'
Requires Helm and PyYAML.
"""
import subprocess
import unittest
from pathlib import Path
import yaml
CHART = Path(__file__).resolve().parents[1] / 'charts/vergabe-teilnahme'
DIGEST = 'sha256:' + 'a' * 64
def render(values):
return subprocess.run(
['helm', 'template', 'pilot', str(CHART), '-n', 'pilot-test', '-f', '-'],
input=yaml.safe_dump(values), text=True, capture_output=True, check=False,
)
def pilot():
return {
'pilot': {'enabled': True},
'image': {'digest': DIGEST},
'persistence': {'media': {'enabled': True}, 'appState': {'enabled': True}},
}
class PilotChartTests(unittest.TestCase):
def objects(self, values):
result = render(values)
self.assertEqual(result.returncode, 0, result.stderr)
return list(yaml.safe_load_all(result.stdout))
def test_existing_tag_profile_retains_original_rollout(self):
objects = self.objects({'image': {'tag': 'legacy-test'}})
deployment = next(o for o in objects if o['kind'] == 'Deployment')
self.assertEqual(deployment['spec']['strategy']['type'], 'RollingUpdate')
self.assertFalse(any(o['kind'] == 'PersistentVolumeClaim' for o in objects))
def test_pilot_pins_image_and_separates_durable_state(self):
objects = self.objects(pilot())
deployment = next(o for o in objects if o['kind'] == 'Deployment')
claims = {o['metadata']['name']: o for o in objects
if o['kind'] == 'PersistentVolumeClaim'}
self.assertEqual(len(claims), 2)
self.assertEqual(deployment['spec']['strategy'], {'type': 'Recreate'})
self.assertEqual(deployment['spec']['replicas'], 1)
pod = deployment['spec']['template']['spec']
app = pod['containers'][0]
self.assertTrue(app['image'].endswith('@' + DIGEST))
self.assertEqual({v['name']: v['mountPath'] for v in app['volumeMounts']},
{'media': '/app/media', 'app-state': '/app/.issue-facade'})
for volume in pod['volumes']:
claim = claims[volume['persistentVolumeClaim']['claimName']]
self.assertEqual(claim['metadata']['annotations']['helm.sh/resource-policy'], 'keep')
self.assertEqual(pod['securityContext']['fsGroup'], 999)
def test_pilot_refuses_each_ephemeral_store(self):
for store in ('media', 'appState'):
with self.subTest(store=store):
values = pilot()
values['persistence'][store]['enabled'] = False
result = render(values)
self.assertNotEqual(result.returncode, 0)
self.assertIn('persistent media and appState', result.stderr)
def test_pilot_refuses_mutable_image_and_bad_digest(self):
for image in ({'tag': 'latest', 'digest': ''}, {'digest': 'sha256:invalid'}):
with self.subTest(image=image):
values = pilot()
values['image'] = image
self.assertNotEqual(render(values).returncode, 0)
def test_pilot_refuses_multiple_replicas(self):
values = pilot()
values['replicaCount'] = 2
result = render(values)
self.assertNotEqual(result.returncode, 0)
self.assertIn('exactly one', result.stderr)
def test_restored_claims_are_reused_without_recreation(self):
values = pilot()
for store in ('media', 'appState'):
values['persistence'][store]['existingClaim'] = 'restored-' + store.lower()
objects = self.objects(values)
self.assertFalse(any(o['kind'] == 'PersistentVolumeClaim' for o in objects))
deployment = next(o for o in objects if o['kind'] == 'Deployment')
volumes = deployment['spec']['template']['spec']['volumes']
self.assertEqual({v['persistentVolumeClaim']['claimName'] for v in volumes},
{'restored-media', 'restored-appstate'})
def test_pilot_refuses_operational_state_in_download_volume(self):
for media, state in (('shared-data', 'shared-data'),
('vergabe-teilnahme-app-state', ''),
('', 'vergabe-teilnahme-media')):
with self.subTest(media=media, state=state):
values = pilot()
values['persistence']['media']['existingClaim'] = media
values['persistence']['appState']['existingClaim'] = state
result = render(values)
self.assertNotEqual(result.returncode, 0)
self.assertIn('distinct claims', result.stderr)
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,82 @@
---
id: RAPPS-WP-0014
type: workplan
title: "Deploy and recover the first invited Vergabe company pilot"
domain: financials
repo: railiance-apps
status: active
owner: the-custodian
topic_slug: railiance
created: "2026-09-11"
updated: "2026-09-11"
related: [VERGABE-WP-0019, VERGABE-WP-0018, HFACT-WP-0001]
---
# Invited Vergabe pilot on Railiance
## Prepare a durable and immutable single-company chart
```task
id: RAPPS-WP-0014-T01
status: done
priority: high
assignee: the-custodian
```
Extend the existing media PVC support with a distinct issue-facade state PVC,
optional existing claims for restore, retained claims on Helm uninstall and
Recreate rollout when persistent local state is mounted. Pilot mode requires
one replica, both durable stores, distinct claims and a valid OCI image digest.
The legacy opt-in behavior remains available for non-pilot installations.
Seven render regression tests and Helm lint pass. Chart 0.2.0 and the review-only
values template prepare the deployment; no live resources were changed.
## Bind the exact company, release, placement, data and access
```task
id: RAPPS-WP-0014-T02
status: progress
priority: high
assignee: the-custodian
```
Consume VERGABE-WP-0019-T02's login-protected release after live CI/publication.
Record exact image/chart revision, company, user count, host/TLS, dedicated
namespace, database/role, both PVCs and admitted runtime Secret custody. Obtain
the user's empty-vs-existing-data disposition. Existing vergabe_db is not test
data. Resolve target inventory before using historical runbook names: the
checked Railiance cluster has no vergabe-teilnahme namespace or matching
Deployment on 2026-09-11. Do not infer data loss or authorization to recreate it.
Use `docs/vergabe-teilnahme-pilot.md` for the review packet. Secret creation,
operator access and placement consume existing platform lanes; they do not
create a parallel identity framework. The public edge needs an admitted login
abuse-control policy and TLS; Django's authentication gate alone is not a rate
limiter. Keep `/media/` behind the app gate. Exact customer identity/host/data
selection is pending user input; source preparation can proceed meanwhile.
## Demonstrate restart, isolated restore, rollback and operating ownership
```task
id: RAPPS-WP-0014-T03
status: wait
priority: high
assignee: the-custodian
depends_on: [RAPPS-WP-0014-T02]
blocking_reason: "Await exact placement/release/data binding before native rehearsal and admission."
```
With synthetic fixture data on the admitted deployment, prove login and health,
two-user collaboration, document upload/download and issue state across pod
replacement. Establish a consistent recovery point for PostgreSQL, media and
issue-facade SQLite; rehearse recovery into a separate database and separate
claims, repeat the workflow, and record recovery time and checksums without
customer content. Record backup owner/cadence/retention/off-host destination,
restore command revision and monitoring/incident owner. Retained local-path
PVCs are neither off-host backup nor node-failure protection.
Review upgrade/migration effects and exact rollback image/data handling. A
single-writer Recreate release has a short service interruption; do not promise
HA. Return evidence to VERGABE-WP-0019-T03/T04 before customer invitations.
Native factory-produced delivery remains VERGABE-WP-0018/HFACT-WP-0001's separate
claim. Pricing and shared-app tenancy are outside this invited-pilot milestone.