feat(statehub): add railiance deployment manifests

This commit is contained in:
2026-06-25 15:15:30 +02:00
parent 6ee5542a88
commit 434c80c2c3
16 changed files with 535 additions and 2 deletions

View File

@@ -1,7 +1,17 @@
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
PYTHON ?= python3
HELM ?= $(shell command -v helm 2>/dev/null || if [ -x "$$HOME/.local/bin/helm" ]; then printf "%s" "$$HOME/.local/bin/helm"; else printf "%s" "helm"; fi)
KUBECTL ?= $(shell command -v kubectl 2>/dev/null || if [ -x "$$HOME/.local/bin/kubectl" ]; then printf "%s" "$$HOME/.local/bin/kubectl"; else printf "%s" "kubectl"; fi)
RAILIANCE_STATE_HUB_RELEASE ?= state-hub
RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub
RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub
RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml
RAILIANCE_STATE_HUB_IMAGE_TAG ?= b536741
RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform
RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests
# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable.
UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi)
@@ -61,6 +71,52 @@ dashboard:
check:
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
railiance-state-hub-render:
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
-f $(RAILIANCE_STATE_HUB_VALUES) \
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG)
railiance-state-hub-client-dry-run:
@set -e; \
tmpdir="$$(mktemp -d)"; \
trap 'rm -rf "$$tmpdir"' EXIT; \
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
-f $(RAILIANCE_STATE_HUB_VALUES) \
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
$(KUBECTL) apply --dry-run=client -f "$$tmpdir/state-hub.yaml"
railiance-state-hub-server-dry-run:
@set -e; \
tmpdir="$$(mktemp -d)"; \
trap 'rm -rf "$$tmpdir"' EXIT; \
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
-f $(RAILIANCE_STATE_HUB_VALUES) \
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
-f $(RAILIANCE_STATE_HUB_VALUES) \
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) \
--show-only templates/namespace.yaml > "$$tmpdir/state-hub-namespace.yaml"; \
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \
$(KUBECTL) apply --dry-run=server -f "$$tmpdir/state-hub-namespace.yaml"; \
if $(KUBECTL) get namespace $(RAILIANCE_STATE_HUB_NAMESPACE) >/dev/null 2>&1; then \
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
$(KUBECTL) apply --dry-run=server -f "$$tmpdir/state-hub.yaml"; \
else \
echo "Namespace $(RAILIANCE_STATE_HUB_NAMESPACE) does not exist; validating namespaced app manifests with client dry-run."; \
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
$(KUBECTL) apply --dry-run=client -f "$$tmpdir/state-hub.yaml"; \
fi
test: test-python dashboard-check
test-python:

View File

@@ -0,0 +1,88 @@
# State Hub Railiance Deployment Handoff
This directory contains the State Hub deployment handoff for `CUST-WP-0011`.
It is source-owned by `state-hub` and split along the Railiance ownership
boundaries used for the actual cluster rollout.
## Ownership
- `deploy/railiance/platform/` is the `railiance-platform` handoff for the
`state-hub-db` CloudNativePG cluster, database bootstrap credential, and
database NetworkPolicies in the `databases` namespace.
- `deploy/railiance/apps/` is the `railiance-apps` handoff for the State Hub API
Helm chart, non-secret production values, and app namespace runtime Secret
template.
- Runtime secret values are not stored here. Replace placeholder passwords only
in an operator-controlled file, then encrypt or deliver through the approved
platform secret path.
## Image
The current image is pinned to:
```text
gitea.coulomb.social/coulomb/state-hub:b536741
```
railiance01 has already pulled this tag with `crictl`, and the image serves
`GET /state/health` against the local WSL database in smoke testing.
## Render And Dry-Run
Render the app chart without touching the cluster:
```bash
make railiance-state-hub-render
```
Run client-side Kubernetes validation for the platform manifests, app Secret
template, and rendered chart:
```bash
make railiance-state-hub-client-dry-run
```
Run server-side dry-run against the configured representative cluster:
```bash
KUBECONFIG=~/.kube/config-hosteurope make railiance-state-hub-server-dry-run
```
Server-side dry-run requires the CNPG CRDs, namespace permissions, and dry-run
permission for resources in `databases` and `state-hub`.
Before the `state-hub` namespace exists, Kubernetes cannot server-dry-run namespaced app
objects into that namespace because dry-run Namespace creation is not persisted.
The Make target therefore server-validates the platform and Namespace manifests,
then falls back to client dry-run for namespaced app manifests with an explicit
notice.
## Promotion Notes
Platform promotion into `railiance-platform`:
- copy `platform/state-hub-db-credentials.sops.yaml.template` to a real SOPS
secret file with an operator-generated password;
- apply or GitOps-manage `platform/state-hub-db-cluster.yaml`;
- apply or GitOps-manage `platform/state-hub-db-networkpolicies.yaml`.
App promotion into `railiance-apps`:
- copy `apps/charts/state-hub/` to `charts/state-hub/`;
- copy `apps/helm/state-hub-values.yaml` to `helm/state-hub-values.yaml`;
- create `state-hub-env` in the `state-hub` namespace from the approved
secret-delivery path;
- deploy with Helm only after `state-hub-db` is healthy.
## Runtime Secret Contract
The app chart expects a Kubernetes Secret named `state-hub-env` in the
`state-hub` namespace with at least:
```text
DATABASE_URL=postgresql+asyncpg://state_hub:<url-encoded-password>@state-hub-db-rw.databases.svc.cluster.local:5432/state_hub
```
Optional runtime settings such as `CORS_ORIGINS` can live in the chart
ConfigMap. The default chart keeps public ingress disabled; access should use
the existing private tunnel/ops-bridge path until a separate exposure decision
is recorded.

