From f4a2ee44ca81e01e34cf06be4854c7c7edb9d86b Mon Sep 17 00:00:00 2001 From: Dalton Alvarenga Date: Tue, 11 Aug 2026 18:50:22 -0300 Subject: [PATCH] =?UTF-8?q?[lab]=20chore(promotion):=20prepara=20servi?= =?UTF-8?q?=C3=A7o=20para=20gates=20lab=20->=20homolog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adequa o repo ao guia de promoção lab -> homolog mantendo Python (Gate 0 via stackException). Cumpre os gates aplicáveis: - promotion-manifest.yaml: stackException (Python/Vanna), Gate B N/A, Gate D/G declarados, capacidade e deltas. - k8s/hml/: overlay homolog — secret DB dedicado (vanna-clubpetro-db, Gate G), probes httpGet /health (Gate E), resources.requests, replicas:1, non-root securityContext; host homologation.clubpetro.com (sem host de lab). - server.py: rota GET /health (alvo das probes). - Dockerfile: usuário non-root uid/gid 10001 + HOME/cache graváveis (Gate E). - .env.example: remove literal lab.clubpetro.com do CORS (Gate D). - k8s/ flat movido para k8s/lab/ (simetria lab/hml); cd.yml aplica k8s/lab/. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 5 +- .gitea/workflows/cd.yml | 7 ++- Dockerfile | 12 +++- k8s/hml/deployment.yaml | 85 +++++++++++++++++++++++++ k8s/hml/ingress.yaml | 32 ++++++++++ k8s/{ => hml}/pvc.yaml | 0 k8s/hml/secret.example.yaml | 53 ++++++++++++++++ k8s/{ => hml}/service.yaml | 0 k8s/{ => lab}/deployment.yaml | 0 k8s/{ => lab}/ingress.yaml | 0 k8s/lab/pvc.yaml | 13 ++++ k8s/lab/service.yaml | 15 +++++ promotion-manifest.yaml | 115 ++++++++++++++++++++++++++++++++++ server.py | 6 ++ 14 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 k8s/hml/deployment.yaml create mode 100644 k8s/hml/ingress.yaml rename k8s/{ => hml}/pvc.yaml (100%) create mode 100644 k8s/hml/secret.example.yaml rename k8s/{ => hml}/service.yaml (100%) rename k8s/{ => lab}/deployment.yaml (100%) rename k8s/{ => lab}/ingress.yaml (100%) create mode 100644 k8s/lab/pvc.yaml create mode 100644 k8s/lab/service.yaml create mode 100644 promotion-manifest.yaml diff --git a/.env.example b/.env.example index b60f497..6bc4648 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,9 @@ CLICKHOUSE_USER=wren_ia CLICKHOUSE_PASSWORD= CLICKHOUSE_SECURE=true -# CORS (separar por vírgula) -VANNA_CORS_ORIGINS=https://lab.clubpetro.com,https://homologation.clubpetro.com +# CORS: origens permitidas por ambiente, separadas por vírgula. +# Definir por ambiente (no k8s vem do secret/env do overlay — nunca hardcode de host aqui). +VANNA_CORS_ORIGINS= # RLS defaults (apenas pra `python ask.py` na CLI; servidor web extrai de query string) RLS_PROGRAM_ID= diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index 4574476..c03cc37 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -82,9 +82,10 @@ jobs: gcloud container clusters get-credentials ${{ secrets.GKE_CLUSTER }} --region ${{ secrets.GKE_REGION }} --project ${{ secrets.GCP_PROJECT }} NS=${{ secrets.K8S_NAMESPACE }} - # 1) Aplica manifests (idempotente — cria PVC/Service/Ingress/Deployment se faltarem) - if [ -d k8s ]; then - kubectl apply -n "$NS" -f k8s/ + # 1) Aplica manifests do LAB (idempotente — cria PVC/Service/Ingress/Deployment se faltarem). + # Overlay do lab vive em k8s/lab/; homolog usa k8s/hml/ (promoção via pipeline lab->homolog). + if [ -d k8s/lab ]; then + kubectl apply -n "$NS" -f k8s/lab/ fi # 2) Atualiza image diff --git a/Dockerfile b/Dockerfile index da01063..036f896 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,8 +51,16 @@ RUN pip install -r requirements.txt # Código do app COPY . . -# data dirs -RUN mkdir -p /app/chroma_db /app/data_storage +# Gate E — usuário non-root. uid/gid 10001 batem com o securityContext do +# k8s/hml/deployment.yaml (runAsUser/fsGroup). HOME=/home/app pro cache do +# ChromaDB (~/.cache/chroma) cair num diretório gravável pelo usuário. +ENV HOME=/home/app +RUN groupadd -g 10001 app \ + && useradd -u 10001 -g 10001 -m -d /home/app -s /usr/sbin/nologin app \ + && mkdir -p /app/chroma_db /app/data_storage /home/app/.cache/chroma \ + && chown -R 10001:10001 /app /home/app + +USER 10001 EXPOSE 8765 diff --git a/k8s/hml/deployment.yaml b/k8s/hml/deployment.yaml new file mode 100644 index 0000000..c05bacf --- /dev/null +++ b/k8s/hml/deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vanna-clubpetro-deployment + labels: + app: vanna-clubpetro +spec: + replicas: 1 # ChromaDB SQLite-based — múltiplas réplicas corrompem o vector store + strategy: + type: Recreate # com PVC ReadWriteOnce não dá pra ter 2 pods montando ao mesmo tempo + selector: + matchLabels: + app: vanna-clubpetro + template: + metadata: + labels: + app: vanna-clubpetro + spec: + # Gate E: pod roda como non-root. fsGroup dá posse dos volumes (PVC) ao + # grupo 10001 pra que o processo non-root consiga escrever no store/cache. + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + containers: + - name: vanna-clubpetro + # A tag é sobrescrita pelo pipeline (kubectl set image). Placeholder: + image: us-central1-docker.pkg.dev/corepetro/clubpetro/vanna-clubpetro:hml-latest + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + ports: + - name: http + containerPort: 8765 + env: + - name: HOME + value: /home/app + - name: VANNA_CORS_ORIGINS + value: "https://homologation.clubpetro.com" + envFrom: + # Gate G: credenciais do ClickHouse num secret DEDICADO ao serviço. + - secretRef: + name: vanna-clubpetro-db + # Config de app (OpenAI, RLS defaults etc.) em secret separado. + - secretRef: + name: vanna-clubpetro-secret + resources: + requests: + cpu: "200m" + memory: "1Gi" + limits: + cpu: "1" + memory: "2Gi" + readinessProbe: + httpGet: + path: /health + port: 8765 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: 8765 + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + volumeMounts: + - name: data + mountPath: /app/chroma_db + subPath: chroma_db + - name: data + mountPath: /app/data_storage + subPath: data_storage + - name: data + mountPath: /home/app/.cache/chroma + subPath: chroma-onnx-cache + volumes: + - name: data + persistentVolumeClaim: + claimName: vanna-clubpetro-data diff --git a/k8s/hml/ingress.yaml b/k8s/hml/ingress.yaml new file mode 100644 index 0000000..efd92d2 --- /dev/null +++ b/k8s/hml/ingress.yaml @@ -0,0 +1,32 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: vanna-clubpetro + labels: + app: vanna-clubpetro + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/use-regex: "true" + nginx.ingress.kubernetes.io/rewrite-target: /$2 + # SSE/WebSocket precisam de timeout grande pra streaming não ser cortado + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/proxy-http-version: "1.1" +spec: + ingressClassName: nginx + rules: + - host: homologation.clubpetro.com + http: + paths: + - path: /api/vanna(/|$)(.*) + pathType: Prefix + backend: + service: + name: vanna-clubpetro + port: + number: 80 + tls: + - hosts: + - homologation.clubpetro.com + secretName: homologation-tls diff --git a/k8s/pvc.yaml b/k8s/hml/pvc.yaml similarity index 100% rename from k8s/pvc.yaml rename to k8s/hml/pvc.yaml diff --git a/k8s/hml/secret.example.yaml b/k8s/hml/secret.example.yaml new file mode 100644 index 0000000..6c9b70e --- /dev/null +++ b/k8s/hml/secret.example.yaml @@ -0,0 +1,53 @@ +# Exemplo dos Secrets do homolog. NÃO versionar os valores reais — este arquivo +# é só documentação. Crie os secrets no cluster via kubectl (fora do git): +# +# # Gate G — credenciais de banco em secret DEDICADO ao serviço: +# kubectl -n create secret generic vanna-clubpetro-db \ +# --from-literal=CLICKHOUSE_HOST=... \ +# --from-literal=CLICKHOUSE_PORT=8443 \ +# --from-literal=CLICKHOUSE_DATABASE=gold \ +# --from-literal=CLICKHOUSE_USER=wren_ia \ +# --from-literal=CLICKHOUSE_PASSWORD=... \ +# --from-literal=CLICKHOUSE_SECURE=true +# +# # Config de app (não-banco): OpenAI + RLS defaults: +# kubectl -n create secret generic vanna-clubpetro-secret \ +# --from-literal=OPENAI_API_KEY=... \ +# --from-literal=OPENAI_MODEL=gpt-5 \ +# --from-literal=OPENAI_TEMPERATURE=1.0 \ +# --from-literal=RLS_PROGRAM_ID=... \ +# --from-literal=RLS_STORE_ID=... \ +# --from-literal=RLS_USER_ID=... +# +# As chaves acima são as mesmas do .env.example. O Deployment injeta ambos +# via envFrom.secretRef. Nunca colocar valor real em arquivo versionado. +--- +apiVersion: v1 +kind: Secret +metadata: + name: vanna-clubpetro-db + labels: + app: vanna-clubpetro +type: Opaque +stringData: + CLICKHOUSE_HOST: "" + CLICKHOUSE_PORT: "8443" + CLICKHOUSE_DATABASE: "gold" + CLICKHOUSE_USER: "wren_ia" + CLICKHOUSE_PASSWORD: "" + CLICKHOUSE_SECURE: "true" +--- +apiVersion: v1 +kind: Secret +metadata: + name: vanna-clubpetro-secret + labels: + app: vanna-clubpetro +type: Opaque +stringData: + OPENAI_API_KEY: "" + OPENAI_MODEL: "gpt-5" + OPENAI_TEMPERATURE: "1.0" + RLS_PROGRAM_ID: "" + RLS_STORE_ID: "" + RLS_USER_ID: "" diff --git a/k8s/service.yaml b/k8s/hml/service.yaml similarity index 100% rename from k8s/service.yaml rename to k8s/hml/service.yaml diff --git a/k8s/deployment.yaml b/k8s/lab/deployment.yaml similarity index 100% rename from k8s/deployment.yaml rename to k8s/lab/deployment.yaml diff --git a/k8s/ingress.yaml b/k8s/lab/ingress.yaml similarity index 100% rename from k8s/ingress.yaml rename to k8s/lab/ingress.yaml diff --git a/k8s/lab/pvc.yaml b/k8s/lab/pvc.yaml new file mode 100644 index 0000000..1fbb092 --- /dev/null +++ b/k8s/lab/pvc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vanna-clubpetro-data + labels: + app: vanna-clubpetro +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + storageClassName: standard-rwo diff --git a/k8s/lab/service.yaml b/k8s/lab/service.yaml new file mode 100644 index 0000000..809362b --- /dev/null +++ b/k8s/lab/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: vanna-clubpetro + labels: + app: vanna-clubpetro +spec: + type: ClusterIP + selector: + app: vanna-clubpetro + ports: + - name: http + port: 80 + targetPort: 8765 + protocol: TCP diff --git a/promotion-manifest.yaml b/promotion-manifest.yaml new file mode 100644 index 0000000..9e3830c --- /dev/null +++ b/promotion-manifest.yaml @@ -0,0 +1,115 @@ +# promotion-manifest.yaml +# Manifesto de promoção lab -> homolog (ClubPetro). +# A PR de promoção deve ser marcada com [lab] e conter este arquivo. +# O pipeline lê os campos abaixo pra rodar os gates 0 + A-F + G + admissao. +# +# OBS: o schema exato é definido pelo parser do pipeline (referências vivas: +# backend=external_apis, seed=action-plan). As chaves aqui são auto-descritivas; +# reconcilie com o parser se ele exigir nomes específicos. + +apiVersion: v1 +kind: PromotionManifest + +service: + name: vanna-clubpetro + kind: backend # serviço HTTP (não é MFE) + description: > + Deploy do Vanna 2.0 (text-to-SQL sobre LLM) que responde perguntas em + pt-BR consultando o ClickHouse Cloud (database gold) com RLS por tenant + (program_id + store_id). + owner: dados-plataforma # TODO: confirmar squad/owner + runtimePort: 8765 + +promotion: + from: lab + to: homolog + # Onde o pipeline re-materializa o código (branch limpa, sem histórico do lab). + kubernetesOverlay: k8s/hml + +# --------------------------------------------------------------------------- +# Gate 0 — Stack +# --------------------------------------------------------------------------- +stack: + language: python + runtime: python-3.11 + framework: vanna-2.0 + fastapi + uvicorn + packageManager: pip + # Stack padrão do core é NestJS>=10/TypeORM0.3 (yarn) OU MFE React17/MUI5. + # Python é NO-GO por padrão -> exceção formal abaixo (Gate 0). + stackException: + requested: true + reason: > + O serviço é um deploy da biblioteca Vanna 2.0, que é Python e não tem + equivalente em NestJS. O núcleo (agente LLM, memória vetorial ChromaDB, + runner RLS do ClickHouse e o web component ) vem do upstream + vanna-ai/vanna. Reescrever em Node significaria reimplementar toda essa + cadeia — inviável e sem ganho. Mantém-se Python; todos os demais gates + aplicáveis são cumpridos (ver abaixo). + approvedBy: "" # TODO: preencher com o aprovador humano do stackException + +# --------------------------------------------------------------------------- +# Gate B — Migrations +# --------------------------------------------------------------------------- +migrations: + applicable: false + reason: > + O serviço não possui schema relacional próprio. O ClickHouse Cloud + (database gold) é gerido fora do serviço; o "treino" (train.py) apenas + popula um vector store ChromaDB local, idempotente e reconstruível + (rm -rf chroma_db/ && python train.py). Não há migrations TypeORM. + Toda query ao ClickHouse é parametrizada via settings do clickhouse_connect + (RLS), nunca por interpolação de string — ver rls_runner.py. + +# --------------------------------------------------------------------------- +# Gate D — Atalhos / segurança +# --------------------------------------------------------------------------- +security: + envVersioned: false # apenas .env.example versionado; .env no .gitignore + hardcodedSecrets: false # segredos vêm de k8s Secret (envFrom), nunca do git + hardcodedLabHost: false # overlay homolog usa homologation.clubpetro.com + npmrcVersioned: false # sem .npmrc no repo + +# --------------------------------------------------------------------------- +# Gate G — Isolamento de credenciais de banco +# --------------------------------------------------------------------------- +database: + # Credenciais do ClickHouse em secret DEDICADO ao serviço (não genérico). + credentialsSecret: vanna-clubpetro-db + genericSecret: false # NÃO usa sqlhomologation / sqluserhomolgeneric etc. + engine: clickhouse-cloud + database: gold + +# --------------------------------------------------------------------------- +# Admissão de capacidade +# --------------------------------------------------------------------------- +capacity: + replicas: 1 # ChromaDB é SQLite-based; réplicas > 1 corrompem o store + strategy: Recreate # PVC ReadWriteOnce — não dá 2 pods montando ao mesmo tempo + resources: + requests: + cpu: "200m" + memory: "1Gi" + limits: + cpu: "1" + memory: "2Gi" + persistentVolume: + size: 5Gi + accessMode: ReadWriteOnce # store vetorial + cache ONNX + CSVs + +# --------------------------------------------------------------------------- +# Deltas (avisos — não bloqueiam) +# --------------------------------------------------------------------------- +deltas: + A_architecture: > + Tenant (program_id/store_id) resolvido via RequestContext e validado por + regex ^[A-Za-z0-9_-]+$ (agent.py). O embed confiável passa os IDs por query + param do — não há token JWT nesta arquitetura de embed. + C_contract: > + OpenAPI auto-servido pelo FastAPI (/openapi.json, /docs). Rotas de chat já + versionadas: /api/vanna/v2/chat_sse|chat_websocket|chat_poll (upstream). + E_ops: > + Probes de liveness/readiness em GET /health; replicas: 1; Dockerfile + multi-stage rodando como usuário non-root (uid 10001). + F_ci: > + CI/CD via .gitea/workflows/cd.yml (build remoto no Cloud Build + rollout + no GKE). bitbucket-pipelines.yml N/A: o repo vive no Gitea self-hosted. diff --git a/server.py b/server.py index e03d36f..20aadaa 100644 --- a/server.py +++ b/server.py @@ -42,6 +42,12 @@ def _build_app(): if os.path.isdir(dist): fastapi_app.mount("/static", StaticFiles(directory=dist), name="static") + @fastapi_app.get("/health") + async def health(): + # Alvo das probes de liveness/readiness (Gate E). Leve, sem tocar + # ClickHouse/LLM — só sinaliza que o processo subiu e serve HTTP. + return {"status": "ok"} + @fastapi_app.get("/vanna-theme.css") async def vanna_theme(): path = os.path.join(here, "static", "vanna-theme.css") -- 2.45.2