apiVersion: v2
name: bp-newapi
# 1.4.27 (TBD-A39 #1834, 2026-05-19): replace the Helm-`lookup`-based
# DSN Secret render with a post-install/post-upgrade Job that polls
# CNPG's `<cluster>-app` Secret until populated, composes the SQL_DSN
# string, and PATCHes it into the chart-managed
# `<release>-newapi-db-dsn` Secret in place.
#
# ROOT CAUSE (#1834 D34 close-audit on t32 2026-05-19):
#   The pre-1.4.27 cnpg-cluster.yaml rendered the DSN Secret via Helm
#   `lookup "v1" "Secret" .Release.Namespace <cluster>-app` at template
#   time. On every freshly-franchised Sovereign CNPG materialises the
#   `<cluster>-app` source Secret only AFTER bp-newapi's HelmRelease
#   applies, so the first render's lookup returns nil and the Secret
#   was committed with an empty password — literally
#   `postgres://newapi:@newapi-bp-newapi-newapi-pg-rw.newapi.svc.cluster.local:5432/newapi?sslmode=require`.
#   The chart annotated the rendered Secret `helm.sh/resource-policy:
#   keep`, so Flux never overwrote the empty bytes on subsequent
#   reconciles even after CNPG had populated the source. Result on
#   t32: newapi Pod in 21x CrashLoopBackOff with `password
#   authentication failed for user "newapi"`. Public probe to
#   `newapi.t32.omani.works` returned envoy 503 "no healthy upstream".
#   The chart's own header comment claimed "the 1-minute Flux
#   reconcile picks it up on the next tick" — verified false in
#   production; the `resource-policy: keep` annotation pinned the
#   empty bytes across every subsequent reconcile.
#
# FIX (canonical seam — issue #830 Bug 2 + ADR-0001 §11.3
# anti-duplication):
#   - cnpg-cluster.yaml: drop the Helm `lookup` / DSN composition. The
#     DSN Secret now renders as an empty-value placeholder so kubelet
#     can satisfy the Deployment's secretKeyRef on first schedule.
#   - NEW templates/database-secret-sync-job.yaml: Helm
#     post-install/post-upgrade Job that polls `<cluster>-app` (up to
#     10 min), reads the `password` bytes, composes the canonical
#     `postgres://<user>:<password>@<host>:5432/<db>?sslmode=<mode>`
#     string, and strategic-merge PATCHes it into the placeholder
#     Secret. Hook-weight 5 (after the SA/Role/RoleBinding at 0).
#     ServiceAccount + Role (get/create/update/patch/apply on
#     secrets) + RoleBinding bound to the same hook lifecycle.
#   - Pattern mirrors platform/gitea/chart/templates/database-secret-
#     sync-job.yaml (proven on otech30, issue #830 Bug 2) and
#     platform/wordpress-tenant/chart/templates/database-secret-sync-
#     job.yaml (proven on t26).
#
# TEMPLATABILITY (Inviolable Principle #4):
#   Every knob (cnpg.cluster.owner, cnpg.database, cnpg.sslMode,
#   cnpg.dsnSecretName) flows through to the Job's env vars, so an
#   operator override on any of those updates the composed DSN on the
#   next helm upgrade without forking the chart.
#
# 1.4.19 (TBD-A12 #1798, 2026-05-18): add `startupProbe` to the newapi
# container to gate liveness + readiness until GORM AutoMigrate
# finishes on first-boot.
#
# ROOT CAUSE (t22 chart 1.4.18 Wave 36 verify):
#   On a freshly franchised Sovereign the auto-provisioned CNPG database
#   `newapi` starts empty. The upstream NewAPI binary's bootstrap path
#   (model/main.go:InitDB → migrateDB) logs "database migration started"
#   then runs GORM AutoMigrate on 26 models (Channel, Token, User,
#   PasskeyCredential, Option, Redemption, Ability, Log, Midjourney,
#   TopUp, QuotaData, Task, Model, Vendor, PrefillGroup, Setup, TwoFA,
#   TwoFABackupCode, Checkin, SubscriptionOrder, UserSubscription,
#   SubscriptionPreConsumeRecord, CustomOAuthProvider, UserOAuthBinding,
#   SubscriptionPlan) plus two column-type migrations
#   (subscription_plans.price_amount → decimal, tokens.model_limits →
#   text). The Gin route registration + the `:3000` listen only happen
#   AFTER migration returns nil.
#
#   On a cpx21/cpx31 node with CNPG primary in the same namespace and
#   sslmode=require this first-boot migration runs 60-120s. Pre-A12
#   the chart's livenessProbe was `initialDelaySeconds: 30` +
#   `periodSeconds: 10` + `failureThreshold: 3` ⇒ kubelet SIGKILLed
#   the container at the 50s mark, EVERY restart, mid-migration. The
#   empty `newapi` DB on t22 had ZERO public-schema tables after 29
#   CrashLoopBackOff restarts because every kill races the first GORM
#   query. pg_stat_activity on CNPG side showed no `newapi` user
#   connections; the kill happens before the migration's TLS handshake
#   completes.
#
#   Symptom: `[SYS] ... database migration started` is the LAST log
#   line; container terminates with exit code 2 ~50s later; readiness
#   probe shows `dial tcp <pod-ip>:3000: connect: connection refused`;
#   endpoint /v1/models returns 503 "no healthy upstream".
#
# FIX (CANONICAL SEAM, Inviolable Principle #16):
#   StartupProbe owns "is this binary ALIVE for the first time?". It
#   short-circuits BOTH liveness and readiness until the binary
#   reports healthy on `:3000/api/status` — at which point those two
#   probes activate with their normal cadence. The startupProbe gives
#   GORM AutoMigrate up to 5 minutes (30 × 10s) before it gives up;
#   well above the observed 60-120s ceiling and below operator-
#   impatience limits. Once migration completes the binary is no
#   slower than steady-state, so the existing 10s liveness probe
#   period and 3-failure threshold are unchanged.
#
#   This is the canonical k8s pattern for slow-start migrations — every
#   chart that bakes a GORM/Alembic/Flyway AutoMigrate against an
#   empty DB on first boot should own a startupProbe, NOT inflate its
#   liveness initialDelaySeconds (which would also delay restart
#   recovery on every subsequent reconcile).
#
# TEMPLATABILITY (Inviolable Principle #4):
#   Every knob (path, port, initialDelaySeconds, periodSeconds,
#   timeoutSeconds, failureThreshold, AND the gate to disable the
#   probe entirely) is operator-tunable from per-Sovereign overlays
#   via .Values.newapi.probes.startup.*. Setting
#   `.Values.newapi.probes.startup: null` skip-renders the
#   startupProbe block — operators who pre-seed their database can
#   opt out and let the (pre-A12) liveness initialDelaySeconds gate
#   take over.
#
# 1.4.17 (TBD-D35d #1778, 2026-05-18): add `httproute.yaml` template +
# `ingress.httpRoute` values block so a Sovereign install can render a
# Cilium Gateway HTTPRoute for `newapi.<sovereign-fqdn>` that outranks
# the marketplace `tenant-wildcard` (`*.<sovereign-fqdn>`). Without this
# the sandbox runtime's `NEWAPI_BASE_URL=https://newapi.<fqdn>/v1` hit
# the wildcard, was forwarded to the storefront `console` Service, and
# 502'd the entire BYOS Claude Code journey. The chart-managed HTTPRoute
# lives in the `newapi` namespace (same as the Service backend), so no
# cross-namespace ReferenceGrant is required. Default OFF (`ingress.
# httpRoute.enabled: false`); the bootstrap-kit slot 80 overlay flips
# it to true and supplies `host: newapi.${SOVEREIGN_FQDN}`.
#
# 1.4.15 (TBD-A11 #1765, 2026-05-18): fix `mkdir /data/logs: permission
# denied` CrashLoopBackOff on every fresh Sovereign. The upstream
# Calcium-Ion/new-api v0.13.2 binary writes ephemeral logs to
# `/data/logs` on startup; pre-1.4.15 the chart ran the Pod as uid
# 1000 (`runAsNonRoot: true`, `runAsUser: 1000`) against a root-owned
# `/data` baked into the upstream image AND mounted no volume at
# `/data`, so the binary's `mkdir` went against the container rootfs
# (root-owned, not writable by uid 1000 even with
# `readOnlyRootFilesystem: false`).
#
# Fix (two halves, both required):
#   - values.yaml: add `fsGroup: 1000` to `newapi.securityContext` so
#     kubelet chowns the mount to gid 1000 on Pod start.
#   - deployment.yaml: add `volumes: - name: data emptyDir: ...` and
#     a matching `volumeMounts: - name: data mountPath: /data` on the
#     newapi container. emptyDir (not PVC) is correct — NewAPI's
#     durable state (users, credits, channels, audit log) lives in
#     Postgres via SQL_DSN; only ephemeral logs land on disk so a
#     restart-loses-logs semantic is acceptable.
#
# Caught on t22 2026-05-18 with `newapi-bp-newapi-*` in
# CrashLoopBackOff and `kubectl logs --previous` showing
# `mkdir /data/logs: permission denied`. Verified via `helm template`
# that the Pod spec now carries both halves; on Sovereign reconcile
# the Pod transitions from CrashLoopBackOff → Running.
#
# 1.4.4 (qa-loop bounded-cycle audit prov #20 Fix #138, 2026-05-11): add
# pre-install/pre-upgrade hook (templates/000-external-secrets-webhook-
# readiness-job.yaml) that polls the external-secrets validating-
# admission webhook (`external-secrets-webhook.external-secrets-system.
# svc:443/validate-external-secrets-io-v1beta1-externalsecret`) until
# it returns a structured HTTP response (200/400/405/415/422). Closes
# the race between bp-external-secrets reaching HR Ready=True (Pods
# Ready) and the apiserver-side EndpointSlice for the webhook Service
# being observable by the validating admission controller.
#
# ROOT CAUSE (prov #20):
#   bp-newapi 1.4.2 HR FAILED with the chart's templates/external-
#   secret.yaml apply rejected by the apiserver:
#     Internal error occurred: failed calling webhook
#     "validate.externalsecret.external-secrets.io": ...
#     no endpoints available for service "external-secrets-webhook"
#   bp-external-secrets satisfies Flux dependsOn the moment its
#   Deployments report Ready, but Pod Ready ≠ webhook EndpointSlice
#   reachable. The chart immediately tried to apply ExternalSecret and
#   the webhook returned 503/connect-error.
#
# CANONICAL SEAM (Inviolable Principle #16):
#   The chart that CONSUMES the webhook owns the readiness gate — NOT
#   the upstream external-secrets chart (owned by Fix #137 territory)
#   and NOT a Flux HR-level dependsOn (which checks the wrong layer).
#   Fix #138 mirrors Fix #137's pattern but probes a different
#   validation endpoint (ExternalSecret vs ClusterSecretStore).
#
# TEMPLATABILITY (Inviolable Principle #4):
#   Every knob (webhook service name, namespace, port, path, timeout,
#   interval, gate-enabled flag) is operator-tunable from per-
#   Sovereign overlays via .Values.externalSecretsWebhookGate.*.
#
# 1.4.3: deploy-only bump (a9861f94 — no semantic change beyond
#        carrying the v0.13.2 image-tag fix to the bootstrap-kit).
#
# 1.4.2 (qa-loop bounded-cycle audit prov #7 Gap F, 2026-05-10): point
# `.Values.newapi.image.tag` at a tag that ACTUALLY EXISTS in GHCR. Pre-
# 1.4.2 the chart referenced `ghcr.io/openova-io/openova/newapi-mirror:
# v0.4.5` — a tag that was never built by any CI workflow and never
# pushed to GHCR (the package itself didn't exist; the original commit
# 44d0200a invented the tag with no matching CI build). Every fresh
# Sovereign's NewAPI Pod ImagePullBackOff'd, blocking alice signup
# gate 5 (LLM).
#
# Fix:
#   - NEW .github/workflows/build-bp-newapi.yaml — mirrors
#     `docker.io/calciumion/new-api:<UPSTREAM_VER>` to
#     `ghcr.io/openova-io/openova/newapi-mirror:<UPSTREAM_VER>` on every
#     push to platform/newapi/chart/**. Mirrors the bp-guacamole CI
#     pattern: capture upstream repo digest, re-tag into GHCR, bump
#     values.yaml + Chart.yaml + dispatch blueprint-release.
#   - values.yaml — bump `newapi.image.tag` from `v0.4.5` (fictitious)
#     to `v0.13.2` (latest stable Calcium-Ion/new-api on Docker Hub
#     at the authoring date, 2026-04-27 upstream publish). The
#     v1.0.0-rc.x line is gated on upstream stabilising the schema
#     migration; the channel-seed Job uses the legacy admin-API request
#     shape, so do NOT auto-roll past v0.13.x without re-running the
#     channel-seed integration smoke.
#   - appVersion bumped from `0.4.5` to `0.13.2` to match the mirrored
#     upstream (Helm convention: appVersion = upstream version without
#     the `v` prefix; consumers reading `helm list` see the actual
#     NewAPI release running).
#
# 1.4.1 (issue #952, 2026-05-05): templatize spec.imagePullSecrets on the
# Deployment + channel-seed Job and default `imagePullSecrets:
# [{name: ghcr-pull}]` in values.yaml. Pre-1.4.1 the Pod template emitted
# no imagePullSecrets, so kubelet pulled the PRIVATE
# `ghcr.io/openova-io/openova/newapi-mirror` and
# `ghcr.io/openova-io/openova/services-metering-sidecar` images
# anonymously and got 403 Forbidden on every fresh Sovereign — blocking
# alice signup gate 5 (LLM). The cloud-init `flux-system/ghcr-pull`
# Secret is reflected into the `newapi` namespace via bp-reflector
# annotations (paired with #952 cloud-init update adding `newapi` to the
# `reflection-auto-namespaces` list).
#
# 1.4.0 (issue #943, 2026-05-05): auto-provision CNPG-backed Postgres +
# in-chart credentials Secret so a Sovereign install at bootstrap-kit
# slot 80 lands a real Pod without operator intervention.
#
# Pre-#943 the deployment.yaml gate REQUIRED operator-supplied
# `database.existingSecret` AND `credentials.existingSecret` — the Pod
# silently skipped render whenever either was empty. On a freshly
# franchised Sovereign nothing populated those values, so NewAPI never
# came up and alice signup gate 5 (LLM) timed out waiting for the
# customer-facing /v1/* API to respond.
#
# Fix:
#   - NEW templates/cnpg-cluster.yaml — when .Values.cnpg.enabled
#     (DEFAULT true), renders postgresql.cnpg.io/v1.Cluster
#     `<release>-newapi-pg` + a Helm-`lookup`-persistent DSN Secret
#     `<release>-newapi-db-dsn` (key: SQL_DSN) read by the deployment.
#     Capabilities-gated on postgresql.cnpg.io/v1 so a cold install
#     before bp-cnpg is Ready surfaces as "no Cluster yet" rather
#     than a hard install error (mirrors platform/powerdns/chart/
#     templates/cnpg-cluster.yaml's pattern).
#   - NEW templates/credentials-secret.yaml — when
#     .Values.credentials.autoProvision (DEFAULT true) AND
#     .Values.credentials.existingSecret is empty, renders
#     `<release>-app-creds` carrying SESSION_SECRET + CRYPTO_SECRET
#     (each 64-char randAlphaNum, persistent across reconciles via
#     Helm `lookup`). helm.sh/resource-policy: keep so a re-install
#     recovers the same bytes (otherwise an upgrade silently rotates
#     the keys, invalidating every active session cookie + every
#     encrypted token).
#   - deployment.yaml — Secret name resolution now picks the chart-
#     emitted defaults when the operator hasn't supplied an override.
#     The Pod-render gate accepts EITHER an explicit existingSecret
#     OR the auto-provisioned one.
#   - values.yaml — adds .Values.cnpg.* and .Values.credentials.
#     {autoProvision,autoSecretName} blocks; every knob is operator-
#     overridable per Inviolable Principle #4.
#
# 1.3.0: defaultChannels.qwenBankDhofar (channel #1 = Qwen3.6 @
# https://llm-api.omtd.bankdhofar.com) + post-install/post-upgrade
# `channel-seed` Helm hook Job that idempotently POSTs default
# channels into NewAPI's admin API at /api/channel/. Bridges the
# previous documentation-only channels.yaml ConfigMap → the actual
# Postgres-backed channel rows the upstream binary reads at runtime.
# Issue #915 (epic SME tenant integration DoD: alice → OpenClaw →
# NewAPI → Qwen3.6@BankDhofar end-to-end).
# 1.2.0: Traefik Middleware gated behind ingress.middleware.enabled.
# 1.4.6 (Fix #163, 2026-05-11, MIRROR-EVERYTHING): 000-external-secrets-
# webhook-readiness-job hook image switched from curlimages/curl:8.10.1
# to harbor.openova.io/proxy-dockerhub/curlimages/curl:8.10.1 per
# CLAUDE.md inviolable rule.
#
# 1.4.10 (fix-convergence-wave11, 2026-05-18): gate the
# defaultChannels.qwenBankDhofar entry on attestation-complete instead
# of hard-failing the helm template when commercial-contract attestation
# fields are empty. PR #1631 added envsubst placeholders
# `${LLM_BANK_DHOFAR_ACCOUNT_ID:-}` / `${LLM_BANK_DHOFAR_CONTRACT_REF:-}`
# in the bootstrap-kit overlay so franchised Sovereigns can opt in to
# marketplace without supplying real attestation; but the chart's
# `assertChannelAttestation` helper still raised `commercial-contract
# attestation requires accountId` and the install crashed on every
# Sovereign that flipped `MARKETPLACE_ENABLED=true` without a signed
# commercial contract. Fix: in `bp-newapi.effectiveChannels` (and the
# matching `channel-seed-job.yaml` gate), SKIP composing the
# qwenBankDhofar channel when attestation.kind=commercial-contract AND
# either accountId or contractRef is empty. NewAPI installs with zero
# default channels; operator-supplied channels still compose. Once
# the operator overlay supplies the attestation values the channel
# composes on the next reconcile. helm template renders cleanly with
# BOTH the missing-attestation state (no channel) AND the
# fully-populated state (channel composed normally).
# 1.4.23 (Fix #1902, TBD-A40, 2026-05-19): omit HTTPRoute parentRef
# sectionName by default — multi-zone Sovereigns rename HTTPS listeners
# to https-<sanitised-zone>, breaking NoMatchingListener with the prior
# `default "https"` fallback in templates/httproute.yaml. The template
# now uses `{{- with $parent.sectionName }}` to drop the field entirely
# when blank, and values.yaml defaults sectionName to "". Matches the
# catalyst-system fix in PR #1888.
# 1.4.25 (Fix #1901, TBD-A39, 2026-05-19): replace bp-cnpg
# NetworkPolicy egress (namespaceLabel: cnpg-system) with a
# podSelector matching cnpg.io/cluster=<release>-bp-newapi-newapi-pg
# in templates/networkpolicy.yaml gated on `.Values.cnpg.enabled`.
# The CNPG operator runs in cnpg-system but the actual Postgres
# workload Pods reconcile into `.Release.Namespace` (this
# blueprint's NS); the prior rule could never reach them and the
# newapi container looped 1/2 Ready with DB-connect timeouts on
# fresh prov.
# 1.4.29 (TBD-A52 #1944, 2026-05-19): point the default Valkey URL at
# `valkey-primary.valkey.svc.cluster.local` instead of the non-existent
# `valkey.valkey.svc.cluster.local`. The bp-valkey blueprint installs
# the upstream bitnami chart with architecture=replication, which
# renders Services named `<release>-primary` / `<release>-replicas` /
# `<release>-headless` — there is NO plain `valkey` Service. The old
# default resolved to NXDOMAIN and the NewAPI pod looped CrashLoopBackOff
# on the Redis ping probe (t34: 31 restarts with
# `lookup valkey.valkey.svc.cluster.local: no such host`). Same hostname
# already documented as canonical in products/catalyst/chart/values.yaml
# bp-cnpg-pair comments.
version: 1.4.30
appVersion: "0.13.2"
description: |
  Catalyst Blueprint scratch chart for NewAPI — multi-tenant LLM
  marketplace gateway.

  No first-party Helm chart is published by the NewAPI upstream
  (https://github.com/Calcium-Ion/new-api ships a docker-compose only).
  This chart hand-wires the upstream container image as a
  Deployment + Service + Ingress (split customer-API vs ops-admin) +
  ConfigMap + ServiceAccount + NetworkPolicy + ExternalSecret consumers.

  Connects to:
    - bp-cnpg            (Postgres for users, credits, channels, audit log)
    - bp-valkey          (session and rate-limit cache)
    - bp-keycloak        (OIDC for ops-staff admin UI)
    - bp-external-secrets (DB DSN, master key, per-channel provider keys)
    - bp-vllm            (in-cluster cheap-tier inference channel; optional)

  Customer surface is NOT this chart — Catalyst is the customer-facing
  UI. This chart's customer surface is the OpenAI-compatible API at
  `api.<host>/v1/*`. The upstream's portal UI is intentionally disabled
  via the ingress configuration.

  Pairs with bp-cnpg, bp-keycloak, bp-external-secrets, bp-valkey,
  bp-vllm (depends.list in blueprint.yaml).
type: application
keywords: [catalyst, blueprint, newapi, llm, gateway, marketplace, multi-tenant, credits, byok, openai-compatible, reseller]
maintainers:
  - name: OpenOva Catalyst
    email: catalyst@openova.io
# Scratch chart — see comments in bp-librechat/chart/Chart.yaml for the
# rationale on the `common` library subchart dependency (issue #181
# hollow-chart gate).
dependencies:
  - name: common
    version: "0.1.3"
    repository: "https://sigstore.github.io/helm-charts"