View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: state-hub
description: State Hub API service for private Railiance operation
type: application
version: 0.1.0
appVersion: "b536741"

View File

@@ -0,0 +1,26 @@
{{- define "statehub.fullname" -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "statehub.labels" -}}
app: {{ include "statehub.fullname" . }}
app.kubernetes.io/name: {{ include "statehub.fullname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/part-of: railiance-apps
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
railiance.io/layer: s5-app
{{- end -}}
{{- define "statehub.selectorLabels" -}}
app: {{ include "statehub.fullname" . }}
{{- end -}}
{{- define "statehub.image" -}}
{{- if not .Values.image.tag -}}
{{- fail "image.tag is required - pin it in deploy/railiance/apps/helm/state-hub-values.yaml or pass --set image.tag=<sha>" -}}
{{- end -}}
{{- printf "%s:%s" .Values.image.repository .Values.image.tag -}}
{{- end -}}

View File

@@ -0,0 +1,9 @@
{{- if .Values.config.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Values.config.name }}
labels: {{- include "statehub.labels" . | nindent 4 }}
data:
CORS_ORIGINS: {{ .Values.config.corsOrigins | quote }}
{{- end }}

View File

@@ -0,0 +1,66 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "statehub.fullname" . }}
labels: {{- include "statehub.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels: {{- include "statehub.selectorLabels" . | nindent 6 }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels: {{- include "statehub.labels" . | nindent 8 }}
spec:
securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets: {{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: state-hub
image: {{ include "statehub.image" . | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext: {{- toYaml .Values.securityContext | nindent 12 }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
envFrom:
{{- if .Values.config.enabled }}
- configMapRef:
name: {{ .Values.config.name | quote }}
{{- end }}
- secretRef:
name: {{ .Values.secret.name | quote }}
{{- if .Values.probes.enabled }}
readinessProbe:
httpGet:
path: {{ .Values.probes.path }}
port: {{ .Values.probes.port }}
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
livenessProbe:
httpGet:
path: {{ .Values.probes.path }}
port: {{ .Values.probes.port }}
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
{{- end }}
resources: {{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity: {{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations: {{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,28 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "statehub.fullname" . }}
labels: {{- include "statehub.labels" . | nindent 4 }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- if .Values.ingress.tls }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ include "statehub.fullname" . }}-tls
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "statehub.fullname" . }}
port:
number: {{ .Values.service.port }}
{{- end }}

View File

@@ -0,0 +1,8 @@
{{- if .Values.namespace.create }}
apiVersion: v1
kind: Namespace
metadata:
name: {{ .Release.Namespace }}
labels:
{{- toYaml .Values.namespace.labels | nindent 4 }}
{{- end }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "statehub.fullname" . }}
labels: {{- include "statehub.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
protocol: TCP
name: http
selector: {{- include "statehub.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,67 @@
image:
repository: gitea.coulomb.social/coulomb/state-hub
tag: ""
pullPolicy: IfNotPresent
imagePullSecrets: []
replicaCount: 1
namespace:
create: true
labels:
railiance.io/postgres-client: state-hub-db
railiance.io/layer: s5-app
service:
type: ClusterIP
port: 8000
targetPort: 8000
config:
enabled: true
name: state-hub-config
corsOrigins: "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001"
secret:
name: state-hub-env
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 1000m
memory: 2Gi
ingress:
enabled: false
className: traefik
host: state-hub.coulomb.social
tls: true
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod
probes:
enabled: true
path: /state/health
port: 8000
liveness:
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readiness:
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
podSecurityContext: {}
securityContext: {}
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -0,0 +1,8 @@
# Production values for the State Hub Railiance chart handoff.
# Non-secret values only. DATABASE_URL comes from the Secret `state-hub-env`.
image:
tag: "b536741"
ingress:
enabled: false

View File

@@ -0,0 +1,18 @@
# Template for the State Hub runtime Secret in the state-hub namespace.
# DO NOT commit this file with real credentials.
# Encrypt with: sops -e -i state-hub-env.sops.yaml
# Apply with: kubectl apply -f <(sops -d state-hub-env.sops.yaml)
---
apiVersion: v1
kind: Secret
metadata:
name: state-hub-env
namespace: state-hub
labels:
app.kubernetes.io/name: state-hub
app.kubernetes.io/component: runtime-env
app.kubernetes.io/managed-by: manual
railiance.io/layer: s5-app
type: Opaque
stringData:
DATABASE_URL: postgresql+asyncpg://state_hub:REPLACE_WITH_URL_ENCODED_PASSWORD@state-hub-db-rw.databases.svc.cluster.local:5432/state_hub

View File

@@ -0,0 +1,28 @@
---
# Dedicated CNPG Cluster for State Hub episodic memory.
# Owned by railiance-platform (S3). Operator lives in cnpg-system.
#
# Pre-condition: state-hub-db-credentials Secret exists in databases namespace.
# Runtime app Secret is separate and lives in the state-hub namespace.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: state-hub-db
namespace: databases
labels:
app.kubernetes.io/name: state-hub-db
app.kubernetes.io/component: database
app.kubernetes.io/managed-by: manual
railiance.io/layer: s3-platform
railiance.io/role: state-hub-database
spec:
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:16
storage:
size: 10Gi
bootstrap:
initdb:
database: state_hub
owner: state_hub
secret:
name: state-hub-db-credentials

View File

@@ -0,0 +1,19 @@
# Template for the state-hub-db bootstrap Secret.
# DO NOT commit this file with real credentials.
# Encrypt with: sops -e -i state-hub-db-credentials.sops.yaml
# Apply with: kubectl apply -f <(sops -d state-hub-db-credentials.sops.yaml)
---
apiVersion: v1
kind: Secret
metadata:
name: state-hub-db-credentials
namespace: databases
labels:
app.kubernetes.io/name: state-hub-db
app.kubernetes.io/component: database-bootstrap
app.kubernetes.io/managed-by: manual
railiance.io/layer: s3-platform
type: kubernetes.io/basic-auth
stringData:
username: state_hub
password: REPLACE_WITH_PASSWORD

View File

@@ -0,0 +1,74 @@
---
# NetworkPolicies for the dedicated State Hub CNPG cluster.
# Namespaces that need database access must carry:
# railiance.io/postgres-client: state-hub-db
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-kube-api-state-hub-db
namespace: databases
labels:
app.kubernetes.io/name: state-hub-db
railiance.io/layer: s3-platform
spec:
podSelector:
matchLabels:
cnpg.io/cluster: state-hub-db
policyTypes:
- Egress
egress:
- to:
- namespaceSelector: {}
ports:
- protocol: TCP
port: 6443
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-from-cnpg-operator-state-hub-db
namespace: databases
labels:
app.kubernetes.io/name: state-hub-db
railiance.io/layer: s3-platform
spec:
podSelector:
matchLabels:
cnpg.io/cluster: state-hub-db
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: cnpg-system
ports:
- protocol: TCP
port: 5432
- protocol: TCP
port: 8000
- protocol: TCP
port: 9187
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-from-state-hub-namespace-state-hub-db
namespace: databases
labels:
app.kubernetes.io/name: state-hub-db
railiance.io/layer: s3-platform
spec:
podSelector:
matchLabels:
cnpg.io/cluster: state-hub-db
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
railiance.io/postgres-client: state-hub-db
ports:
- protocol: TCP
port: 5432

View File

@@ -225,8 +225,9 @@ and verified railiance01 can pull it with `sudo crictl pull`.
```task
id: CUST-WP-0011-T04
status: todo
status: done
priority: high
completed: "2026-06-25"
state_hub_task_id: "a7baf2eb-abd7-4aa3-b2cb-a5370ac09844"
```
@@ -241,6 +242,24 @@ Create the cluster-side deployment assets using current Railiance boundaries:
**Done when:** manifests lint/apply in a non-destructive dry run and ownership
boundaries are documented.
Completed 2026-06-25: added a source-owned Railiance deployment handoff under
`deploy/railiance/` with platform manifests for `state-hub-db` CNPG, database
credentials, database NetworkPolicies, an app Helm chart, production values, and
a `state-hub-env` Secret template. Added Make targets for rendering,
client-side dry-run validation, and namespace-aware server-side dry-run
validation. Verified:
- `make railiance-state-hub-render`
- `make railiance-state-hub-client-dry-run`
- `make railiance-state-hub-server-dry-run`
The server dry-run validates platform resources and the Namespace manifest
against the live cluster API. Because the `state-hub` namespace does not yet
exist, it explicitly falls back to client dry-run for namespaced app manifests;
Kubernetes cannot persist a dry-run Namespace for subsequent namespaced
server-side validation. Ownership boundaries and promotion notes are documented
in `deploy/railiance/README.md`.
---
### T05 — Deploy empty State Hub and run migrations on railiance01