Compare commits

..

2 Commits

Author SHA1 Message Date
2fe6460578 Merge pull request '[lab] Prepara serviço para gates de promoção lab -> homolog' (#9) from lab/promotion-gates into main
All checks were successful
CD / build (push) Successful in 5m28s
2026-08-11 21:52:34 +00:00
f4a2ee44ca [lab] chore(promotion): prepara serviço para gates lab -> homolog
All checks were successful
CD / build (pull_request) Successful in 5m2s
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) <noreply@anthropic.com>
2026-08-11 18:50:22 -03:00
14 changed files with 336 additions and 7 deletions

View File

@ -11,8 +11,9 @@ CLICKHOUSE_USER=wren_ia
CLICKHOUSE_PASSWORD= CLICKHOUSE_PASSWORD=
CLICKHOUSE_SECURE=true CLICKHOUSE_SECURE=true
# CORS (separar por vírgula) # CORS: origens permitidas por ambiente, separadas por vírgula.
VANNA_CORS_ORIGINS=https://lab.clubpetro.com,https://homologation.clubpetro.com # 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 defaults (apenas pra `python ask.py` na CLI; servidor web extrai de query string)
RLS_PROGRAM_ID= RLS_PROGRAM_ID=

View File

@ -82,9 +82,10 @@ jobs:
gcloud container clusters get-credentials ${{ secrets.GKE_CLUSTER }} --region ${{ secrets.GKE_REGION }} --project ${{ secrets.GCP_PROJECT }} gcloud container clusters get-credentials ${{ secrets.GKE_CLUSTER }} --region ${{ secrets.GKE_REGION }} --project ${{ secrets.GCP_PROJECT }}
NS=${{ secrets.K8S_NAMESPACE }} NS=${{ secrets.K8S_NAMESPACE }}
# 1) Aplica manifests (idempotente — cria PVC/Service/Ingress/Deployment se faltarem) # 1) Aplica manifests do LAB (idempotente — cria PVC/Service/Ingress/Deployment se faltarem).
if [ -d k8s ]; then # Overlay do lab vive em k8s/lab/; homolog usa k8s/hml/ (promoção via pipeline lab->homolog).
kubectl apply -n "$NS" -f k8s/ if [ -d k8s/lab ]; then
kubectl apply -n "$NS" -f k8s/lab/
fi fi
# 2) Atualiza image # 2) Atualiza image

View File

@ -51,8 +51,16 @@ RUN pip install -r requirements.txt
# Código do app # Código do app
COPY . . COPY . .
# data dirs # Gate E — usuário non-root. uid/gid 10001 batem com o securityContext do
RUN mkdir -p /app/chroma_db /app/data_storage # 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 EXPOSE 8765

85
k8s/hml/deployment.yaml Normal file
View File

@ -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

32
k8s/hml/ingress.yaml Normal file
View File

@ -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

View File

@ -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 <ns> 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 <ns> 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: ""

13
k8s/lab/pvc.yaml Normal file
View File

@ -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

15
k8s/lab/service.yaml Normal file
View File

@ -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

115
promotion-manifest.yaml Normal file
View File

@ -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 <vanna-chat>) 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 <vanna-chat> — 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.

View File

@ -42,6 +42,12 @@ def _build_app():
if os.path.isdir(dist): if os.path.isdir(dist):
fastapi_app.mount("/static", StaticFiles(directory=dist), name="static") 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") @fastapi_app.get("/vanna-theme.css")
async def vanna_theme(): async def vanna_theme():
path = os.path.join(here, "static", "vanna-theme.css") path = os.path.join(here, "static", "vanna-theme.css")