diff --git a/deploy/k8s/qonto-assistant/README.md b/deploy/k8s/qonto-assistant/README.md new file mode 100644 index 0000000..884e508 --- /dev/null +++ b/deploy/k8s/qonto-assistant/README.md @@ -0,0 +1,50 @@ +# qonto-assistant Kubernetes manifests (draft) + +QONTO-WP-0004-T06. Modeled on `llm-connect/deploy/k8s/activity-core-llm-connect/`, +tightened per `docs/SecurityPractice.md` §7's isolation-profile request +(I1 "Reinforced" minimum, given internet reachability via the facade and +sole custody of the bank credential). + +## Files + +| File | Purpose | +| --- | --- | +| `namespace.yaml` | Dedicated `qonto-assistant` namespace — not shared with `activity-core` or any other workload | +| `configmap.yaml` | Non-secret runtime config. Security-integration URLs left unset until key-cape/flex-auth/tenant-engine have confirmed in-cluster addresses | +| `deployment.yaml` | `replicas: 0` by default — meant to be scaled 0↔1 by the facade (T05, not yet built), never run continuously | +| `service.yaml` | `ClusterIP` only — never bind the raw port to a public address | +| `networkpolicy.yaml` | Default-deny; ingress only from the (assumed co-located) facade pod; egress limited to DNS + 443 | +| `externalsecret.yaml` | Syncs `API_KEY`/`API_USER` from `tenants/binky/qonto-api` — **depends on CCR-2026-0009 (proposed, not approved)** | + +## What is real vs. draft + +- **Real, already live:** the underlying secret at `tenants/binky/qonto-api` + (CCR-2026-0008, human/OIDC admin access only). +- **Draft, needs approval before applying:** `externalsecret.yaml` and the + paired `ClusterSecretStore` in + `railiance-platform/argocd/platform-addons/openbao-secretstore/openbao-qonto-assistant.clustersecretstore.yaml` + both depend on **CCR-2026-0009** (workload-scoped Kubernetes-auth access + lane), which is `status: proposed` — not yet approved by + platform-operator/binky-tenant-owner. +- **Open question, not decided here:** whether the facade + (QONTO-WP-0004-T05) lands in this same namespace (assumed by + `networkpolicy.yaml`'s ingress rule) or a separate one. Update the + `podSelector`/`namespaceSelector` in `networkpolicy.yaml` once that's + decided. +- **Known egress-policy limitation:** native Kubernetes `NetworkPolicy` has + no FQDN-aware egress rule, so the `0.0.0.0/0:443` egress rule is broader + than the intent (only `thirdparty.qonto.com` and `bao.coulomb.social`). + Called out in `networkpolicy.yaml`'s own comment rather than silently + widened further. + +## Applying (once CCR-2026-0009 is approved) + +```bash +kubectl kustomize deploy/k8s/qonto-assistant # render and review first +kubectl apply -k deploy/k8s/qonto-assistant +kubectl -n qonto-assistant scale deployment/qonto-assistant --replicas=1 # manual wake, until T05 exists +``` + +See `railiance/app.toml` for the staged-promotion contract (Stage 1 local → +Stage 2 canary → Stage 3 production) this deployment should go through, per +`railiance-cluster/docs/app-toml-contract.md`. diff --git a/deploy/k8s/qonto-assistant/configmap.yaml b/deploy/k8s/qonto-assistant/configmap.yaml new file mode 100644 index 0000000..41b85c9 --- /dev/null +++ b/deploy/k8s/qonto-assistant/configmap.yaml @@ -0,0 +1,29 @@ +# Non-secret runtime configuration. Security-integration URLs +# (QONTO_KEY_CAPE_JWKS_URL / QONTO_FLEX_AUTH_URL / QONTO_TENANT_ENGINE_URL) +# are deliberately left unset here: none of key-cape, flex-auth, or +# tenant-engine has a confirmed in-cluster reachable address yet. Each +# integration fails safe when unset (see docs/SecurityPractice.md #10 and +# #4) -- unset is the correct value until those addresses are confirmed, +# not a placeholder to fill in blindly. +apiVersion: v1 +kind: ConfigMap +metadata: + name: qonto-assistant-config + namespace: qonto-assistant + labels: + app.kubernetes.io/name: qonto-assistant + app.kubernetes.io/part-of: qonto-assistant +data: + QONTO_ASSISTANT_HOST: "0.0.0.0" + QONTO_ASSISTANT_PORT: "8080" + QONTO_ASSISTANT_DEFAULT_TENANT: "binky" + QONTO_BASE_URL: "https://thirdparty.qonto.com" + QONTO_AUTH_MODE: "legacy_api_key" + QONTO_CREDENTIAL_SOURCE: "env" + QONTO_RATE_LIMIT_REQUESTS: "20" + QONTO_RATE_LIMIT_WINDOW_SECONDS: "60" + QONTO_MAX_CONCURRENCY: "4" + QONTO_DENY_ESCALATION_ENABLED: "true" + # QONTO_KEY_CAPE_JWKS_URL: TODO once key-cape has a confirmed in-cluster address + # QONTO_FLEX_AUTH_URL: TODO once flex-auth has a confirmed in-cluster address + # QONTO_TENANT_ENGINE_URL: TODO once tenant-engine is deployed anywhere reachable diff --git a/deploy/k8s/qonto-assistant/deployment.yaml b/deploy/k8s/qonto-assistant/deployment.yaml new file mode 100644 index 0000000..dec27d3 --- /dev/null +++ b/deploy/k8s/qonto-assistant/deployment.yaml @@ -0,0 +1,69 @@ +# Starts at replicas: 0 -- this Deployment is meant to be scaled 0<->1 by +# the facade/activator (QONTO-WP-0004-T05, not yet built), never run +# continuously. Applying this manifest alone leaves the service correctly +# idle; something else (the facade, or manual `kubectl scale` for interim +# testing) must patch replicas to 1 to actually run it. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: qonto-assistant + namespace: qonto-assistant + labels: + app.kubernetes.io/name: qonto-assistant + app.kubernetes.io/part-of: qonto-assistant +spec: + replicas: 0 + selector: + matchLabels: + app.kubernetes.io/name: qonto-assistant + template: + metadata: + labels: + app.kubernetes.io/name: qonto-assistant + app.kubernetes.io/part-of: qonto-assistant + spec: + containers: + - name: qonto-assistant + image: docker.io/library/qonto-assistant:latest + imagePullPolicy: Never + envFrom: + - configMapRef: + name: qonto-assistant-config + - secretRef: + name: qonto-assistant-qonto-api + optional: false + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: + path: /v1/health + port: http + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /v1/health + port: http + periodSeconds: 30 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + securityContext: + fsGroup: 10001 diff --git a/deploy/k8s/qonto-assistant/externalsecret.yaml b/deploy/k8s/qonto-assistant/externalsecret.yaml new file mode 100644 index 0000000..76dcc64 --- /dev/null +++ b/deploy/k8s/qonto-assistant/externalsecret.yaml @@ -0,0 +1,29 @@ +# DRAFT -- depends on CCR-2026-0009 (status: proposed) and the paired draft +# ClusterSecretStore railiance-platform/argocd/platform-addons/openbao-secretstore/ +# openbao-qonto-assistant.clustersecretstore.yaml. Do not apply until that +# CCR is approved and the store exists. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: qonto-assistant-qonto-api + namespace: qonto-assistant + labels: + app.kubernetes.io/name: qonto-assistant + app.kubernetes.io/part-of: railiance-gitops +spec: + refreshInterval: 15m + secretStoreRef: + kind: ClusterSecretStore + name: openbao-qonto-assistant + target: + name: qonto-assistant-qonto-api + creationPolicy: Owner + data: + - secretKey: API_KEY + remoteRef: + key: tenants/binky/qonto-api + property: API_KEY + - secretKey: API_USER + remoteRef: + key: tenants/binky/qonto-api + property: API_USER diff --git a/deploy/k8s/qonto-assistant/kustomization.yaml b/deploy/k8s/qonto-assistant/kustomization.yaml new file mode 100644 index 0000000..4516300 --- /dev/null +++ b/deploy/k8s/qonto-assistant/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - configmap.yaml + - deployment.yaml + - service.yaml + - networkpolicy.yaml + - externalsecret.yaml diff --git a/deploy/k8s/qonto-assistant/namespace.yaml b/deploy/k8s/qonto-assistant/namespace.yaml new file mode 100644 index 0000000..e0dca0e --- /dev/null +++ b/deploy/k8s/qonto-assistant/namespace.yaml @@ -0,0 +1,13 @@ +# Dedicated namespace, not shared with activity-core or any other workload. +# Per docs/SecurityPractice.md §7: qonto-assistant is the sole holder of a +# real bank credential and must be internet-reachable (via the facade, +# QONTO-WP-0004-T05) -- Kings Guard isolation profile I1 "Reinforced" +# minimum, I2 "Dedicated" worth considering. A dedicated namespace is the +# baseline either way. +apiVersion: v1 +kind: Namespace +metadata: + name: qonto-assistant + labels: + app.kubernetes.io/part-of: qonto-assistant + railiance-platform/isolation-profile: reinforced diff --git a/deploy/k8s/qonto-assistant/networkpolicy.yaml b/deploy/k8s/qonto-assistant/networkpolicy.yaml new file mode 100644 index 0000000..1ec78a1 --- /dev/null +++ b/deploy/k8s/qonto-assistant/networkpolicy.yaml @@ -0,0 +1,53 @@ +# Default-deny with a narrow allow list, tighter than llm-connect's sibling +# policy (deploy in llm-connect/deploy/k8s/activity-core-llm-connect/) -- +# that one allows any pod in its shared namespace on ingress; this one only +# allows the facade (assumed co-located in this namespace until +# QONTO-WP-0004-T05 decides otherwise -- update podSelector below if the +# facade lands in a different namespace). +# +# Egress limitation, stated plainly: native Kubernetes NetworkPolicy has no +# FQDN-aware egress rule. The `0.0.0.0/0:443` rule below is broader than the +# intent ("only thirdparty.qonto.com and bao.coulomb.social") -- it is the +# same coarseness llm-connect's own NetworkPolicy accepts for its egress. +# If Cilium (or another CNI with FQDN-aware NetworkPolicy/CiliumNetworkPolicy) +# is available on railiance01, replace this rule with an FQDN allow-list +# instead of widening it further. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: qonto-assistant-facade-only + namespace: qonto-assistant + labels: + app.kubernetes.io/name: qonto-assistant + app.kubernetes.io/part-of: qonto-assistant +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: qonto-assistant + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: qonto-assistant-facade + ports: + - protocol: TCP + port: 8080 + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + - to: + - ipBlock: + cidr: 0.0.0.0/0 + ports: + - protocol: TCP + port: 443 diff --git a/deploy/k8s/qonto-assistant/service.yaml b/deploy/k8s/qonto-assistant/service.yaml new file mode 100644 index 0000000..6feaddb --- /dev/null +++ b/deploy/k8s/qonto-assistant/service.yaml @@ -0,0 +1,20 @@ +# ClusterIP only. Per docs/SecurityPractice.md §5: qonto-assistant's raw +# port must never be bound to a publicly-reachable address in any +# deployment -- the facade (QONTO-WP-0004-T05) is the sole internet-facing +# component and reaches this Service from inside the cluster. +apiVersion: v1 +kind: Service +metadata: + name: qonto-assistant + namespace: qonto-assistant + labels: + app.kubernetes.io/name: qonto-assistant + app.kubernetes.io/part-of: qonto-assistant +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: qonto-assistant + ports: + - name: http + port: 8080 + targetPort: http diff --git a/railiance/app.toml b/railiance/app.toml new file mode 100644 index 0000000..0a0a9c6 --- /dev/null +++ b/railiance/app.toml @@ -0,0 +1,157 @@ +schema_version = "railiance.app.v1" + +[app] +id = "qonto-assistant" +name = "Qonto Governed Assistant" +repo = "qonto-assistant" +owner = "binky-tenant-owner" +criticality = "critical" +description = "Sole holder of the Binky Qonto bank API credential; governed read-only REST+MCP finance surface for agent harnesses, reachable via a facade (QONTO-WP-0004-T05, not yet built)." + +[source] +revision = "git:main" +artifact = "image" +digest_policy = "required" + +[rollback] +strategy = "manual-runbook" +command = "kubectl -n qonto-assistant scale deployment/qonto-assistant --replicas=0" +verification = "Facade reports the backend unreachable; no traffic proxied until re-woken against a known-good image digest." + +[[platform.dependencies]] +name = "openbao" +kind = "other" +required = true +stage = "stage2" +evidence = "ExternalSecret qonto-assistant-qonto-api syncs API_KEY/API_USER without error (depends on CCR-2026-0009 approval)." + +[[secrets.references]] +name = "qonto-api-credential" +route = "openbao-workload-kv-read" +target = "ExternalSecret/qonto-assistant-qonto-api" +stage = "stage2" +required = true + +[[observability.health_endpoints]] +name = "local-health" +url = "http://127.0.0.1:8080/v1/health" +stage = "stage1" +expected_status = 200 + +[[observability.health_endpoints]] +name = "cluster-health" +url = "http://qonto-assistant.qonto-assistant.svc.cluster.local:8080/v1/health" +stage = "stage2" +expected_status = 200 + +[stages.stage1] +enabled = true +namespace = "local" +release = "qonto-assistant-local" +commands = ["make test", "kubectl kustomize deploy/k8s/qonto-assistant"] +checks = ["unit-tests", "kustomize-render", "local-health"] +evidence = ["pytest output", "kustomize render success", "local health 200"] +requires_approval = false + +[stages.stage2] +enabled = true +namespace = "qonto-assistant" +release = "qonto-assistant-canary" +commands = ["kubectl apply -k deploy/k8s/qonto-assistant --dry-run=server", "kubectl -n qonto-assistant scale deployment/qonto-assistant --replicas=1"] +checks = ["server-dry-run", "canary-ready", "cluster-health", "operator-approval"] +evidence = ["dry-run diff", "pod readiness", "health 200", "State Hub progress id"] +requires_approval = true +canary_mode = "isolated" +observation_minutes = 60 + +[stages.stage3] +enabled = true +namespace = "qonto-assistant" +release = "qonto-assistant" +commands = ["kubectl apply -k deploy/k8s/qonto-assistant", "kubectl -n qonto-assistant scale deployment/qonto-assistant --replicas=0"] +checks = ["stage2-accepted", "rollback-target", "cluster-health", "operator-approval"] +evidence = ["promotion commit", "new stable digest", "post-promotion smoke, then scale back to 0 -- the facade (T05) owns wake/idle from here on"] +requires_approval = true +promotion_mode = "release-replace" +previous_stable = "kubectl:qonto-assistant:previous-digest" + +[[checks]] +id = "unit-tests" +type = "command" +stage = "stage1" +description = "Run repository unit tests." +required = true +run = "make test" +timeout_seconds = 600 + +[[checks]] +id = "kustomize-render" +type = "command" +stage = "stage1" +description = "Render the kustomize manifest set locally, no cluster contact." +required = true +run = "kubectl kustomize deploy/k8s/qonto-assistant" + +[[checks]] +id = "local-health" +type = "http" +stage = "stage1" +description = "Confirm local service health." +required = true +url = "http://127.0.0.1:8080/v1/health" +expected_status = 200 +timeout_seconds = 10 + +[[checks]] +id = "server-dry-run" +type = "kubernetes" +stage = "stage2" +description = "Server-side dry run of the full manifest set before scaling the canary up." +required = true +namespace = "qonto-assistant" +resource = "kustomization/deploy/k8s/qonto-assistant" +condition = "DryRunAccepted" + +[[checks]] +id = "canary-ready" +type = "kubernetes" +stage = "stage2" +description = "Canary deployment reaches Available after being scaled to 1." +required = true +namespace = "qonto-assistant" +resource = "deploy/qonto-assistant" +condition = "Available" + +[[checks]] +id = "cluster-health" +type = "http" +stage = "stage2" +description = "Cluster health endpoint returns 200." +required = true +url = "http://qonto-assistant.qonto-assistant.svc.cluster.local:8080/v1/health" +expected_status = 200 +timeout_seconds = 10 + +[[checks]] +id = "operator-approval" +type = "manual" +stage = "stage2" +description = "Human approval is recorded before this internet-facing, credential-holding workload takes traffic -- criticality=critical per app.toml." +required = true +evidence_required = "State Hub approval note id, candidate digest, rollback target, confirmation that CCR-2026-0009 is approved." + +[[checks]] +id = "stage2-accepted" +type = "manual" +stage = "stage3" +description = "Stage 2 gates passed for the same candidate artifact." +required = true +evidence_required = "State Hub Stage 2 acceptance progress id." + +[[checks]] +id = "rollback-target" +type = "manual" +stage = "stage3" +description = "Previous stable release is recorded before promotion." +required = true +evidence_required = "Previous image digest or kustomize revision."