From ea7da4364afdc9a71b335185003857dad64ad0d8 Mon Sep 17 00:00:00 2001 From: Athletic Map Deploy Date: Thu, 17 Sep 2026 17:58:21 +0000 Subject: [PATCH] feat(plataforma): tenant proprio da Athletic Map Namespace, banco, Keycloak e realm proprios, separados dos clientes e do tenant de demonstracao. Leva 4 contextos (configuracao, cadastro, financeiro, captacao) mais bff e SPA, e nao os 13 de um cliente: a Athletic Map nao e escolinha. Ingress escrito do zero, sem o stub backend legado e sem catch-all /api. Duas rotas publicas, ambas por desenho: /api/public/leads (o formulario da landing page) e /api/public/webhooks/asaas (o aviso de pagamento, que chega da internet sem token nosso). Dominio em *.athleticmap.influxdigital.com.br, e nao no canonico athleticmap.com do P0.9: aquele ainda nao tem registro A, e com desafio HTTP-01 nascer nele daria certificado nao emitido e login quebrado. Tres SealedSecret gerados para ESTE namespace, e a chave publica do backup no lugar do placeholder. As 7 imagens locais exigidas estao no no, incluindo servico-captacao:1.0 e atm-pg-backup:1.0, construidas hoje. Preparado em P3.1 a P3.4. Validado com kubectl --dry-run: 32 documentos. --- .../plataforma/00-namespace-quota-netpol.yaml | 68 +++++++++ .../plataforma/05-sealed-db-credentials.yaml | 36 +++++ .../plataforma/06-sealed-keycloak-admin.yaml | 37 +++++ tenants/plataforma/07-sealed-asaas.yaml | 33 +++++ tenants/plataforma/10-postgres.yaml | 133 ++++++++++++++++++ tenants/plataforma/20-keycloak.yaml | 86 +++++++++++ tenants/plataforma/30-ingress.yaml | 93 ++++++++++++ tenants/plataforma/35-realm-import-cm.yaml | 39 +++++ tenants/plataforma/40-middleware.yaml | 10 ++ tenants/plataforma/50-backup.yaml | 83 +++++++++++ .../plataforma/70-servico-configuracao.yaml | 59 ++++++++ tenants/plataforma/79-servico-financeiro.yaml | 81 +++++++++++ tenants/plataforma/80-servico-cadastro.yaml | 59 ++++++++ tenants/plataforma/82-servico-captacao.yaml | 81 +++++++++++ tenants/plataforma/90-servico-bff.yaml | 60 ++++++++ tenants/plataforma/95-frontend-spa.yaml | 48 +++++++ tenants/plataforma/README.md | 102 ++++++++++++++ .../plataforma/realm/athleticmap-realm.json | 62 ++++++++ 18 files changed, 1170 insertions(+) create mode 100644 tenants/plataforma/00-namespace-quota-netpol.yaml create mode 100644 tenants/plataforma/05-sealed-db-credentials.yaml create mode 100644 tenants/plataforma/06-sealed-keycloak-admin.yaml create mode 100644 tenants/plataforma/07-sealed-asaas.yaml create mode 100644 tenants/plataforma/10-postgres.yaml create mode 100644 tenants/plataforma/20-keycloak.yaml create mode 100644 tenants/plataforma/30-ingress.yaml create mode 100644 tenants/plataforma/35-realm-import-cm.yaml create mode 100644 tenants/plataforma/40-middleware.yaml create mode 100644 tenants/plataforma/50-backup.yaml create mode 100644 tenants/plataforma/70-servico-configuracao.yaml create mode 100644 tenants/plataforma/79-servico-financeiro.yaml create mode 100644 tenants/plataforma/80-servico-cadastro.yaml create mode 100644 tenants/plataforma/82-servico-captacao.yaml create mode 100644 tenants/plataforma/90-servico-bff.yaml create mode 100644 tenants/plataforma/95-frontend-spa.yaml create mode 100644 tenants/plataforma/README.md create mode 100644 tenants/plataforma/realm/athleticmap-realm.json diff --git a/tenants/plataforma/00-namespace-quota-netpol.yaml b/tenants/plataforma/00-namespace-quota-netpol.yaml new file mode 100644 index 0000000..00830f5 --- /dev/null +++ b/tenants/plataforma/00-namespace-quota-netpol.yaml @@ -0,0 +1,68 @@ +# Tenant `plataforma` da Athletic Map — isolamento do tenant (namespace + quota + limites + rede) +# Modelo: 1 namespace por cliente (ver arquitetura-multitenant-pods-por-cliente.md) +apiVersion: v1 +kind: Namespace +metadata: + name: plataforma-prod + labels: + name: plataforma-prod + tenant: plataforma + athleticmap.io/tier: platform +--- +# Cota de recursos do tenant — protege o cluster contra consumo excessivo de um cliente +apiVersion: v1 +kind: ResourceQuota +metadata: + name: tenant-quota + namespace: plataforma-prod +spec: + hard: + requests.cpu: "2" + requests.memory: 2Gi + limits.cpu: "4" + limits.memory: 6Gi + pods: "20" + persistentvolumeclaims: "4" +--- +# Defaults de recursos por container (para a cota ser satisfeita sem declarar em cada pod) +apiVersion: v1 +kind: LimitRange +metadata: + name: defaults + namespace: plataforma-prod +spec: + limits: + - type: Container + default: + cpu: 500m + memory: 512Mi + defaultRequest: + cpu: 100m + memory: 128Mi +--- +# Isolamento de rede: nega tráfego cross-tenant; permite intra-namespace + ingress (Traefik) + egress +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: deny-cross-tenant + namespace: plataforma-prod +spec: + podSelector: {} + policyTypes: [Ingress, Egress] + ingress: + - from: + - podSelector: {} # mesma namespace + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system # Traefik (ingress) + egress: + - to: # intra-namespace (pods) + ClusterIPs (VIP de service, pre-DNAT) + - podSelector: {} + - ipBlock: { cidr: 10.43.0.0/16 } + - to: # DNS (CoreDNS em kube-system) + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - { protocol: UDP, port: 53 } + - { protocol: TCP, port: 53 } diff --git a/tenants/plataforma/05-sealed-db-credentials.yaml b/tenants/plataforma/05-sealed-db-credentials.yaml new file mode 100644 index 0000000..6bd78f6 --- /dev/null +++ b/tenants/plataforma/05-sealed-db-credentials.yaml @@ -0,0 +1,36 @@ +# ============================================================================ +# Credencial do banco do tenant `plataforma`. Gerada em 2026-09-17 (P3.1). +# +# PODE SER COMMITADA. Um SealedSecret e cifrado com a chave publica do +# controlador DESTE cluster e, no escopo estrito (o padrao), so decifra com +# ESTE nome (`db-credentials`) neste namespace (`plataforma-prod`). Renomear o +# recurso ou copia-lo para outro namespace faz o controlador recusar — que e a +# razao de o do `piloto-prod` nao servir aqui. +# +# A SENHA NAO PASSOU POR MAQUINA DE NINGUEM: nasceu, selou e morreu dentro de um +# unico shell no servidor, e so este YAML cifrado voltou. Por isso nao existe +# lugar nenhum onde ela esteja anotada — e nem precisa: quem a le e o pod, a +# partir do Secret que o controlador cria. +# +# Consumida por: postgres (POSTGRES_PASSWORD), keycloak (KC_DB_PASSWORD) e os +# quatro servicos (SPRING_DATASOURCE_PASSWORD). +# +# PARA TROCAR: `scripts/gerar_credenciais_plataforma.sh db` e commitar. O +# Postgres ja existente NAO adota a senha nova sozinho — `POSTGRES_PASSWORD` so +# vale na inicializacao do volume. Depois do primeiro deploy, trocar exige +# tambem um `ALTER ROLE` no banco. +# ============================================================================ +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: db-credentials + namespace: plataforma-prod +spec: + encryptedData: + password: AgCQo0CKgSuv6JiTU7GnxufPJhGTwNcNPuENhGy0D5Qk1g0/cYO2Pw2C6gjRc9VfqMOR0s2eJrIQ+x7xn42/jSV7Fh3GgFBaiI8+43nTJSr1IiUZThI2taLfKx5oC/Ai8Tww4Kclx8CvS1hyrxSNBWg6vMjvMYL2bTkLhvtBJzlUr1IJPQ3hSzv8zDfTe5Ybsu55t3mHJ6jWm57NacA3X3nfbitMNTQKmz95bq2gqiuMs2GUuB1XV9YPoMEUhlJCx1HDJLl+c3oEoqGcMwuAvlYctJC2XbMbqMgryIXqN5QfzMAqFcdHhI6m0pEDIa+Kl5ErABzTxmAlDFNZTCiUNKygK1uRnXJbybEhouA7vfbZQbT3fW+4ADkvn6/RcVupsQXsE3nl1oOLKGNn77W6xqEWsWd9NymJHqMIKd7YjmhFLbWf4oLivthwqJk45be1sy+jIXx4lzZ/T99qSx9NHK0ysFaEZYs2v4P8i2f34S0XdjaBzWgxksO19nTvZwJR8V2KZte93paeF4So+RwV0bpfISJrltAzFaqmHXR9V88S/XvbXDYM40tN6dCDvAAswe8tXbcYA9A+/e8xZqOEoiWzIRgTkDUmtEwdY2iO/Q9CCjmPClsOSLQPgEQAe6gM3tC1xntCVbiVl3OZq32tNi9bhzjClpUdcdsTfgXoWCSvDBDqurfXBnh8vr20ApN3H7J4CFUeSSCH3rVD99yIkzzC7YUHm7kssIc= + template: + metadata: + creationTimestamp: null + name: db-credentials + namespace: plataforma-prod diff --git a/tenants/plataforma/06-sealed-keycloak-admin.yaml b/tenants/plataforma/06-sealed-keycloak-admin.yaml new file mode 100644 index 0000000..fe6f21a --- /dev/null +++ b/tenants/plataforma/06-sealed-keycloak-admin.yaml @@ -0,0 +1,37 @@ +# ============================================================================ +# Senha do admin do Keycloak do tenant `plataforma`. Gerada em 2026-09-17 (P3.1). +# +# PODE SER COMMITADA, pela mesma razao do 05: cifrada para este cluster e presa +# ao nome `keycloak-admin` no namespace `plataforma-prod`. +# +# A senha nasceu, selou e morreu num unico shell no servidor. Nao esta anotada +# em lugar nenhum. +# +# Consumida por: keycloak (KC_BOOTSTRAP_ADMIN_PASSWORD). +# +# ATENCAO — `KC_BOOTSTRAP_ADMIN_*` so cria o usuario na PRIMEIRA subida, com o +# banco do Keycloak vazio. Depois disso, trocar aqui nao troca a senha de quem ja +# existe: isso se faz pelo proprio Keycloak. +# +# PARA ENTRAR a primeira vez, o usuario e o que o `20-keycloak.yaml` define em +# `KC_BOOTSTRAP_ADMIN_USERNAME`. A senha voce recupera do cluster depois de +# aplicar, com `kubectl -n plataforma-prod get secret keycloak-admin`, e a +# primeira coisa a fazer e trocar por uma sua. +# +# PARA REGERAR (so antes da primeira subida): +# `scripts/gerar_credenciais_plataforma.sh keycloak`. +# ============================================================================ +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: keycloak-admin + namespace: plataforma-prod +spec: + encryptedData: + password: AgCNIMheCsVg1efxr9P/NEImwaBC03RLQv4tNZdOnc2z5ledyQSWEl13RiYL/aJs0shUiSkKxGi0WOY4zCfpDMTvOVkW/CtcoK/FvXHQ3Qi6m22f0D3lQwWybpZb8S0tKIQv4T0AqXI6Rd/qYmCzxsaFsvUXYq74aGAxX4OiUAdP1UPBBg9kzcUmAUN1oJNbfGI+DdPA1NEx0GOmcMzKX1LDBNDKBLzaLHbCNJNEH2wioHp9N17/iimceg8sG7JO84a9YgDkDSsHd7MG+ilGjDbRSoVEY/4pjGlC7gmiEr65zvUkd2aEhUrKAlk5+8f4OSprmlLrNZDbGeNFj54QTBnuR0zHV6NrAKGTF9pb9pwvA5gK4S6AmDrG3pzBXHHfv6jqx9zXnnn3f7TnB3WVXPzEaz9Wh53d+6iys5pJykR7StLZzby5YP8qTNzazMnSALJdVsAXl9qgJx3fRMbG9zXLGGL1u+VJcyiFF7nRQl5EjheJocCHmClgUzdIOAEdsjvQ1O1DOJrAcICU0PRmqyJL02yUVbIA5NIQAP9leQQnNHcxY40UbNCXitaiakA6LFYivpvmJkRy1b9HGh4Xm5hAEvGPPLUXCcsFyoTBIJ6rULr6SSrBLQVK4s/Ufv1ohL1ievC92J1sCy9MpVA1kOSeOyD2vNwYCq2jRXhER5xoSLB1UdsvNqP9d5oQLU94K7W4sPQY9tD4hgKoOrynEDYvmvsJK9f/xjE= + template: + metadata: + creationTimestamp: null + name: keycloak-admin + namespace: plataforma-prod diff --git a/tenants/plataforma/07-sealed-asaas.yaml b/tenants/plataforma/07-sealed-asaas.yaml new file mode 100644 index 0000000..b374dac --- /dev/null +++ b/tenants/plataforma/07-sealed-asaas.yaml @@ -0,0 +1,33 @@ +# ============================================================================ +# Credenciais do Asaas do tenant `plataforma`. Geradas em 2026-09-17 (P3.3). +# +# PODE SER COMMITADO: cifrado para ESTE cluster e preso ao nome +# `asaas-credentials` no namespace `plataforma-prod`. +# +# Sao DOIS segredos por desenho: `api-key` autoriza COBRAR e MOVIMENTAR +# DINHEIRO; `webhook-token` so autoriza alguem a nos AVISAR que algo aconteceu. +# `AsaasConfig` recusa subir o pod se forem iguais. +# +# O `webhook-token` tem de estar cadastrado no painel do Asaas, no campo "Token +# de autenticacao" do webhook. Se ele for regerado aqui sem ser atualizado la, os +# avisos passam a ser recusados — e 15 recusas seguidas param a fila da conta. +# +# PARA REGERAR: `scripts/gerar_credenciais_plataforma.sh asaas` (pede a chave de +# producao do painel). Passo a passo da ligacao em +# `docs/p33-ligar-o-asaas-de-producao.md`. +# ============================================================================ +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: asaas-credentials + namespace: plataforma-prod +spec: + encryptedData: + api-key: AgA6oIPHMEJv5lT6RHldPfmgqw4rNOxq0dDSZ4Pj033HAGC+6mTo4nUQTrsIE3JyvjyDZdGC7lqNzG4URiNQA9AmwAFummr7jzqlsE4BBmzsemRPZWgPhKnsAfxe+wsiVhUlQ8a/h1GMwXPG/ASE/04eRZZ4nczY7sT61gS9TKj68p90a70jmto6Y2/8VZIVa8UrgdC8CSDVNzQhpCgUHyfWEq4+ofBoami+OAQxIC0tcYWJMrbaZm+qJmGrDibxL+a4qaJXpof4Q1r275M6Ih6Bq0iPjRoom9pkckzcwcamyGF8C0OvL2ToRb/JiSOcRbum+nQ0MeiTGPoOb99conhKTtQKoTpQ50J9K6/K3d6qaa773PswZUiRqTs0kfIP73U3fcr4UY0lckbDr3SbXLvW8IILxxWoHe6LUnmL1IopD2vI1UwzYNC+zofP3fzigmeDMLk0019DrZ+Pt5/FXAiJW0I5KZmqTjcQC6cBAFpmdRewvqdYSQ/FHPuLJV928eeBe5vSoMdpbJJydZFmzVYJT+xxZ87SPgBxaJd6Oif/1vJuXXApAQJ6n+n/0278uHGCsQzI8WfC/VFX+HU2Emfvxg+ab0kw9nG8YXXnTh5PbIJmFCZpg2B66IYjY6OF540sfQIPNPcLgOlPwrmlzbSFKvJpbjZYjesTCKwokwBXObvUF2Qg0gx0cJCcMWzNelcnTaSLBhQir5GOnCa2CPveMxaFafCw4/pc9cqEmKkqsuzYT2A/gzQUAcVJ8jhA7+0OjBQWq++2m24J6LSzCa5nLkyTCOFMIqP9JoAQYkJZxBN9UHvtSaeLB0QN9fvJiiFLpzygKAIDH9hyVjX67ZZe8vtTqAowp2m5t+3MncJ9+t+ImIFo+VU3lPzyvhX1gswGP3zuwVUzG9XYHc8zxWy9ebrx16d8 + webhook-token: AgAPV9CV3uQ5r9ZHUPtWomaL07MnnjhrFolq4Onrya2zlAWlt7MV9++kz9/6x781eE9/G+X+r6M+HyWpP47Tpkxn6adWngyhW4qTPTcbdWQC4XeP/nF8XVB3rak1YKveMS3TiQ+1RA8j88YzzofiT5svOXUn7YK+vNBBsA0XxgEMerwzeYEbn0FEf0ZI/toAOnjJ7U72BCUALyt12q7mIZnI5XIdNNQlLQqRnbwaOTfb6RTo6fF8WT6qF5kAW1ontYaxTRxd4dUg5QKCOuWC1ycvlbX7lcjSRkWJojmKqK5fuoobds+/IRjj/WdV3KWMnojhif539SxHK1ThKphxc/bsquNKcWOfX09ISoh6dLGTxgiurbFnVerXGf4TbASd0xVH1DNtPm0QCq3rzsKxk/JxsO/w4ySSYltX0xWYJYy+bqDJdffvFi296I3sgINiseAT+HbcDpZAwZH0bAwb6nqIlq1YqUiXhd0NShFGGMqwUA7j5b3LC1iZYdqZ0b2YOydjhkkAy56NzFg/bKu2obNkcstftxfDBTAk2z7AZDUdoaZLw21AVLWb+IRbQUhIBoT/u9bQWvsMLC/uqyrz9kBVxDKGyFN94mrsgJgL+rQ1WjIwRevN1ZhrZdYodhwZ9E94Pj7iEdAXrCVKLdQlFzSvy/vRLRe6Bg5hxa0iMBTaXt9FmsQQ0O305nHR3IzxXPTsLBmUHUellYDnfYyACtYdYST40s7JJJ3GAc+xfmZL8g== + template: + metadata: + creationTimestamp: null + name: asaas-credentials + namespace: plataforma-prod diff --git a/tenants/plataforma/10-postgres.yaml b/tenants/plataforma/10-postgres.yaml new file mode 100644 index 0000000..4404587 --- /dev/null +++ b/tenants/plataforma/10-postgres.yaml @@ -0,0 +1,133 @@ +# PostgreSQL dedicado do tenant (banco da aplicação + banco do Keycloak) +apiVersion: v1 +kind: ConfigMap +metadata: + name: pg-initdb + namespace: plataforma-prod +data: + 01-keycloak.sql: | + CREATE DATABASE keycloak; +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-data + namespace: plataforma-prod +spec: + accessModes: [ReadWriteOnce] + storageClassName: local-path + resources: + requests: + storage: 5Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: plataforma-prod +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:16 + # TLS ligado, e pg_hba SEM regra `host` em claro — ver o ConfigMap + # pg-hba-tls. Sem trocar `host` por `hostssl`, por o sslmode=require + # no cliente NAO impede outro cliente de conectar em claro: o servidor + # aceitaria os dois. O criterio "conexao sem TLS e recusada" e do lado + # do servidor, e so esta linha o cumpre. + args: + - -c + - ssl=on + - -c + - ssl_cert_file=/etc/postgres-tls/tls.crt + - -c + - ssl_key_file=/etc/postgres-tls/tls.key + - -c + - hba_file=/etc/postgres-hba/pg_hba.conf + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: athleticmap + - name: POSTGRES_USER + value: atm + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: tls + mountPath: /etc/postgres-tls + readOnly: true + - name: hba + mountPath: /etc/postgres-hba + readOnly: true + - name: data + mountPath: /var/lib/postgresql/data + - name: initdb + mountPath: /docker-entrypoint-initdb.d + readinessProbe: + exec: + command: ["pg_isready", "-U", "atm", "-d", "athleticmap"] + initialDelaySeconds: 10 + periodSeconds: 5 + # fsGroup 999 = gid do postgres na imagem debian. E o que faz o Secret + # montar como root:postgres, e sem isso o pod nao sobe. + securityContext: + fsGroup: 999 + volumes: + - name: tls + secret: + secretName: postgres-tls + # 0640 + fsGroup 999 = root:postgres, que e a UNICA combinacao que + # funciona. Medido: root:root 0640 faz o Postgres morrer com + # "could not load private key file: Permission denied" — o processo + # roda como uid 999 e nao le arquivo do grupo root. + defaultMode: 0640 + - name: hba + configMap: + name: pg-hba-tls + - name: data + persistentVolumeClaim: + claimName: postgres-data + - name: initdb + configMap: + name: pg-initdb +--- +# pg_hba SEM nenhuma regra `host` em claro: quem chegar pela rede sem TLS leva +# "no pg_hba.conf entry ... no encryption" e nao conecta. +apiVersion: v1 +kind: ConfigMap +metadata: + name: pg-hba-tls + namespace: plataforma-prod +data: + pg_hba.conf: | + # socket local: initdb, readinessProbe e administracao de dentro do pod + local all all trust + # rede: so com TLS + hostssl all all 0.0.0.0/0 scram-sha-256 + hostssl all all ::/0 scram-sha-256 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: plataforma-prod +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 diff --git a/tenants/plataforma/20-keycloak.yaml b/tenants/plataforma/20-keycloak.yaml new file mode 100644 index 0000000..86bd603 --- /dev/null +++ b/tenants/plataforma/20-keycloak.yaml @@ -0,0 +1,86 @@ +# Keycloak dedicado do tenant `plataforma` (IdP proprio da Athletic Map), persistindo no Postgres +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak + namespace: plataforma-prod +spec: + replicas: 1 + selector: + matchLabels: + app: keycloak + template: + metadata: + labels: + app: keycloak + spec: + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:26.0 + args: ["start-dev", "--import-realm"] + env: + - name: KC_DB + value: postgres + - name: KC_DB_URL + value: "jdbc:postgresql://postgres:5432/keycloak?sslmode=require" + - name: KC_DB_USERNAME + value: atm + - name: KC_DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: password + - name: KC_BOOTSTRAP_ADMIN_USERNAME + value: admin + - name: KC_BOOTSTRAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: keycloak-admin + key: password + - name: KC_HEALTH_ENABLED + value: "true" + - name: KC_HTTP_ENABLED + value: "true" + - name: KC_PROXY_HEADERS + value: xforwarded + - name: KC_HOSTNAME + value: "auth-plataforma.athleticmap.influxdigital.com.br" + - name: KC_HOSTNAME_STRICT + value: "true" + ports: + - containerPort: 8080 + - containerPort: 9000 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + readinessProbe: + httpGet: + path: /health/ready + port: 9000 + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 40 + volumeMounts: + - name: realm-import + mountPath: /opt/keycloak/data/import + readOnly: true + volumes: + - name: realm-import + configMap: + name: kc-realm-import +--- +apiVersion: v1 +kind: Service +metadata: + name: keycloak + namespace: plataforma-prod +spec: + selector: + app: keycloak + ports: + - port: 8080 + targetPort: 8080 diff --git a/tenants/plataforma/30-ingress.yaml b/tenants/plataforma/30-ingress.yaml new file mode 100644 index 0000000..e00cdd6 --- /dev/null +++ b/tenants/plataforma/30-ingress.yaml @@ -0,0 +1,93 @@ +# Ingress do tenant `plataforma`. +# +# ── Por que este arquivo foi escrito, e nao copiado ─────────────────────────── +# +# O piloto guarda o Ingress dentro de `30-apps-stubs.yaml`, junto com o stub +# `backend`. Copiar aquele arquivo traria duas coisas que este tenant nao deve +# herdar: +# +# 1. O proprio stub `backend`, que P0.5 mediu nao atender trafego nenhum (11 +# requisicoes externas em 50 dias, todas 401) e que esta em fila para ser +# aposentado. Tenant novo nao nasce com divida. +# 2. A rota catch-all `/api` -> `backend`. Aqui nao ha catch-all: o que nao +# casa com um prefixo conhecido devolve 404, que e a resposta honesta. +# +# Os nomes de Service tambem divergem de proposito: o piloto publica `frontend`, +# e aqui o Service se chama `frontend-spa` (veja `95-frontend-spa.yaml`). Copiar +# o do piloto daria um Ingress apontando para um Service inexistente. +# +# ── Por que o dominio e `.influxdigital.com.br`, e nao `.athleticmap.com` ───── +# +# O canonico decidido com o usuario em 2026-09-16 (P0.9) e +# `.athleticmap.com`. Ele ainda NAO vale, e nascer nele hoje quebraria +# justamente o criterio deste card ("login funciona"): +# +# - `plataforma.athleticmap.com` e `auth-plataforma.athleticmap.com` nao tem +# registro A nenhum (conferido em 2026-09-17 por nslookup); +# - o apex `athleticmap.com` resolve para 72.61.42.87, que NAO e o cluster — +# o cluster e 187.77.37.184; +# - cert-manager usa desafio HTTP-01, entao sem o host resolvindo para o +# cluster o certificado nao e emitido, e sem certificado nao ha login. +# +# Ja `*.athleticmap.influxdigital.com.br` e wildcard para o cluster (conferido: +# ate um subdominio inventado resolve para 187.77.37.184). Entao este tenant +# sobe hoje e atravessa o corte de dominio junto com os demais, no passo 5 do +# `docs/p09-plano-de-corte-dominio.md` — e nao numa migracao propria. +# +# ── As rotas publicas ──────────────────────────────────────────────────────── +# +# Duas, e ambas por desenho: +# +# `/api/public/leads` quem preenche o formulario da landing page +# ainda nao e cliente, e portanto nao tem token. +# `/api/public/webhooks/asaas` o aviso de pagamento chega dos servidores do +# provedor, que tambem nao tem token nosso; quem +# autentica e o cabecalho `asaas-access-token`. +# +# Nenhuma outra rota esta aberta, e nao ha catch-all — o que significa que uma +# rota publica NOVA em qualquer servico precisa ser acrescentada aqui a mao, ou +# devolve 404. O `scripts/conferir_rotas_publicas.py` existe para isso nao +# depender de alguem lembrar. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: plataforma + namespace: plataforma-prod + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.middlewares: plataforma-prod-redirect-https@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - plataforma.athleticmap.influxdigital.com.br + - auth-plataforma.athleticmap.influxdigital.com.br + secretName: plataforma-tls + rules: + - host: plataforma.athleticmap.influxdigital.com.br + http: + paths: + # PUBLICA — sem token, por desenho. Vem primeiro porque o Traefik + # ordena por tamanho do prefixo, mas deixar explicito evita que alguem + # reordene sem perceber. + - { path: /api/public/leads, pathType: Prefix, backend: { service: { name: servico-captacao, port: { number: 80 } } } } + + # PUBLICA — o aviso de pagamento do Asaas, que chega da internet sem + # token nenhum (P3.3). NAO casa com /api/financeiro: o controlador + # atende em /api/public/webhooks/asaas, e como aqui nao ha catch-all, + # a ausencia desta linha daria 404 em todo aviso — a cobranca seria + # paga e nunca baixada. Quem autentica e o cabecalho + # `asaas-access-token`, conferido no WebhookAsaasController. + - { path: /api/public/webhooks/asaas, pathType: Prefix, backend: { service: { name: servico-financeiro, port: { number: 80 } } } } + + # O resto exige token, como em qualquer tenant. + - { path: /api/configuracao, pathType: Prefix, backend: { service: { name: servico-configuracao, port: { number: 80 } } } } + - { path: /api/cadastro, pathType: Prefix, backend: { service: { name: servico-cadastro, port: { number: 80 } } } } + - { path: /api/financeiro, pathType: Prefix, backend: { service: { name: servico-financeiro, port: { number: 80 } } } } + - { path: /bff, pathType: Prefix, backend: { service: { name: servico-bff, port: { number: 80 } } } } + - { path: /, pathType: Prefix, backend: { service: { name: frontend-spa, port: { number: 80 } } } } + + - host: auth-plataforma.athleticmap.influxdigital.com.br + http: + paths: + - { path: /, pathType: Prefix, backend: { service: { name: keycloak, port: { number: 8080 } } } } diff --git a/tenants/plataforma/35-realm-import-cm.yaml b/tenants/plataforma/35-realm-import-cm.yaml new file mode 100644 index 0000000..c219aef --- /dev/null +++ b/tenants/plataforma/35-realm-import-cm.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +data: + athleticmap-realm.json: | + { + "realm": "athleticmap", + "enabled": true, + "displayName": "Athletic Map", + "loginWithEmailAllowed": true, + "roles": { + "realm": [ + { "name": "admin" }, + { "name": "atm_athlete" }, + { "name": "atm_trainer" }, + { "name": "atm_team_admin" }, + { "name": "atm_fed_admin" } + ] + }, + "clients": [ + { + "clientId": "spa", + "name": "Athletic Map SPA", + "publicClient": true, + "protocol": "openid-connect", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "redirectUris": ["https://plataforma.athleticmap.influxdigital.com.br/*"], + "webOrigins": ["https://plataforma.athleticmap.influxdigital.com.br"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "https://plataforma.athleticmap.influxdigital.com.br/*" + } + } + ] + } +kind: ConfigMap +metadata: + name: kc-realm-import + namespace: plataforma-prod diff --git a/tenants/plataforma/40-middleware.yaml b/tenants/plataforma/40-middleware.yaml new file mode 100644 index 0000000..2ff6e39 --- /dev/null +++ b/tenants/plataforma/40-middleware.yaml @@ -0,0 +1,10 @@ +# Redirect HTTP->HTTPS (Traefik). Aplicado por-ingress (nao afeta o desafio ACME do cert-manager). +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: redirect-https + namespace: plataforma-prod +spec: + redirectScheme: + scheme: https + permanent: true diff --git a/tenants/plataforma/50-backup.yaml b/tenants/plataforma/50-backup.yaml new file mode 100644 index 0000000..97f669a --- /dev/null +++ b/tenants/plataforma/50-backup.yaml @@ -0,0 +1,83 @@ +# Backup diario do Postgres do tenant `plataforma` (pg_dump cifrado com age, ver P2.4) +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: pg-backups + namespace: plataforma-prod +spec: + accessModes: [ReadWriteOnce] + storageClassName: local-path + resources: + requests: + storage: 5Gi +--- +# ConfigMap com o DESTINATARIO age (chave PUBLICA). +# +# Publica pode ser versionada: ela so cifra. Quem tem a publica NAO abre o +# backup. A privada e gerada por scripts/backup/gerar_chave_backup.sh na maquina +# de quem restaura, e NUNCA chega neste repositorio nem no VPS. +# +# O valor abaixo e um marcador: substitua pelo destinatario real antes de +# aplicar. Com o marcador, o backup_tenant.sh recusa rodar em vez de gravar em +# claro — e o comportamento certo. +apiVersion: v1 +kind: ConfigMap +metadata: + name: pg-backup-chave + namespace: plataforma-prod +data: + # Chave PUBLICA do age. Versionar isto e seguro e proposital: ela so serve + # para CIFRAR. Abrir um backup exige a privada, que por desenho nao esta + # neste repositorio nem no servidor — se estivesse no VPS, quem invadisse a + # maquina levaria a chave junto com o arquivo cifrado, e o backup cifrado + # nao teria servido para nada. + # + # Gerada em 2026-09-17 por `scripts/backup/gerar_chave_backup.sh`, na maquina + # do operador. ANOTE A DATA: rotacionar exige manter a chave antiga enquanto + # houver backup cifrado com ela dentro da retencao de 7 dias. + destinatario: "age1g37gc3gvq0zn7802pqh5dd0m3pkvc4z3g2hqk5w3dgvnaz3hwsrq7ps04y" +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: pg-backup + namespace: plataforma-prod +spec: + schedule: "0 2 * * *" # diariamente 02:00 + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + concurrencyPolicy: Forbid + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + containers: + # Imagem propria: postgres:16 + age. O dump precisa ser cifrado + # ANTES de sair do processo, entao as duas ferramentas tem de estar + # no mesmo container. Construida de scripts/backup/Containerfile. + - name: pg-backup + image: docker.io/library/atm-pg-backup:1.0 + env: + - name: PGPASSWORD + valueFrom: + secretKeyRef: { name: db-credentials, key: password } + - name: CHAVE_PUBLICA + valueFrom: + configMapKeyRef: { name: pg-backup-chave, key: destinatario } + - { name: PGHOST, value: "postgres" } + - { name: PGUSER, value: "atm" } + - { name: DATABASES, value: "athleticmap keycloak" } + - { name: DESTINO, value: "/backups" } + - { name: RETENCAO_DIAS, value: "7" } + # COMANDO_ENVIO fica vazio ate haver credencial de armazenamento + # externo. Sem ele o backup fica cifrado, mas NO MESMO SERVIDOR — + # e o proprio script avisa isso no fim da execucao. + - { name: COMANDO_ENVIO, value: "" } + command: ["backup_tenant.sh"] + volumeMounts: + - { name: backups, mountPath: /backups } + volumes: + - name: backups + persistentVolumeClaim: + claimName: pg-backups diff --git a/tenants/plataforma/70-servico-configuracao.yaml b/tenants/plataforma/70-servico-configuracao.yaml new file mode 100644 index 0000000..c575a1c --- /dev/null +++ b/tenants/plataforma/70-servico-configuracao.yaml @@ -0,0 +1,59 @@ +# Bounded context "configuracao" como app proprio (ADR-0002): Deployment/pod +# medivel = centro de custo. Schema proprio "configuracao" no Postgres do silo +# (criado pelo Flyway). Roteado em /api/configuracao no ingress do tenant. +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: servico-configuracao + namespace: plataforma-prod + labels: + app: servico-configuracao + athleticmap.io/contexto: configuracao +spec: + replicas: 1 + selector: { matchLabels: { app: servico-configuracao } } + template: + metadata: + labels: + app: servico-configuracao + athleticmap.io/contexto: configuracao + spec: + containers: + - name: servico-configuracao + image: docker.io/library/servico-configuracao:1.0 + imagePullPolicy: Never + ports: [{ containerPort: 8083 }] + env: + - { 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_JWK_SET_URI, value: "http://keycloak:8080/realms/athleticmap/protocol/openid-connect/certs" } + - { name: ATM_ISSUER, value: "https://auth-plataforma.athleticmap.influxdigital.com.br/realms/athleticmap" } + - { name: ATM_TENANT, value: "plataforma" } + resources: + requests: { cpu: 150m, memory: 320Mi } + limits: { cpu: "1", memory: 768Mi } + readinessProbe: + httpGet: { path: /api/configuracao/health, 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-configuracao + namespace: plataforma-prod + labels: + app: servico-configuracao +spec: + selector: { app: servico-configuracao } + ports: [{ name: http, port: 80, targetPort: 8083 }] diff --git a/tenants/plataforma/79-servico-financeiro.yaml b/tenants/plataforma/79-servico-financeiro.yaml new file mode 100644 index 0000000..134b77d --- /dev/null +++ b/tenants/plataforma/79-servico-financeiro.yaml @@ -0,0 +1,81 @@ +# Bounded context "financeiro" (2a onda): Cobranca + ApuracaoRateio (centros de custo). +# Schema proprio "financeiro" (Flyway). Roteado em /api/financeiro no ingress. +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: servico-financeiro + namespace: plataforma-prod + labels: + app: servico-financeiro + athleticmap.io/contexto: financeiro +spec: + replicas: 1 + selector: { matchLabels: { app: servico-financeiro } } + template: + metadata: + labels: + app: servico-financeiro + athleticmap.io/contexto: financeiro + spec: + containers: + - name: servico-financeiro + image: docker.io/library/servico-financeiro:1.0 + imagePullPolicy: Never + ports: [{ containerPort: 8083 }] + env: + - { 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_JWK_SET_URI, value: "http://keycloak:8080/realms/athleticmap/protocol/openid-connect/certs" } + - { name: ATM_ISSUER, value: "https://auth-plataforma.athleticmap.influxdigital.com.br/realms/athleticmap" } + - { name: ATM_TENANT, value: "plataforma" } + + # ── Asaas de PRODUCAO (P3.3) ────────────────────────────────── + # Aqui e o tenant da plataforma: a conta Asaas e a da propria + # Athletic Map, e nao a de um cliente. + # + # O rotulo `ASAAS_AMBIENTE` e a URL tem de concordar. Se nao + # concordarem, `AsaasClient` RECUSA a emissao — de proposito: + # producao apontando para sandbox faria a cobranca "funcionar" sem + # ninguem pagar de verdade, que e o erro caro e silencioso. + - { name: ASAAS_BASE_URL, value: "https://api.asaas.com/v3" } + - { name: ASAAS_AMBIENTE, value: "producao" } + - { name: ASAAS_USER_AGENT, value: "AthleticMap/plataforma" } + + # Os DOIS segredos sao separados, e nao por preciosismo: a API Key + # autoriza movimentar dinheiro; o token do webhook so autoriza + # alguem a nos avisar que algo aconteceu. Se forem iguais, + # `AsaasConfig` RECUSA subir o pod. + - name: ASAAS_API_KEY + valueFrom: + secretKeyRef: { name: asaas-credentials, key: api-key } + - name: ASAAS_WEBHOOK_TOKEN + valueFrom: + secretKeyRef: { name: asaas-credentials, key: webhook-token } + resources: + requests: { cpu: 150m, memory: 320Mi } + limits: { cpu: "1", memory: 768Mi } + readinessProbe: + httpGet: { path: /api/financeiro/health, 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-financeiro + namespace: plataforma-prod + labels: + app: servico-financeiro +spec: + selector: { app: servico-financeiro } + ports: [{ name: http, port: 80, targetPort: 8083 }] diff --git a/tenants/plataforma/80-servico-cadastro.yaml b/tenants/plataforma/80-servico-cadastro.yaml new file mode 100644 index 0000000..b809cc0 --- /dev/null +++ b/tenants/plataforma/80-servico-cadastro.yaml @@ -0,0 +1,59 @@ +# Bounded context "cadastro" como app proprio (ADR-0002/0003): Deployment/pod +# medivel = centro de custo. Schema proprio "cadastro" no Postgres do silo +# (criado pelo Flyway). Roteado em /api/cadastro no ingress do tenant. +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: servico-cadastro + namespace: plataforma-prod + labels: + app: servico-cadastro + athleticmap.io/contexto: cadastro +spec: + replicas: 1 + selector: { matchLabels: { app: servico-cadastro } } + template: + metadata: + labels: + app: servico-cadastro + athleticmap.io/contexto: cadastro + spec: + containers: + - name: servico-cadastro + image: docker.io/library/servico-cadastro:1.0 + imagePullPolicy: Never + ports: [{ containerPort: 8083 }] + env: + - { 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_JWK_SET_URI, value: "http://keycloak:8080/realms/athleticmap/protocol/openid-connect/certs" } + - { name: ATM_ISSUER, value: "https://auth-plataforma.athleticmap.influxdigital.com.br/realms/athleticmap" } + - { name: ATM_TENANT, value: "plataforma" } + resources: + requests: { cpu: 150m, memory: 320Mi } + limits: { cpu: "1", memory: 768Mi } + readinessProbe: + httpGet: { path: /api/cadastro/health, 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-cadastro + namespace: plataforma-prod + labels: + app: servico-cadastro +spec: + selector: { app: servico-cadastro } + ports: [{ name: http, port: 80, targetPort: 8083 }] diff --git a/tenants/plataforma/82-servico-captacao.yaml b/tenants/plataforma/82-servico-captacao.yaml new file mode 100644 index 0000000..8949c06 --- /dev/null +++ b/tenants/plataforma/82-servico-captacao.yaml @@ -0,0 +1,81 @@ +# 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: + containers: + - name: servico-captacao + image: docker.io/library/servico-captacao:1.0 + imagePullPolicy: Never + ports: [{ containerPort: 8083 }] + env: + - { 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.influxdigital.com.br" } + - { name: CAPTACAO_RETENCAO_MESES, value: "24" } + - { 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: "" } + resources: + requests: { cpu: 150m, memory: 320Mi } + limits: { cpu: "1", memory: 768Mi } + readinessProbe: + httpGet: { path: /api/public/leads/health, 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 }] diff --git a/tenants/plataforma/90-servico-bff.yaml b/tenants/plataforma/90-servico-bff.yaml new file mode 100644 index 0000000..48debde --- /dev/null +++ b/tenants/plataforma/90-servico-bff.yaml @@ -0,0 +1,60 @@ +# BFF (Backend-for-Frontend) — ponto unico da SPA; agrega configuracao + cadastro +# com token relay. Stateless (sem banco). Roteado em /bff no ingress do tenant. +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: servico-bff + namespace: plataforma-prod + labels: + app: servico-bff + athleticmap.io/contexto: bff +spec: + replicas: 1 + selector: { matchLabels: { app: servico-bff } } + template: + metadata: + labels: + app: servico-bff + athleticmap.io/contexto: bff + spec: + containers: + - name: servico-bff + image: docker.io/library/servico-bff:1.5 + imagePullPolicy: Never + ports: [{ containerPort: 8083 }] + env: + - { name: ATM_CONFIG_URL, value: "http://servico-configuracao" } + - { name: ATM_CADASTRO_URL, value: "http://servico-cadastro" } + - { name: ATM_ORGANIZACAO_URL, value: "http://servico-organizacao" } + - { name: ATM_PLANEJAMENTO_URL, value: "http://servico-planejamento" } + - { name: ATM_CAMPEONATO_URL, value: "http://servico-campeonato" } + - { name: ATM_SAUDE_URL, value: "http://servico-saude" } + - { name: ATM_FINANCEIRO_URL, value: "http://servico-financeiro" } + - { name: ATM_JWK_SET_URI, value: "http://keycloak:8080/realms/athleticmap/protocol/openid-connect/certs" } + - { name: ATM_ISSUER, value: "https://auth-plataforma.athleticmap.influxdigital.com.br/realms/athleticmap" } + - { name: ATM_TENANT, value: "plataforma" } + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { cpu: 500m, memory: 512Mi } + readinessProbe: + httpGet: { path: /bff/health, port: 8083 } + initialDelaySeconds: 20 + periodSeconds: 10 + failureThreshold: 30 + livenessProbe: + httpGet: { path: /actuator/health/liveness, port: 8083 } + initialDelaySeconds: 50 + periodSeconds: 20 + failureThreshold: 6 +--- +apiVersion: v1 +kind: Service +metadata: + name: servico-bff + namespace: plataforma-prod + labels: + app: servico-bff +spec: + selector: { app: servico-bff } + ports: [{ name: http, port: 80, targetPort: 8083 }] diff --git a/tenants/plataforma/95-frontend-spa.yaml b/tenants/plataforma/95-frontend-spa.yaml new file mode 100644 index 0000000..7e91bdc --- /dev/null +++ b/tenants/plataforma/95-frontend-spa.yaml @@ -0,0 +1,48 @@ +# SPA Angular (shell OIDC + admin de catalogos + cadastro de Atleta) consumindo +# o BFF. Substitui o stub estatico nginx/configMap. Roteado em / no ingress. +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend-spa + namespace: plataforma-prod + labels: + app: frontend-spa + athleticmap.io/contexto: frontend +spec: + replicas: 1 + selector: { matchLabels: { app: frontend-spa } } + template: + metadata: + labels: + app: frontend-spa + athleticmap.io/contexto: frontend + spec: + containers: + - name: frontend-spa + image: docker.io/library/frontend-spa:1.6 + imagePullPolicy: Never + ports: [{ containerPort: 80 }] + env: + - { name: ATM_KC_URL, value: "https://auth-plataforma.athleticmap.influxdigital.com.br" } + - { name: ATM_REALM, value: "athleticmap" } + - { name: ATM_CLIENT, value: "spa" } + - { name: ATM_TENANT, value: "plataforma" } + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { cpu: 200m, memory: 128Mi } + readinessProbe: + httpGet: { path: /, port: 80 } + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend-spa + namespace: plataforma-prod + labels: + app: frontend-spa +spec: + selector: { app: frontend-spa } + ports: [{ name: http, port: 80, targetPort: 80 }] diff --git a/tenants/plataforma/README.md b/tenants/plataforma/README.md new file mode 100644 index 0000000..2540d70 --- /dev/null +++ b/tenants/plataforma/README.md @@ -0,0 +1,102 @@ +# Tenant `plataforma` + +**Este não é um cliente.** É onde a Athletic Map opera a si mesma: o namespace, +o banco e o realm da própria empresa, separados dos clientes e separados também +do tenant de demonstração. + +Gerado em 2026-09-17 pela tarefa **P3.1** (card 137). + +## Estado: preparado, não aplicado + +Nada aqui foi publicado. Os manifests estão prontos para o ArgoCD, mas **faltam +duas credenciais que só um humano pode gerar** — veja +[`05-sealed-db-credentials.yaml`](05-sealed-db-credentials.yaml) e +[`06-sealed-keycloak-admin.yaml`](06-sealed-keycloak-admin.yaml). No lugar dos +`SealedSecret` há um `ConfigMap` marcador, com nome impossível de confundir +(`CREDENCIAL-DO-BANCO-NAO-FOI-GERADA`) e com o comando `kubeseal` a rodar. + +Enquanto os marcadores estiverem no lugar, o `postgres` e o `keycloak` não +sobem. É deliberado: melhor não subir do que subir com senha conhecida. + +**Por que o assistente não gerou as credenciais:** `SealedSecret` é selado +**para um namespace**. O do `piloto-prod` não decifra em `plataforma-prod` — o +controlador recusa. E gerar segredo novo significaria escrevê-lo em algum lugar +antes de selar, o que o preâmbulo das tarefas proíbe. + +## O que este tenant leva, e o que não leva + +Um cliente roda 13 contextos. Aqui rodam 4, porque a Athletic Map não é uma +escolinha — não tem atleta, treino, campeonato nem prontuário. + +| Leva | Para quê | +| --- | --- | +| `servico-configuracao` | catálogos e parâmetros | +| `servico-cadastro` | pessoas — os próprios funcionários | +| `servico-financeiro` | o financeiro da casa (veja a ressalva abaixo) | +| `servico-captacao` | **só existe aqui**: os leads da landing page | +| `bff` + `frontend-spa` | a porta de entrada | +| `postgres`, `keycloak`, `backup` | a infraestrutura do silo | + +**Não leva**, de propósito: + +- os 9 contextos restantes (agenda, saúde, pedagógico, nutrição, campeonato, + organização, planejamento, assistência social, administrativo) — sem uso aqui; +- o `30-apps-stubs.yaml` do piloto, que carrega o stub `backend` legado. P0.5 + mediu: 11 requisições externas em 50 dias, todas 401. Tenant novo não nasce + com dívida, então o Ingress foi escrito do zero + ([`30-ingress.yaml`](30-ingress.yaml)) e **sem o catch-all `/api`**. + +## Domínio + +Hoje: `plataforma.athleticmap.influxdigital.com.br` e +`auth-plataforma.athleticmap.influxdigital.com.br`. + +Não é o canônico. O canônico decidido em 2026-09-16 é +`.athleticmap.com` (P0.9), mas **o corte ainda não foi executado**. +Conferido em 2026-09-17: `plataforma.athleticmap.com` não tem registro A, e o +apex `athleticmap.com` resolve para 72.61.42.87, que não é o cluster +(187.77.37.184). Como o cert-manager usa desafio HTTP-01, nascer no `.com` hoje +daria certificado não emitido e login quebrado. + +`*.athleticmap.influxdigital.com.br` é wildcard para o cluster, então este +tenant sobe já e **deve atravessar o corte junto com os demais**, no passo 5 do +plano. + +> **Atenção — este tenant NÃO está no corte preparado.** O plano vive em +> `docs/p09-plano-de-corte-dominio.md`, **que não existe nesta branch**: está em +> `feat/dominio-app-athleticmap` (commit `f71480a`). E esse commit altera +> `acme`, `demo` e `piloto` — o `plataforma` ainda não existia quando foi +> escrito. Quem executar o P0.9 precisa **incluir esta pasta à mão**, senão ela +> fica sozinha no domínio do fornecedor. + +Os hosts a trocar aqui estão em `30-ingress.yaml`, +`20-keycloak.yaml`, `35-realm-import-cm.yaml`, `realm/athleticmap-realm.json`, +`95-frontend-spa.yaml`, o `ATM_ISSUER` dos quatro serviços e o `CAPTACAO_ORIGEM`. + +## Uma ressalva sobre "cobrar a si mesma" + +O card 137 descreve este tenant como "onde a Athletic Map cobra a si mesma". +Isso **contradiz** `project-docs/Escolinhas esportivas/Produto e negócio/Planos +de assinatura.md:137`, que diz: *"Billing da assinatura é outro sistema. O `servico-financeiro` cobra o +aluno do cliente; cobrar o cliente exige gateway próprio (Asaas, Stripe, Iugu) e +ciclo de inadimplência/suspensão — não dá para reaproveitar."* + +O tenant foi montado assim mesmo porque a infraestrutura é idêntica nas duas +leituras — namespace, banco, realm e login não mudam. O que muda é se o +`servico-financeiro` daqui serve para cobrar assinatura. **Isso está em aberto** e +é o que os cards 138 (P3.2) e 139 (P3.3, Asaas) decidem. + +## Como verificar, quando for aplicado + +O critério do card é *"login no tenant plataforma funciona e o financeiro abre +vazio"*. Depois de gerar as duas credenciais e deixar o ArgoCD sincronizar: + +```sh +kubectl -n plataforma-prod get pods # postgres, keycloak e os 6 serviços prontos +kubectl -n plataforma-prod get ingress # certificado emitido, ADDRESS preenchido +curl -sI https://plataforma.athleticmap.influxdigital.com.br | head -1 # 200 +``` + +O "financeiro vazio" é o esperado: banco novo, nenhuma cobrança. Se vier dado, +o tenant está apontando para o banco errado — pare e confira o +`05-sealed-db-credentials.yaml`. diff --git a/tenants/plataforma/realm/athleticmap-realm.json b/tenants/plataforma/realm/athleticmap-realm.json new file mode 100644 index 0000000..b2e1e9b --- /dev/null +++ b/tenants/plataforma/realm/athleticmap-realm.json @@ -0,0 +1,62 @@ +{ + "realm": "athleticmap", + "enabled": true, + "displayName": "Athletic Map", + "loginWithEmailAllowed": true, + "roles": { + "realm": [ + { + "name": "admin" + }, + { + "name": "atm_athlete" + }, + { + "name": "atm_trainer" + }, + { + "name": "atm_team_admin" + }, + { + "name": "atm_fed_admin" + } + ] + }, + "clients": [ + { + "clientId": "spa", + "name": "Athletic Map SPA", + "publicClient": true, + "protocol": "openid-connect", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "redirectUris": [ + "https://plataforma.athleticmap.influxdigital.com.br/*" + ], + "webOrigins": [ + "https://plataforma.athleticmap.influxdigital.com.br" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "https://plataforma.athleticmap.influxdigital.com.br/*" + }, + "protocolMappers": [ + { + "name": "funcionario_id", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "funcionario_id", + "claim.name": "funcionario_id", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + } + ] +} \ No newline at end of file