# servico-captacao — recebe o formulario da landing page. # # SO EXISTE NESTE TENANT, e e por isso que o tenant `plataforma` precisa # existir: lead e prospecto DA ATHLETIC MAP, e gravar isso no banco de um # cliente seria o oposto do que a Fase 2 inteira fez. Ver P2.8. # # E o unico servico do repositorio com rota PUBLICA (sem token): quem # preenche o formulario ainda nao e cliente. O que substitui a autenticacao # esta no proprio servico — CORS restrito, rate limit por IP e consent # obrigatorio. # Bounded context "financeiro" (2a onda): Cobranca + ApuracaoRateio (centros de custo). # Schema proprio "financeiro" (Flyway). Roteado em /api/public/leads no ingress. --- apiVersion: apps/v1 kind: Deployment metadata: name: servico-captacao namespace: plataforma-prod labels: app: servico-captacao athleticmap.io/contexto: financeiro spec: replicas: 1 selector: { matchLabels: { app: servico-captacao } } template: metadata: labels: app: servico-captacao athleticmap.io/contexto: financeiro spec: # P5.6/P5.9: ServiceAccount DEDICADA (ver 16-rbac-provisionamento.yaml). # Com a `default`, a permissao de ler e escalar Deployment dos tenants # valeria para qualquer pod que subisse sem `serviceAccountName`. serviceAccountName: atm-provisionamento volumes: - { name: gitops, emptyDir: {} } # O Documento de Governanca do painel do dono (botao "Informacoes"). optional: sem o # ConfigMap o pod sobe e a rota responde 404 "documentacao nao publicada". SEM subPath: # o kubelet atualiza o arquivo quando o ConfigMap muda. - { name: documentacao, configMap: { name: governanca-documentacao, optional: true } } # P5.3 — a copia de trabalho do GitOps, refeita a cada subida do pod. # # CLONE RASO E ANONIMO. O Gitea aceita leitura sem credencial neste # repositorio (conferido em 2026-09-22), e leitura e tudo o que a etapa 1 # precisa: ela RENDERIZA o molde para `tenants/`. Empurrar e a etapa # ARGO_SINCRONIZADO, que depende de credencial e continua bloqueada. # # `emptyDir`, e nao PVC: a copia e descartavel por desenho. Se o pod morrer # no meio de um provisionamento, o que ficou pela metade some junto — e isso # e melhor do que um clone velho e divergente sobrevivendo entre reinicios. # # A imagem e a do `act_runner`, que JA esta no no e tem git 2.45.2. Nao ha # registry aqui (`imagePullPolicy: Never`), entao construir uma imagem so # para ter git custaria mais do que reusar esta. # # O endereco e o INTERNO, o mesmo que o ApplicationSet do ArgoCD usa — o # externo passa por Ingress e TLS, e nao ha motivo para sair do cluster # para buscar o que esta dentro dele. Depende do `19-netpol-gitea.yaml`. initContainers: - name: clonar-gitops image: docker.io/gitea/act_runner:0.2.11 imagePullPolicy: Never command: ["/bin/sh", "-c"] args: - | set -e git clone --depth 1 http://gitea-http.gitea.svc:3000/atmadmin/athletic-map-deploy.git /gitops test -d /gitops/moldes/consolidado || { echo "molde ausente no clone"; exit 1; } # O container principal roda como o usuario app (uid 999), e o clone # e feito como root. Sem esta linha a copia de trabalho fica # so-leitura para ele, e a etapa MANIFESTOS_GERADOS falha ao escrever # tenants/ — medido em 2026-09-22. # # chown, e nao fsGroup: fsGroup ajusta o GRUPO e depende de o # diretorio ter permissao de grupo, o que o git nao garante arquivo a # arquivo. Entregar a posse e explicito e nao depende de umask. chown -R 999:999 /gitops echo "clone pronto: $(ls /gitops/moldes/consolidado | wc -l) arquivo(s) de molde" volumeMounts: - { name: gitops, mountPath: /gitops } resources: requests: { cpu: 10m, memory: 32Mi } limits: { cpu: 200m, memory: 128Mi } containers: - name: servico-captacao image: docker.io/library/servico-captacao:4.41-versao-minima imagePullPolicy: Never ports: [{ containerPort: 8083 }] volumeMounts: - { name: gitops, mountPath: /gitops } - { name: documentacao, mountPath: /etc/atm/documentacao, readOnly: true } env: # Purchase pelo servidor (ConversaoNoServidor). Os IDs sao publicos (os # mesmos da landing); token e segredo vem do SealedSecret conversao-credentials. - { name: ATM_CONVERSAO_META_PIXEL_ID, value: "1697857761759110" } - name: ATM_CONVERSAO_META_TOKEN valueFrom: secretKeyRef: { name: conversao-credentials, key: meta-token } - { name: ATM_CONVERSAO_GA4_MEDICAO, value: "G-NLM3ESK4E9" } - name: ATM_CONVERSAO_GA4_SEGREDO valueFrom: secretKeyRef: { name: conversao-credentials, key: ga4-segredo } # N16 — a chave de que sai o token de CADA tenant (HMAC do nome; ChaveDoResumo). # Sem ela o captacao recusa todo resumo (401) e o provisionamento nao sela o # resumo-credentials do tenant novo. Selada em 08-sealed-resumo-chave.yaml. - name: RESUMO_CHAVE valueFrom: secretKeyRef: { name: resumo-chave, key: chave, optional: true } # E-mail dos LOGINS dos realms que o provisionamento cria (EtapaCriarRealm). # Caixa propria, fora da cota da nao-responda@. Senha no SealedSecret smtp-logins. - { name: SMTP_LOGINS_USUARIO, value: "acesso@athleticmap.com" } # Cifra da chave de licenca (06/10/2026): o reenvio manda a MESMA chave. Sem ela, reenvio e envio de # acesso respondem 503. NUNCA trocar sem antes "trocar chave" em todos os tenants. - name: ATM_LICENCA_CIFRA valueFrom: secretKeyRef: { name: licenca-cifra, key: chave } - name: SMTP_LOGINS_SENHA valueFrom: secretKeyRef: { name: smtp-logins, key: senha } - { name: SPRING_DATASOURCE_URL, value: "jdbc:postgresql://postgres:5432/athleticmap?sslmode=require" } - { name: SPRING_DATASOURCE_USERNAME, value: "atm" } - name: SPRING_DATASOURCE_PASSWORD valueFrom: secretKeyRef: { name: db-credentials, key: password } - { name: ATM_TENANT, value: "plataforma" } # CORS: so a landing page. Formulario publico com CORS aberto # vira endpoint de spam de qualquer site. Ver P2.8. # A origem do navegador que faz o POST. A landing page e servida # pelo proprio nginx da SPA (`frontend/nginx.conf`, `location = # /lp-escolinhas`), que neste tenant e o Service `frontend-spa` — # logo a origem e o host do tenant, e nao o apex de marketing. # `@CrossOrigin` aqui aceita UMA origem: o placeholder vira string # literal, entao virgula nao separa em duas. Se a LP for publicada # noutro host, este valor muda junto ou o formulario toma CORS. - { name: CAPTACAO_ORIGEM, value: "https://plataforma.athleticmap.com" } - { name: CAPTACAO_RETENCAO_MESES, value: "24" } # A URL de retorno do PAGAMENTO (nao do webhook, que e servidor # para servidor). `{lead}` vira o id do lead na hora de montar o # link (CorpoDoLinkDeAssinatura): a tela /preparando aceita esse # id porque, no caminho do LINK, a assinatura do Asaas so nasce # quando o pagador conclui o checkout. - { name: ASSINATURA_URL_RETORNO, value: "https://plataforma.athleticmap.com/preparando?lead={lead}" } # P5.1 — o token do webhook de ASSINATURA. Sem ele o endpoint # RECUSA TUDO, de proposito, e o log diz exatamente isso. O mesmo # valor precisa estar cadastrado no painel do Asaas, na conta da # Athletic Map (nao na do cliente). - name: PLATAFORMA_WEBHOOK_TOKEN valueFrom: secretKeyRef: { name: plataforma-webhook-token, key: token } # P5.2 — o token de consulta do estado de um provisionamento. E # segredo NOSSO, diferente do de cima: o Asaas nao tem o que ler # aqui dentro. Interino, no cabecalho `x-atm-admin-token`; o lugar # definitivo e uma tela com papel do Keycloak. - name: PLATAFORMA_ADMIN_TOKEN valueFrom: secretKeyRef: { name: plataforma-admin-token, key: token } # A subconta Asaas de cada tenant (CobrancaDoTenant). O token autentica # o aviso de situacao que cada subconta manda para a plataforma; ele # nasceu no servidor (scripts/ligar_subconta.py) e vai no webhook de # cada subconta aberta — trocar aqui exige trocar la. - { name: SUBCONTA_LIGADO, value: "true" } - name: SUBCONTA_TOKEN_DE_SITUACAO valueFrom: secretKeyRef: { name: plataforma-subconta-token, key: token } # P5.3 — de onde vem o certificado PUBLICO do sealed-secrets. # # A URL, e nao um arquivo: o controlador ROTACIONA a chave a cada # 30 dias (medido: 16/06, 16/07, 15/08, 14/09), e um certificado # versionado nasce certo e fica velho em um mes, EM SILENCIO — # selar com o antigo continua funcionando enquanto ele guardar as # privadas anteriores. Lido a cada provisionamento, vem sempre o # corrente. # # Depende do `18-netpol-sealed-secrets.yaml`: sem ele a conexao # nao sai (o ipBlock da deny-cross-tenant nao vale depois do DNAT). - { name: PROVISIONAMENTO_CERT_SELAGEM, value: "http://sealed-secrets-controller.kube-system:8080/v1/cert.pem" } # P5.3 — a copia de trabalho do GitOps e o molde, no MESMO volume. # # O initContainer clona o repositorio em /gitops na subida; dentro # dele, `moldes/consolidado` e o molde e `tenants/` e onde a # etapa escreve. Um volume serve aos dois. # # Vazio falhava FECHADO, nomeando a propriedade — era o estado ate # 2026-09-22. Escrever num diretorio temporario que ninguem commita # daria a impressao de que a etapa funcionou. - { name: PROVISIONAMENTO_GITOPS, value: "/gitops" } # P5.3 — o repositorio remoto para onde o commit e EMPURRADO. # # O nome DE DENTRO do cluster, e nao o `git..nip.io` que se usa # de fora: o de fora sai do cluster e volta, e a NetworkPolicy de # egress (19-netpol-gitea) permite o caminho interno, nao o externo. # # `gitea-http` e headless (ClusterIP None), entao o DNS devolve o IP # do POD. Isso e o que faz a netpol por `podSelector` funcionar aqui # — `ipBlock` com ClusterIP nao funciona atravessando namespace, # porque o k3s avalia a politica DEPOIS do DNAT. - { name: PROVISIONAMENTO_GITOPS_URL, value: "http://gitea-http.gitea.svc:3000/atmadmin/athletic-map-deploy.git" } # P5.3 — quem empurra. ESTA CREDENCIAL IMPLANTA EM PRODUCAO: o # ApplicationSet tem gerador de diretorio sobre `tenants/*` com # selfHeal, entao a pasta empurrada vira Application e e aplicada # sozinha. Por isso e um usuario proprio do Gitea, com escrita so # neste repositorio, e um TOKEN revogavel — nao a conta atmadmin. - name: PROVISIONAMENTO_GITOPS_USUARIO valueFrom: secretKeyRef: { name: provisionamento-credentials, key: gitops-usuario } - name: PROVISIONAMENTO_GITOPS_SENHA valueFrom: secretKeyRef: { name: provisionamento-credentials, key: gitops-senha } # P5.4 — a Admin API do Keycloak, para criar o realm do tenant novo. # # `AdminDoKeycloak` pega token por `grant_type=password` no client # `admin-cli` do realm `master`, entao isto e um usuario DE LA, e nao # do realm `athleticmap`. # # Hoje pode ser o admin do master, que pode tudo. O certo e um # usuario de servico com `create-realm` e nada mais — esta no diario. - name: KEYCLOAK_ADMIN_USUARIO valueFrom: secretKeyRef: { name: provisionamento-credentials, key: keycloak-admin-usuario } - name: KEYCLOAK_ADMIN_SENHA valueFrom: secretKeyRef: { name: provisionamento-credentials, key: keycloak-admin-senha } - { name: PROVISIONAMENTO_MOLDE, value: "/gitops/moldes/consolidado" } - { name: PROVISIONAMENTO_PLANO_PADRAO, value: "bronze" } # O PAINEL DO COMPRADOR (realm `clientes`). O `iss` e conferido contra o # nome PUBLICO; as chaves vem por DENTRO do cluster, porque este pod nao # alcanca o nome publico do Keycloak (medido em 2026-09-25: HTTP 000). - { name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI, value: "https://auth-plataforma.athleticmap.com/realms/clientes" } - { name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWKSETURI, value: "http://keycloak.plataforma-prod.svc.cluster.local:8080/realms/clientes/protocol/openid-connect/certs" } # Login do DONO no painel (captacao 4.38): JWT do realm `athleticmap` com atm_admin, # aceito alem do x-atm-admin-token. Decodificador proprio, separado do realm clientes. - { name: ATM_PLATAFORMA_ADMIN_JWT_EMISSOR, value: "https://auth-plataforma.athleticmap.com/realms/athleticmap" } - { name: ATM_PLATAFORMA_ADMIN_JWT_CHAVES, value: "http://keycloak.plataforma-prod.svc.cluster.local:8080/realms/athleticmap/protocol/openid-connect/certs" } - { name: ATM_DOCUMENTACAO_ARQUIVO, value: "/etc/atm/documentacao/conteudo.json.gz" } # Versao minima de cada app (so AVISA "atualize"; quem derruba e a chave revogada). # Formato: app[.plataforma]=X.Y.Z, separados por virgula; a da plataforma vence. - { name: ATM_APP_VERSAO_MINIMA, value: "treinador=1.0.9,dono=1.0.9,pais=1.0.9" } # DESLIGADO ate a tela minha-conta.html estar no ar: ligado, manda a cada # comprador o convite para definir senha do painel. - { name: ACESSO_DO_CLIENTE_LIGADO, value: "true" } # P5.8 — a chave do Asaas, para CRIAR o link de assinatura. # # E o mesmo Secret que o `servico-financeiro` usa, e por isso # nao ha credencial nova a selar: muda so quem a le. Os dois # fluxos continuam distintos — o financeiro cobra o ATLETA, este # cobra o TENANT. # # `optional: true` de proposito: um ambiente sem Asaas sobe do # mesmo jeito, e a venda que precisar da chave recusa nomeando a # propriedade. Derrubar o pod tiraria do ar o provisionamento e a # captura de lead, que nao dependem disso. - name: ASAAS_API_KEY valueFrom: secretKeyRef: name: asaas-credentials key: api-key optional: true - { name: CAPTACAO_LIMITE_POR_IP, value: "5" } # Destino do aviso ao comercial. Vazio = lead e gravado e o # aviso vira log sem PII. Preencher quando houver SMTP. - { name: CAPTACAO_EMAIL_COMERCIAL, value: "" } # ── SMTP ───────────────────────────────── # # HOSTINGER, e nao Google. O dominio `athleticmap.com` tem MX em # mx1/mx2.hostinger.com e SPF `include:_spf.mail.hostinger.com`: # mandar pelo Gmail falharia SPF justamente no e-mail que leva o # link de senha de quem acabou de pagar. Medido em 2026-09-24 — a # credencial e ACEITA em smtp.hostinger.com:587 e recusada (535) # em smtp.gmail.com e em smtp.titan.email. - { name: SMTP_HOST, value: "smtp.hostinger.com" } - { name: SMTP_PORTA, value: "587" } - { name: SMTP_USUARIO, value: "nao-responda@athleticmap.com" } # `nao-responda@` de proposito: a senha dela vive no cluster, e uma # caixa que nao guarda nada limita o estrago se o segredo vazar. Sem # o RESPONDER_PARA, quem acabou de pagar e responde fala com o vazio. - { name: SMTP_RESPONDER_PARA, value: "contato@athleticmap.com" } - { name: SMTP_STARTTLS, value: "true" } - { name: SMTP_AUTENTICAR, value: "true" } # Vazia, o realm nasceria com um SMTP que so falha na hora de ENVIAR # — depois de o cliente ter conta. A etapa recusa antes disso. - name: SMTP_SENHA valueFrom: secretKeyRef: { name: smtp-credentials, key: senha } # O molde do realm. Sem ele a etapa recusa nomeando a propriedade; o # arquivo vive no mesmo clone do GitOps que o /gitops monta. - { name: PROVISIONAMENTO_REALM_TEMPLATE, value: "/gitops/moldes/realm-do-tenant.json" } # ── o banco do cliente novo (etapa BANCO_CRIADO) ────────────── # # DIRETO no pg-compartilhado, e nao pelo PgBouncer: o pool so serve # databases que ja estao na lista dele, e aqui o database ainda NAO # existe. E a netpol egress-banco-compartilhado ja permite os dois. # 8, e nao as 5 do padrao. # # A etapa BANCO_CRIADO reinicia o PgBouncer e espera ele voltar servindo o # tenant novo. Com 5 tentativas o backoff da 5+10+20+40+80 = 155s, e nas # tres medicoes de 2026-09-24 o pod novo demorou mais que isso — os dois # clientes pagos precisaram de uma retomada a mao CADA. # # Com 8 e o teto de 120s: 515s. A janela de 20 minutos da execucao continua # cabendo, entao isto nao troca uma espera por um incidente silencioso. - { name: PROVISIONAMENTO_TENTATIVAS, value: "8" } - { name: PROVISIONAMENTO_PG_ADMIN_URL, value: "jdbc:postgresql://pg-compartilhado:5432/{database}" } - { name: PROVISIONAMENTO_PG_ADMIN_USUARIO, value: "postgres" } - name: PROVISIONAMENTO_PG_ADMIN_SENHA valueFrom: secretKeyRef: { name: pg-compartilhado-admin, key: password } # As etapas de migracao e de catalogos, que conectam num banco que JA # existe. Tambem direto: elas rodam com a conta de administracao, e # passar pelo pool so acrescentaria uma peca ao caminho. - { name: PROVISIONAMENTO_BANCO_URL, value: "jdbc:postgresql://pg-compartilhado:5432/athleticmap_{tenant}" } - { name: PROVISIONAMENTO_BANCO_USUARIO, value: "postgres" } - name: PROVISIONAMENTO_BANCO_SENHA valueFrom: secretKeyRef: { name: pg-compartilhado-admin, key: password } resources: requests: { cpu: 150m, memory: 320Mi } limits: { cpu: "1", memory: 768Mi } # `/actuator/health/readiness`, e nao um health de dominio. # # A primeira versao apontava para `/api/public/leads/health`, copiado # do manifesto do financeiro por analogia. Esse caminho NAO EXISTE: # o `LeadController` tem um unico `@PostMapping`, e por desenho — a # superficie deste servico publico e exatamente um POST. O pod ficou # 0/1 enquanto a aplicacao dizia "Started" no log, que e o sintoma # mais confuso possivel. readinessProbe: httpGet: { path: /actuator/health/readiness, port: 8083 } initialDelaySeconds: 25 periodSeconds: 10 failureThreshold: 30 livenessProbe: httpGet: { path: /actuator/health/liveness, port: 8083 } initialDelaySeconds: 60 periodSeconds: 20 failureThreshold: 6 --- apiVersion: v1 kind: Service metadata: name: servico-captacao namespace: plataforma-prod labels: app: servico-captacao spec: selector: { app: servico-captacao } ports: [{ name: http, port: 80, targetPort: 8083 }]