diff --git a/moldes/consolidado/56-cronjob-resumo.yaml b/moldes/consolidado/56-cronjob-resumo.yaml new file mode 100644 index 0000000..d4dcf99 --- /dev/null +++ b/moldes/consolidado/56-cronjob-resumo.yaml @@ -0,0 +1,141 @@ +# ============================================================================= +# N16 — o resumo AGREGADO do tenant, entregue a plataforma uma vez por dia, para o +# comprador ver todas as escolinhas dele lado a lado no /minha-conta. +# +# AGUARDANDO GRAVACAO no molde (`moldes/consolidado/` do GitOps) e nos tenants que +# ja existem. Nada aqui foi aplicado. +# +# O desenho: o TENANT calcula (runtime-operacao, GET /api/resumo-do-tenant, so +# contadores e somas) e o TENANT envia (este CronJob). A plataforma nao entra no +# namespace nem no banco do tenant para isto. +# +# 1. token do client `operacao` no realm do tenant (o mesmo das tarefas do +# financeiro: Secret `operacao-credentials`); +# 2. GET http://runtime-operacao/api/resumo-do-tenant (lista de 1 ou 2 meses); +# 3. POST https://plataforma.athleticmap.com/api/public/resumo-do-tenant/{{TENANT}} +# com o cabecalho `x-atm-resumo-token` — do Secret `resumo-credentials`, que +# so vale para ESTE tenant (HMAC do nome dele com a chave da plataforma). +# +# A saida para a plataforma e pela internet (443 publico), que a deny-cross-tenant +# ja libera: nao precisa de NetworkPolicy nova. +# +# `optional: true` no resumo-credentials: tenant sem o Secret (chave da plataforma +# ainda nao configurada) roda o job, que termina OK dizendo "sem token" — nao gera +# KubeJobFailed nem gasta a cota de e-mail dos alertas. +# ============================================================================= +apiVersion: v1 +kind: ConfigMap +metadata: + name: resumo-cron + namespace: {{TENANT}}-prod +data: + enviar.sh: | + #!/bin/sh + set -eu + + : "${KEYCLOAK_URL:?falta KEYCLOAK_URL}" + : "${CLIENT_SECRET:?falta CLIENT_SECRET}" + : "${ISSUER_HOST:?falta ISSUER_HOST}" + : "${DESTINO:?falta DESTINO}" + + if [ -z "${RESUMO_TOKEN:-}" ]; then + echo "sem o Secret resumo-credentials neste tenant: nada a enviar." + exit 0 + fi + + i=0 + until curl -sS -o /dev/null -m 5 "${KEYCLOAK_URL}" 2>/dev/null; do + i=$((i+1)) + if [ "$i" -ge 24 ]; then + echo "ERRO: ${KEYCLOAK_URL} inalcancavel depois de 2 min." >&2 + exit 1 + fi + sleep 5 + done + + RESP="$(printf 'grant_type=client_credentials&client_id=operacao&client_secret=%s' \ + "${CLIENT_SECRET}" \ + | curl -sS --fail-with-body -X POST "${KEYCLOAK_URL}" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -H 'X-Forwarded-Proto: https' \ + -H 'X-Forwarded-Port: 443' \ + -H "X-Forwarded-Host: ${ISSUER_HOST}" -d @-)" + TOKEN="$(printf '%s' "${RESP}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')" + if [ -z "${TOKEN}" ]; then + echo "ERRO: nao consegui token pelo client 'operacao'." >&2 + exit 1 + fi + + # Os tokens vao por `-K -` (stdin), e nao na linha de comando: argumento de + # processo aparece em `ps` para quem estiver no no. + CODIGO="$(printf 'header = "Authorization: Bearer %s"\n' "${TOKEN}" \ + | curl -sS -K - -o /tmp/resumo.json -w '%{http_code}' http://runtime-operacao/api/resumo-do-tenant)" + if [ "${CODIGO}" != "200" ]; then + echo "ERRO: o runtime-operacao respondeu ${CODIGO} ao resumo." >&2 + exit 1 + fi + echo "resumo calculado: $(wc -c < /tmp/resumo.json) bytes (so numeros)" + + CODIGO="$(printf 'header = "x-atm-resumo-token: %s"\n' "${RESUMO_TOKEN}" \ + | curl -sS -K - -X POST "${DESTINO}" -H 'Content-Type: application/json' \ + --data-binary @/tmp/resumo.json -o /tmp/saida.json -w '%{http_code}')" + echo "plataforma: ${CODIGO}" + cat /tmp/saida.json; echo + case "${CODIGO}" in + 2*) ;; + *) echo "ERRO: a plataforma respondeu ${CODIGO}." >&2; exit 1 ;; + esac +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: resumo-para-a-plataforma + namespace: {{TENANT}}-prod +spec: + # Madrugada, depois da virada do dia em Sao Paulo e fora do horario das + # mensalidades (dia 1, 06h). + schedule: "30 4 * * *" + timeZone: America/Sao_Paulo + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 5 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + backoffLimit: 2 + template: + spec: + restartPolicy: OnFailure + containers: + - name: enviar + image: docker.io/curlimages/curl:8.9.1 + imagePullPolicy: Never + command: ["/bin/sh", "/script/enviar.sh"] + env: + - name: KEYCLOAK_URL + value: http://keycloak.plataforma-prod.svc:8080/realms/{{REALM}}/protocol/openid-connect/token + - name: ISSUER_HOST + value: auth.athleticmap.com + - name: DESTINO + value: https://plataforma.athleticmap.com/api/public/resumo-do-tenant/{{TENANT}} + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: + name: operacao-credentials + key: client-secret + - name: RESUMO_TOKEN + valueFrom: + secretKeyRef: + name: resumo-credentials + key: token + optional: true + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 200m, memory: 64Mi } + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: resumo-cron diff --git a/moldes/consolidado/56-cronjob-retencao.yaml b/moldes/consolidado/56-cronjob-retencao.yaml new file mode 100644 index 0000000..8024e4f --- /dev/null +++ b/moldes/consolidado/56-cronjob-retencao.yaml @@ -0,0 +1,75 @@ +# ============================================================================= +# P6.3 — a ELIMINACAO automatica (decisao 3 do dono, 2026-09-28): todo dia, o +# runtime-core elimina quem completou 5 anos do fim, anonimiza o fiscal e grava +# o registro. Nada fica elegivel antes de 2031 — ate la a rodada termina com +# zero eliminados, e e isso que prova que ela esta viva. +# +# PENDENTE DE GRAVACAO NO MOLDE (moldes/consolidado/) E NOS 4 TENANTS ANTIGOS +# (tenants/{acme,demo,escolinha,piloto}/). Este arquivo mora aqui ate o dono +# gravar — o mesmo caminho do 55-cronjobs-financeiro.yaml. +# +# Por que CronJob, e nao @Scheduled no runtime: com mais de uma replica, um +# agendador no processo rodaria a eliminacao duas vezes (o mesmo motivo do +# financeiro). Duas chamadas simultaneas nao duplicam nada — a pessoa e travada +# antes de sair —, mas uma so e o desenho certo. +# +# DEPENDE do ConfigMap `financeiro-cron` (55-cronjobs-financeiro.yaml): usa o +# mesmo `disparar.sh`, o mesmo client `operacao` e o mesmo Secret +# `operacao-credentials`. O runtime-core so aceita esta rota com token do client +# `operacao` (SegurancaDoRuntime) — nenhum usuario humano, nem admin. +# +# Titular que falha -> o endpoint devolve 500 -> o Job falha -> KubeJobFailed. +# Silencio aqui seria descobrir em 2031 que a rotina nunca rodou. +# +# ANTES DE APLICAR: o runtime-core do tenant tem de estar numa imagem com o +# modulo servico-retencao (senao a rota da 404 e o Job falha todo dia). +# Para conferir um tenant sem eliminar nada: `?simular=true` (mesmo caminho, +# transacao desfeita, contagens reais). +# ============================================================================= +apiVersion: batch/v1 +kind: CronJob +metadata: + name: retencao-eliminacao + namespace: {{TENANT}}-prod +spec: + # 04:40, longe do backup (02:30) e das tarefas do financeiro (:15 e :30). + schedule: "40 4 * * *" + timeZone: America/Sao_Paulo + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 5 + jobTemplate: + spec: + activeDeadlineSeconds: 1800 + backoffLimit: 1 + template: + spec: + restartPolicy: OnFailure + containers: + - name: disparar + image: docker.io/curlimages/curl:8.9.1 + imagePullPolicy: Never + command: ["/bin/sh", "/script/disparar.sh"] + env: + - name: KEYCLOAK_URL + value: http://keycloak.plataforma-prod.svc:8080/realms/{{REALM}}/protocol/openid-connect/token + - name: ISSUER_HOST + value: auth.athleticmap.com + - name: ALVO + value: http://runtime-core/api/retencao/eliminacao?limite=200 + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: + name: operacao-credentials + key: client-secret + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 200m, memory: 64Mi } + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: financeiro-cron + defaultMode: 0555 diff --git a/platform/monitoring/athleticmap-rules-nota-fiscal.yaml b/platform/monitoring/athleticmap-rules-nota-fiscal.yaml new file mode 100644 index 0000000..c10c4a7 --- /dev/null +++ b/platform/monitoring/athleticmap-rules-nota-fiscal.yaml @@ -0,0 +1,141 @@ +# ============================================================================= +# N18 — Alerta de nota fiscal travada. +# +# DESTINO NO GITOPS: platform/monitoring/athleticmap-rules-nota-fiscal.yaml +# (repositorio athletic-map-deploy do servidor). O app `atm-platform` le +# `platform/` com recurse, entao basta o arquivo estar la — nao precisa mexer +# no `athleticmap-rules.yaml` nem no `application.yaml`. +# +# De onde vem: `MetricasDasNotasFiscais` e `ProcessarNotasPendentes`, no +# servico-financeiro (dentro do runtime-operacao nos tenants consolidados). As +# metricas `atm_*` ja estao na lista de keep dos tres ServiceMonitors +# (runtimes, servicos, tenants), entao chegam sem mudar a coleta. +# +# Procedimento manual: docs/runbook-nota-fiscal-travada.md (repo nova-atm). +# +# ── POR QUE ESTES LIMITES ─────────────────────────────────────────────────── +# +# 6 HORAS para nota que devia estar andando (PENDENTE_EMISSAO / EMITINDO): +# - no normal, a varredura roda de hora em hora (:15) e o provedor emite em +# ate 15 min: uma nota passa menos de 1h30 pendente; +# - a NFS-e nacional cai e volta sozinha, em geral em minutos ou poucas +# horas, e o provedor tenta de novo sem ninguem pedir — alertar em 1-2h +# seria e-mail para algo que se resolve sozinho (e a caixa dos alertas e a +# mesma dos avisos aos clientes, com cota); +# - 6h sao seis rodadas da varredura: nao e mais soluco. E ainda da tempo de +# agir no mesmo dia, bem antes do fim da competencia. +# 24 HORAS vira critical (mesmo nome de alerta: o Alertmanager cala o warning +# quando o critical dispara — inhibit_rules com equal [namespace, alertname]). +# Um dia inteiro sem nota e pagante perguntando, e a virada de mes chegando. +# +# ERRO: 1 hora. Nota em ERRO NAO e reenviada sozinha — ninguem mexendo, ela +# fica la para sempre. O `for` so segura a rajada de uma rodada ruim, para o +# e-mail sair uma vez com o total, e nao nota a nota. +# +# PENDENTE_DADOS: 72 horas. Nao e instabilidade: e cadastro (configuracao +# fiscal ou dado do tomador faltando), resolvido pela escolinha ou pelo +# contador, em dia util. 72h atravessa um fim de semana sem alarme. O "mais +# brando" esta no PRAZO, e nao na severidade: `info` nao e usado na +# plataforma (scripts/test_regras_de_alerta.py so aceita warning/critical) e, +# no desenho do chart, info existe para ser calado pelo InfoInhibitor. +# ============================================================================= +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: athleticmap-rules-nota-fiscal + namespace: monitoring + labels: + release: monitoring # o Prometheus so carrega regras com este label +spec: + groups: + - name: athleticmap.nota-fiscal + rules: + # `max by (namespace, situacao)`: com duas replicas do runtime, as duas + # publicam o mesmo numero (a conta vem do banco do tenant). O max junta. + - alert: AthleticMapNotaFiscalTravada + expr: | + max by (namespace, situacao) ( + atm_nota_fiscal_pendente_mais_antiga_segundos{situacao=~"PENDENTE_EMISSAO|EMITINDO"} + ) > 6 * 3600 + for: 15m + labels: + severity: warning + annotations: + summary: "{{ $labels.namespace }}: nota fiscal parada em {{ $labels.situacao }} ha mais de 6h" + description: >- + A nota mais antiga em {{ $labels.situacao }} esta parada ha + {{ $value | humanizeDuration }}. EMITINDO = ja esta no provedor (Asaas) + esperando a prefeitura: quase sempre e a NFS-e nacional instavel — + confira o status da NFS-e nacional e o painel do Asaas (Notas fiscais) + antes de mexer; o provedor tenta de novo sozinho. PENDENTE_EMISSAO = a + varredura de hora em hora nao levou a nota: veja o CronJob + financeiro-notas (kubectl -n {{ $labels.namespace }} get jobs + -l job-name --sort-by=.metadata.creationTimestamp). NAO emita a nota + por fora sem cancelar a do provedor: sai em dobro. Procedimento: + docs/runbook-nota-fiscal-travada.md. + + - alert: AthleticMapNotaFiscalTravada + expr: | + max by (namespace, situacao) ( + atm_nota_fiscal_pendente_mais_antiga_segundos{situacao=~"PENDENTE_EMISSAO|EMITINDO"} + ) > 24 * 3600 + for: 15m + labels: + severity: critical + annotations: + summary: "{{ $labels.namespace }}: nota fiscal parada em {{ $labels.situacao }} ha mais de 24h" + description: >- + Um dia inteiro sem a nota sair ({{ $value | humanizeDuration }}). Se a + NFS-e nacional continua fora, avise a escolinha (a nota sai atrasada, + com a data do servico) e acompanhe; se voltou e a nota nao andou, use + "sincronizar" em cada nota e, se o provedor disser erro, "reabrir". + Emissao manual so seguindo o runbook, na ordem, para nao duplicar: + docs/runbook-nota-fiscal-travada.md. + + - alert: AthleticMapNotaFiscalEmErro + expr: max by (namespace) (atm_nota_fiscal_pendentes{situacao="ERRO"}) > 0 + for: 1h + labels: + severity: warning + annotations: + summary: "{{ $labels.namespace }}: {{ $value }} nota(s) fiscal(is) em ERRO" + description: >- + O provedor ou a prefeitura recusou, e nota em ERRO NAO e reenviada + sozinha. Veja o motivo de cada uma (GET /api/financeiro/notas?situacao=ERRO, + campo erroMensagem). Se foi a NFS-e nacional fora, espere ela voltar + e use POST /api/financeiro/notas/{id}/reabrir — a proxima varredura + manda de novo. Se foi dado errado, corrija antes de reabrir. + Procedimento: docs/runbook-nota-fiscal-travada.md. + + - alert: AthleticMapNotaFiscalSemDadosFiscais + expr: | + max by (namespace) ( + atm_nota_fiscal_pendente_mais_antiga_segundos{situacao="PENDENTE_DADOS"} + ) > 72 * 3600 + for: 1h + labels: + severity: warning + annotations: + summary: "{{ $labels.namespace }}: notas sem emitir por falta de dado fiscal ha mais de 3 dias" + description: >- + Nao e instabilidade: falta configuracao fiscal da escolinha (codigo de + servico municipal, aliquotas, emissao desligada) ou dado do tomador + (CPF/CNPJ, endereco). O motivo esta em erroMensagem ("faltam: ...") em + GET /api/financeiro/notas?situacao=PENDENTE_DADOS. Fale com a escolinha + ou o contador; depois de corrigir, "reabrir" cada nota. + + # Silencio nao e "nenhuma nota parada": se a conta nao roda, as metricas + # acima ficam no ultimo valor (ou em zero, se nunca mediram). Com a + # validade de 2 min, ~30 tentativas por hora por replica. + - alert: AthleticMapNotaFiscalSemMedicao + expr: max by (namespace) (increase(atm_nota_fiscal_medicao_falhou_total[30m])) > 5 + for: 30m + labels: + severity: warning + annotations: + summary: "{{ $labels.namespace }}: ha 1h nao se consegue contar as notas fiscais paradas" + description: >- + O alerta de nota travada esta cego neste tenant. Quase sempre e o banco + do financeiro fora ou sem conexao. Log: kubectl -n {{ $labels.namespace }} + logs deploy/runtime-operacao | grep 'nao consegui contar as pendentes' + (no escolinha-prod e no plataforma-prod o deploy e o servico-financeiro). diff --git a/tenants/acme/56-cronjob-resumo.yaml b/tenants/acme/56-cronjob-resumo.yaml new file mode 100644 index 0000000..2106a12 --- /dev/null +++ b/tenants/acme/56-cronjob-resumo.yaml @@ -0,0 +1,141 @@ +# ============================================================================= +# N16 — o resumo AGREGADO do tenant, entregue a plataforma uma vez por dia, para o +# comprador ver todas as escolinhas dele lado a lado no /minha-conta. +# +# AGUARDANDO GRAVACAO no molde (`moldes/consolidado/` do GitOps) e nos tenants que +# ja existem. Nada aqui foi aplicado. +# +# O desenho: o TENANT calcula (runtime-operacao, GET /api/resumo-do-tenant, so +# contadores e somas) e o TENANT envia (este CronJob). A plataforma nao entra no +# namespace nem no banco do tenant para isto. +# +# 1. token do client `operacao` no realm do tenant (o mesmo das tarefas do +# financeiro: Secret `operacao-credentials`); +# 2. GET http://runtime-operacao/api/resumo-do-tenant (lista de 1 ou 2 meses); +# 3. POST https://plataforma.athleticmap.com/api/public/resumo-do-tenant/acme +# com o cabecalho `x-atm-resumo-token` — do Secret `resumo-credentials`, que +# so vale para ESTE tenant (HMAC do nome dele com a chave da plataforma). +# +# A saida para a plataforma e pela internet (443 publico), que a deny-cross-tenant +# ja libera: nao precisa de NetworkPolicy nova. +# +# `optional: true` no resumo-credentials: tenant sem o Secret (chave da plataforma +# ainda nao configurada) roda o job, que termina OK dizendo "sem token" — nao gera +# KubeJobFailed nem gasta a cota de e-mail dos alertas. +# ============================================================================= +apiVersion: v1 +kind: ConfigMap +metadata: + name: resumo-cron + namespace: acme-prod +data: + enviar.sh: | + #!/bin/sh + set -eu + + : "${KEYCLOAK_URL:?falta KEYCLOAK_URL}" + : "${CLIENT_SECRET:?falta CLIENT_SECRET}" + : "${ISSUER_HOST:?falta ISSUER_HOST}" + : "${DESTINO:?falta DESTINO}" + + if [ -z "${RESUMO_TOKEN:-}" ]; then + echo "sem o Secret resumo-credentials neste tenant: nada a enviar." + exit 0 + fi + + i=0 + until curl -sS -o /dev/null -m 5 "${KEYCLOAK_URL}" 2>/dev/null; do + i=$((i+1)) + if [ "$i" -ge 24 ]; then + echo "ERRO: ${KEYCLOAK_URL} inalcancavel depois de 2 min." >&2 + exit 1 + fi + sleep 5 + done + + RESP="$(printf 'grant_type=client_credentials&client_id=operacao&client_secret=%s' \ + "${CLIENT_SECRET}" \ + | curl -sS --fail-with-body -X POST "${KEYCLOAK_URL}" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -H 'X-Forwarded-Proto: https' \ + -H 'X-Forwarded-Port: 443' \ + -H "X-Forwarded-Host: ${ISSUER_HOST}" -d @-)" + TOKEN="$(printf '%s' "${RESP}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')" + if [ -z "${TOKEN}" ]; then + echo "ERRO: nao consegui token pelo client 'operacao'." >&2 + exit 1 + fi + + # Os tokens vao por `-K -` (stdin), e nao na linha de comando: argumento de + # processo aparece em `ps` para quem estiver no no. + CODIGO="$(printf 'header = "Authorization: Bearer %s"\n' "${TOKEN}" \ + | curl -sS -K - -o /tmp/resumo.json -w '%{http_code}' http://runtime-operacao/api/resumo-do-tenant)" + if [ "${CODIGO}" != "200" ]; then + echo "ERRO: o runtime-operacao respondeu ${CODIGO} ao resumo." >&2 + exit 1 + fi + echo "resumo calculado: $(wc -c < /tmp/resumo.json) bytes (so numeros)" + + CODIGO="$(printf 'header = "x-atm-resumo-token: %s"\n' "${RESUMO_TOKEN}" \ + | curl -sS -K - -X POST "${DESTINO}" -H 'Content-Type: application/json' \ + --data-binary @/tmp/resumo.json -o /tmp/saida.json -w '%{http_code}')" + echo "plataforma: ${CODIGO}" + cat /tmp/saida.json; echo + case "${CODIGO}" in + 2*) ;; + *) echo "ERRO: a plataforma respondeu ${CODIGO}." >&2; exit 1 ;; + esac +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: resumo-para-a-plataforma + namespace: acme-prod +spec: + # Madrugada, depois da virada do dia em Sao Paulo e fora do horario das + # mensalidades (dia 1, 06h). + schedule: "30 4 * * *" + timeZone: America/Sao_Paulo + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 5 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + backoffLimit: 2 + template: + spec: + restartPolicy: OnFailure + containers: + - name: enviar + image: docker.io/curlimages/curl:8.9.1 + imagePullPolicy: Never + command: ["/bin/sh", "/script/enviar.sh"] + env: + - name: KEYCLOAK_URL + value: http://keycloak.plataforma-prod.svc:8080/realms/athleticmap-3/protocol/openid-connect/token + - name: ISSUER_HOST + value: auth.athleticmap.com + - name: DESTINO + value: https://plataforma.athleticmap.com/api/public/resumo-do-tenant/acme + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: + name: operacao-credentials + key: client-secret + - name: RESUMO_TOKEN + valueFrom: + secretKeyRef: + name: resumo-credentials + key: token + optional: true + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 200m, memory: 64Mi } + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: resumo-cron diff --git a/tenants/acme/56-cronjob-retencao.yaml b/tenants/acme/56-cronjob-retencao.yaml new file mode 100644 index 0000000..c55814a --- /dev/null +++ b/tenants/acme/56-cronjob-retencao.yaml @@ -0,0 +1,75 @@ +# ============================================================================= +# P6.3 — a ELIMINACAO automatica (decisao 3 do dono, 2026-09-28): todo dia, o +# runtime-core elimina quem completou 5 anos do fim, anonimiza o fiscal e grava +# o registro. Nada fica elegivel antes de 2031 — ate la a rodada termina com +# zero eliminados, e e isso que prova que ela esta viva. +# +# PENDENTE DE GRAVACAO NO MOLDE (moldes/consolidado/) E NOS 4 TENANTS ANTIGOS +# (tenants/{acme,demo,escolinha,piloto}/). Este arquivo mora aqui ate o dono +# gravar — o mesmo caminho do 55-cronjobs-financeiro.yaml. +# +# Por que CronJob, e nao @Scheduled no runtime: com mais de uma replica, um +# agendador no processo rodaria a eliminacao duas vezes (o mesmo motivo do +# financeiro). Duas chamadas simultaneas nao duplicam nada — a pessoa e travada +# antes de sair —, mas uma so e o desenho certo. +# +# DEPENDE do ConfigMap `financeiro-cron` (55-cronjobs-financeiro.yaml): usa o +# mesmo `disparar.sh`, o mesmo client `operacao` e o mesmo Secret +# `operacao-credentials`. O runtime-core so aceita esta rota com token do client +# `operacao` (SegurancaDoRuntime) — nenhum usuario humano, nem admin. +# +# Titular que falha -> o endpoint devolve 500 -> o Job falha -> KubeJobFailed. +# Silencio aqui seria descobrir em 2031 que a rotina nunca rodou. +# +# ANTES DE APLICAR: o runtime-core do tenant tem de estar numa imagem com o +# modulo servico-retencao (senao a rota da 404 e o Job falha todo dia). +# Para conferir um tenant sem eliminar nada: `?simular=true` (mesmo caminho, +# transacao desfeita, contagens reais). +# ============================================================================= +apiVersion: batch/v1 +kind: CronJob +metadata: + name: retencao-eliminacao + namespace: acme-prod +spec: + # 04:40, longe do backup (02:30) e das tarefas do financeiro (:15 e :30). + schedule: "40 4 * * *" + timeZone: America/Sao_Paulo + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 5 + jobTemplate: + spec: + activeDeadlineSeconds: 1800 + backoffLimit: 1 + template: + spec: + restartPolicy: OnFailure + containers: + - name: disparar + image: docker.io/curlimages/curl:8.9.1 + imagePullPolicy: Never + command: ["/bin/sh", "/script/disparar.sh"] + env: + - name: KEYCLOAK_URL + value: http://keycloak.plataforma-prod.svc:8080/realms/athleticmap-3/protocol/openid-connect/token + - name: ISSUER_HOST + value: auth.athleticmap.com + - name: ALVO + value: http://runtime-core/api/retencao/eliminacao?limite=200 + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: + name: operacao-credentials + key: client-secret + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 200m, memory: 64Mi } + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: financeiro-cron + defaultMode: 0555 diff --git a/tenants/demo/56-cronjob-resumo.yaml b/tenants/demo/56-cronjob-resumo.yaml new file mode 100644 index 0000000..3737f46 --- /dev/null +++ b/tenants/demo/56-cronjob-resumo.yaml @@ -0,0 +1,141 @@ +# ============================================================================= +# N16 — o resumo AGREGADO do tenant, entregue a plataforma uma vez por dia, para o +# comprador ver todas as escolinhas dele lado a lado no /minha-conta. +# +# AGUARDANDO GRAVACAO no molde (`moldes/consolidado/` do GitOps) e nos tenants que +# ja existem. Nada aqui foi aplicado. +# +# O desenho: o TENANT calcula (runtime-operacao, GET /api/resumo-do-tenant, so +# contadores e somas) e o TENANT envia (este CronJob). A plataforma nao entra no +# namespace nem no banco do tenant para isto. +# +# 1. token do client `operacao` no realm do tenant (o mesmo das tarefas do +# financeiro: Secret `operacao-credentials`); +# 2. GET http://runtime-operacao/api/resumo-do-tenant (lista de 1 ou 2 meses); +# 3. POST https://plataforma.athleticmap.com/api/public/resumo-do-tenant/demo +# com o cabecalho `x-atm-resumo-token` — do Secret `resumo-credentials`, que +# so vale para ESTE tenant (HMAC do nome dele com a chave da plataforma). +# +# A saida para a plataforma e pela internet (443 publico), que a deny-cross-tenant +# ja libera: nao precisa de NetworkPolicy nova. +# +# `optional: true` no resumo-credentials: tenant sem o Secret (chave da plataforma +# ainda nao configurada) roda o job, que termina OK dizendo "sem token" — nao gera +# KubeJobFailed nem gasta a cota de e-mail dos alertas. +# ============================================================================= +apiVersion: v1 +kind: ConfigMap +metadata: + name: resumo-cron + namespace: demo-prod +data: + enviar.sh: | + #!/bin/sh + set -eu + + : "${KEYCLOAK_URL:?falta KEYCLOAK_URL}" + : "${CLIENT_SECRET:?falta CLIENT_SECRET}" + : "${ISSUER_HOST:?falta ISSUER_HOST}" + : "${DESTINO:?falta DESTINO}" + + if [ -z "${RESUMO_TOKEN:-}" ]; then + echo "sem o Secret resumo-credentials neste tenant: nada a enviar." + exit 0 + fi + + i=0 + until curl -sS -o /dev/null -m 5 "${KEYCLOAK_URL}" 2>/dev/null; do + i=$((i+1)) + if [ "$i" -ge 24 ]; then + echo "ERRO: ${KEYCLOAK_URL} inalcancavel depois de 2 min." >&2 + exit 1 + fi + sleep 5 + done + + RESP="$(printf 'grant_type=client_credentials&client_id=operacao&client_secret=%s' \ + "${CLIENT_SECRET}" \ + | curl -sS --fail-with-body -X POST "${KEYCLOAK_URL}" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -H 'X-Forwarded-Proto: https' \ + -H 'X-Forwarded-Port: 443' \ + -H "X-Forwarded-Host: ${ISSUER_HOST}" -d @-)" + TOKEN="$(printf '%s' "${RESP}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')" + if [ -z "${TOKEN}" ]; then + echo "ERRO: nao consegui token pelo client 'operacao'." >&2 + exit 1 + fi + + # Os tokens vao por `-K -` (stdin), e nao na linha de comando: argumento de + # processo aparece em `ps` para quem estiver no no. + CODIGO="$(printf 'header = "Authorization: Bearer %s"\n' "${TOKEN}" \ + | curl -sS -K - -o /tmp/resumo.json -w '%{http_code}' http://runtime-operacao/api/resumo-do-tenant)" + if [ "${CODIGO}" != "200" ]; then + echo "ERRO: o runtime-operacao respondeu ${CODIGO} ao resumo." >&2 + exit 1 + fi + echo "resumo calculado: $(wc -c < /tmp/resumo.json) bytes (so numeros)" + + CODIGO="$(printf 'header = "x-atm-resumo-token: %s"\n' "${RESUMO_TOKEN}" \ + | curl -sS -K - -X POST "${DESTINO}" -H 'Content-Type: application/json' \ + --data-binary @/tmp/resumo.json -o /tmp/saida.json -w '%{http_code}')" + echo "plataforma: ${CODIGO}" + cat /tmp/saida.json; echo + case "${CODIGO}" in + 2*) ;; + *) echo "ERRO: a plataforma respondeu ${CODIGO}." >&2; exit 1 ;; + esac +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: resumo-para-a-plataforma + namespace: demo-prod +spec: + # Madrugada, depois da virada do dia em Sao Paulo e fora do horario das + # mensalidades (dia 1, 06h). + schedule: "30 4 * * *" + timeZone: America/Sao_Paulo + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 5 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + backoffLimit: 2 + template: + spec: + restartPolicy: OnFailure + containers: + - name: enviar + image: docker.io/curlimages/curl:8.9.1 + imagePullPolicy: Never + command: ["/bin/sh", "/script/enviar.sh"] + env: + - name: KEYCLOAK_URL + value: http://keycloak.plataforma-prod.svc:8080/realms/athleticmap-2/protocol/openid-connect/token + - name: ISSUER_HOST + value: auth.athleticmap.com + - name: DESTINO + value: https://plataforma.athleticmap.com/api/public/resumo-do-tenant/demo + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: + name: operacao-credentials + key: client-secret + - name: RESUMO_TOKEN + valueFrom: + secretKeyRef: + name: resumo-credentials + key: token + optional: true + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 200m, memory: 64Mi } + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: resumo-cron diff --git a/tenants/demo/56-cronjob-retencao.yaml b/tenants/demo/56-cronjob-retencao.yaml new file mode 100644 index 0000000..8425728 --- /dev/null +++ b/tenants/demo/56-cronjob-retencao.yaml @@ -0,0 +1,75 @@ +# ============================================================================= +# P6.3 — a ELIMINACAO automatica (decisao 3 do dono, 2026-09-28): todo dia, o +# runtime-core elimina quem completou 5 anos do fim, anonimiza o fiscal e grava +# o registro. Nada fica elegivel antes de 2031 — ate la a rodada termina com +# zero eliminados, e e isso que prova que ela esta viva. +# +# PENDENTE DE GRAVACAO NO MOLDE (moldes/consolidado/) E NOS 4 TENANTS ANTIGOS +# (tenants/{acme,demo,escolinha,piloto}/). Este arquivo mora aqui ate o dono +# gravar — o mesmo caminho do 55-cronjobs-financeiro.yaml. +# +# Por que CronJob, e nao @Scheduled no runtime: com mais de uma replica, um +# agendador no processo rodaria a eliminacao duas vezes (o mesmo motivo do +# financeiro). Duas chamadas simultaneas nao duplicam nada — a pessoa e travada +# antes de sair —, mas uma so e o desenho certo. +# +# DEPENDE do ConfigMap `financeiro-cron` (55-cronjobs-financeiro.yaml): usa o +# mesmo `disparar.sh`, o mesmo client `operacao` e o mesmo Secret +# `operacao-credentials`. O runtime-core so aceita esta rota com token do client +# `operacao` (SegurancaDoRuntime) — nenhum usuario humano, nem admin. +# +# Titular que falha -> o endpoint devolve 500 -> o Job falha -> KubeJobFailed. +# Silencio aqui seria descobrir em 2031 que a rotina nunca rodou. +# +# ANTES DE APLICAR: o runtime-core do tenant tem de estar numa imagem com o +# modulo servico-retencao (senao a rota da 404 e o Job falha todo dia). +# Para conferir um tenant sem eliminar nada: `?simular=true` (mesmo caminho, +# transacao desfeita, contagens reais). +# ============================================================================= +apiVersion: batch/v1 +kind: CronJob +metadata: + name: retencao-eliminacao + namespace: demo-prod +spec: + # 04:40, longe do backup (02:30) e das tarefas do financeiro (:15 e :30). + schedule: "40 4 * * *" + timeZone: America/Sao_Paulo + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 5 + jobTemplate: + spec: + activeDeadlineSeconds: 1800 + backoffLimit: 1 + template: + spec: + restartPolicy: OnFailure + containers: + - name: disparar + image: docker.io/curlimages/curl:8.9.1 + imagePullPolicy: Never + command: ["/bin/sh", "/script/disparar.sh"] + env: + - name: KEYCLOAK_URL + value: http://keycloak.plataforma-prod.svc:8080/realms/athleticmap-2/protocol/openid-connect/token + - name: ISSUER_HOST + value: auth.athleticmap.com + - name: ALVO + value: http://runtime-core/api/retencao/eliminacao?limite=200 + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: + name: operacao-credentials + key: client-secret + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 200m, memory: 64Mi } + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: financeiro-cron + defaultMode: 0555 diff --git a/tenants/escolinha/56-cronjob-resumo.yaml b/tenants/escolinha/56-cronjob-resumo.yaml new file mode 100644 index 0000000..1e1d11c --- /dev/null +++ b/tenants/escolinha/56-cronjob-resumo.yaml @@ -0,0 +1,141 @@ +# ============================================================================= +# N16 — o resumo AGREGADO do tenant, entregue a plataforma uma vez por dia, para o +# comprador ver todas as escolinhas dele lado a lado no /minha-conta. +# +# AGUARDANDO GRAVACAO no molde (`moldes/consolidado/` do GitOps) e nos tenants que +# ja existem. Nada aqui foi aplicado. +# +# O desenho: o TENANT calcula (runtime-operacao, GET /api/resumo-do-tenant, so +# contadores e somas) e o TENANT envia (este CronJob). A plataforma nao entra no +# namespace nem no banco do tenant para isto. +# +# 1. token do client `operacao` no realm do tenant (o mesmo das tarefas do +# financeiro: Secret `operacao-credentials`); +# 2. GET http://runtime-operacao/api/resumo-do-tenant (lista de 1 ou 2 meses); +# 3. POST https://plataforma.athleticmap.com/api/public/resumo-do-tenant/escolinha +# com o cabecalho `x-atm-resumo-token` — do Secret `resumo-credentials`, que +# so vale para ESTE tenant (HMAC do nome dele com a chave da plataforma). +# +# A saida para a plataforma e pela internet (443 publico), que a deny-cross-tenant +# ja libera: nao precisa de NetworkPolicy nova. +# +# `optional: true` no resumo-credentials: tenant sem o Secret (chave da plataforma +# ainda nao configurada) roda o job, que termina OK dizendo "sem token" — nao gera +# KubeJobFailed nem gasta a cota de e-mail dos alertas. +# ============================================================================= +apiVersion: v1 +kind: ConfigMap +metadata: + name: resumo-cron + namespace: escolinha-prod +data: + enviar.sh: | + #!/bin/sh + set -eu + + : "${KEYCLOAK_URL:?falta KEYCLOAK_URL}" + : "${CLIENT_SECRET:?falta CLIENT_SECRET}" + : "${ISSUER_HOST:?falta ISSUER_HOST}" + : "${DESTINO:?falta DESTINO}" + + if [ -z "${RESUMO_TOKEN:-}" ]; then + echo "sem o Secret resumo-credentials neste tenant: nada a enviar." + exit 0 + fi + + i=0 + until curl -sS -o /dev/null -m 5 "${KEYCLOAK_URL}" 2>/dev/null; do + i=$((i+1)) + if [ "$i" -ge 24 ]; then + echo "ERRO: ${KEYCLOAK_URL} inalcancavel depois de 2 min." >&2 + exit 1 + fi + sleep 5 + done + + RESP="$(printf 'grant_type=client_credentials&client_id=operacao&client_secret=%s' \ + "${CLIENT_SECRET}" \ + | curl -sS --fail-with-body -X POST "${KEYCLOAK_URL}" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -H 'X-Forwarded-Proto: https' \ + -H 'X-Forwarded-Port: 443' \ + -H "X-Forwarded-Host: ${ISSUER_HOST}" -d @-)" + TOKEN="$(printf '%s' "${RESP}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')" + if [ -z "${TOKEN}" ]; then + echo "ERRO: nao consegui token pelo client 'operacao'." >&2 + exit 1 + fi + + # Os tokens vao por `-K -` (stdin), e nao na linha de comando: argumento de + # processo aparece em `ps` para quem estiver no no. + CODIGO="$(printf 'header = "Authorization: Bearer %s"\n' "${TOKEN}" \ + | curl -sS -K - -o /tmp/resumo.json -w '%{http_code}' http://runtime-operacao/api/resumo-do-tenant)" + if [ "${CODIGO}" != "200" ]; then + echo "ERRO: o runtime-operacao respondeu ${CODIGO} ao resumo." >&2 + exit 1 + fi + echo "resumo calculado: $(wc -c < /tmp/resumo.json) bytes (so numeros)" + + CODIGO="$(printf 'header = "x-atm-resumo-token: %s"\n' "${RESUMO_TOKEN}" \ + | curl -sS -K - -X POST "${DESTINO}" -H 'Content-Type: application/json' \ + --data-binary @/tmp/resumo.json -o /tmp/saida.json -w '%{http_code}')" + echo "plataforma: ${CODIGO}" + cat /tmp/saida.json; echo + case "${CODIGO}" in + 2*) ;; + *) echo "ERRO: a plataforma respondeu ${CODIGO}." >&2; exit 1 ;; + esac +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: resumo-para-a-plataforma + namespace: escolinha-prod +spec: + # Madrugada, depois da virada do dia em Sao Paulo e fora do horario das + # mensalidades (dia 1, 06h). + schedule: "30 4 * * *" + timeZone: America/Sao_Paulo + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 5 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + backoffLimit: 2 + template: + spec: + restartPolicy: OnFailure + containers: + - name: enviar + image: docker.io/curlimages/curl:8.9.1 + imagePullPolicy: Never + command: ["/bin/sh", "/script/enviar.sh"] + env: + - name: KEYCLOAK_URL + value: http://keycloak.plataforma-prod.svc:8080/realms/athleticmap-4/protocol/openid-connect/token + - name: ISSUER_HOST + value: auth.athleticmap.com + - name: DESTINO + value: https://plataforma.athleticmap.com/api/public/resumo-do-tenant/escolinha + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: + name: operacao-credentials + key: client-secret + - name: RESUMO_TOKEN + valueFrom: + secretKeyRef: + name: resumo-credentials + key: token + optional: true + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 200m, memory: 64Mi } + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: resumo-cron diff --git a/tenants/escolinha/56-cronjob-retencao.yaml b/tenants/escolinha/56-cronjob-retencao.yaml new file mode 100644 index 0000000..f71b29c --- /dev/null +++ b/tenants/escolinha/56-cronjob-retencao.yaml @@ -0,0 +1,75 @@ +# ============================================================================= +# P6.3 — a ELIMINACAO automatica (decisao 3 do dono, 2026-09-28): todo dia, o +# runtime-core elimina quem completou 5 anos do fim, anonimiza o fiscal e grava +# o registro. Nada fica elegivel antes de 2031 — ate la a rodada termina com +# zero eliminados, e e isso que prova que ela esta viva. +# +# PENDENTE DE GRAVACAO NO MOLDE (moldes/consolidado/) E NOS 4 TENANTS ANTIGOS +# (tenants/{acme,demo,escolinha,piloto}/). Este arquivo mora aqui ate o dono +# gravar — o mesmo caminho do 55-cronjobs-financeiro.yaml. +# +# Por que CronJob, e nao @Scheduled no runtime: com mais de uma replica, um +# agendador no processo rodaria a eliminacao duas vezes (o mesmo motivo do +# financeiro). Duas chamadas simultaneas nao duplicam nada — a pessoa e travada +# antes de sair —, mas uma so e o desenho certo. +# +# DEPENDE do ConfigMap `financeiro-cron` (55-cronjobs-financeiro.yaml): usa o +# mesmo `disparar.sh`, o mesmo client `operacao` e o mesmo Secret +# `operacao-credentials`. O runtime-core so aceita esta rota com token do client +# `operacao` (SegurancaDoRuntime) — nenhum usuario humano, nem admin. +# +# Titular que falha -> o endpoint devolve 500 -> o Job falha -> KubeJobFailed. +# Silencio aqui seria descobrir em 2031 que a rotina nunca rodou. +# +# ANTES DE APLICAR: o runtime-core do tenant tem de estar numa imagem com o +# modulo servico-retencao (senao a rota da 404 e o Job falha todo dia). +# Para conferir um tenant sem eliminar nada: `?simular=true` (mesmo caminho, +# transacao desfeita, contagens reais). +# ============================================================================= +apiVersion: batch/v1 +kind: CronJob +metadata: + name: retencao-eliminacao + namespace: escolinha-prod +spec: + # 04:40, longe do backup (02:30) e das tarefas do financeiro (:15 e :30). + schedule: "40 4 * * *" + timeZone: America/Sao_Paulo + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 5 + jobTemplate: + spec: + activeDeadlineSeconds: 1800 + backoffLimit: 1 + template: + spec: + restartPolicy: OnFailure + containers: + - name: disparar + image: docker.io/curlimages/curl:8.9.1 + imagePullPolicy: Never + command: ["/bin/sh", "/script/disparar.sh"] + env: + - name: KEYCLOAK_URL + value: http://keycloak.plataforma-prod.svc:8080/realms/athleticmap-4/protocol/openid-connect/token + - name: ISSUER_HOST + value: auth.athleticmap.com + - name: ALVO + value: http://runtime-core/api/retencao/eliminacao?limite=200 + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: + name: operacao-credentials + key: client-secret + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 200m, memory: 64Mi } + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: financeiro-cron + defaultMode: 0555 diff --git a/tenants/piloto/56-cronjob-resumo.yaml b/tenants/piloto/56-cronjob-resumo.yaml new file mode 100644 index 0000000..ff2fe04 --- /dev/null +++ b/tenants/piloto/56-cronjob-resumo.yaml @@ -0,0 +1,141 @@ +# ============================================================================= +# N16 — o resumo AGREGADO do tenant, entregue a plataforma uma vez por dia, para o +# comprador ver todas as escolinhas dele lado a lado no /minha-conta. +# +# AGUARDANDO GRAVACAO no molde (`moldes/consolidado/` do GitOps) e nos tenants que +# ja existem. Nada aqui foi aplicado. +# +# O desenho: o TENANT calcula (runtime-operacao, GET /api/resumo-do-tenant, so +# contadores e somas) e o TENANT envia (este CronJob). A plataforma nao entra no +# namespace nem no banco do tenant para isto. +# +# 1. token do client `operacao` no realm do tenant (o mesmo das tarefas do +# financeiro: Secret `operacao-credentials`); +# 2. GET http://runtime-operacao/api/resumo-do-tenant (lista de 1 ou 2 meses); +# 3. POST https://plataforma.athleticmap.com/api/public/resumo-do-tenant/piloto +# com o cabecalho `x-atm-resumo-token` — do Secret `resumo-credentials`, que +# so vale para ESTE tenant (HMAC do nome dele com a chave da plataforma). +# +# A saida para a plataforma e pela internet (443 publico), que a deny-cross-tenant +# ja libera: nao precisa de NetworkPolicy nova. +# +# `optional: true` no resumo-credentials: tenant sem o Secret (chave da plataforma +# ainda nao configurada) roda o job, que termina OK dizendo "sem token" — nao gera +# KubeJobFailed nem gasta a cota de e-mail dos alertas. +# ============================================================================= +apiVersion: v1 +kind: ConfigMap +metadata: + name: resumo-cron + namespace: piloto-prod +data: + enviar.sh: | + #!/bin/sh + set -eu + + : "${KEYCLOAK_URL:?falta KEYCLOAK_URL}" + : "${CLIENT_SECRET:?falta CLIENT_SECRET}" + : "${ISSUER_HOST:?falta ISSUER_HOST}" + : "${DESTINO:?falta DESTINO}" + + if [ -z "${RESUMO_TOKEN:-}" ]; then + echo "sem o Secret resumo-credentials neste tenant: nada a enviar." + exit 0 + fi + + i=0 + until curl -sS -o /dev/null -m 5 "${KEYCLOAK_URL}" 2>/dev/null; do + i=$((i+1)) + if [ "$i" -ge 24 ]; then + echo "ERRO: ${KEYCLOAK_URL} inalcancavel depois de 2 min." >&2 + exit 1 + fi + sleep 5 + done + + RESP="$(printf 'grant_type=client_credentials&client_id=operacao&client_secret=%s' \ + "${CLIENT_SECRET}" \ + | curl -sS --fail-with-body -X POST "${KEYCLOAK_URL}" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -H 'X-Forwarded-Proto: https' \ + -H 'X-Forwarded-Port: 443' \ + -H "X-Forwarded-Host: ${ISSUER_HOST}" -d @-)" + TOKEN="$(printf '%s' "${RESP}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')" + if [ -z "${TOKEN}" ]; then + echo "ERRO: nao consegui token pelo client 'operacao'." >&2 + exit 1 + fi + + # Os tokens vao por `-K -` (stdin), e nao na linha de comando: argumento de + # processo aparece em `ps` para quem estiver no no. + CODIGO="$(printf 'header = "Authorization: Bearer %s"\n' "${TOKEN}" \ + | curl -sS -K - -o /tmp/resumo.json -w '%{http_code}' http://runtime-operacao/api/resumo-do-tenant)" + if [ "${CODIGO}" != "200" ]; then + echo "ERRO: o runtime-operacao respondeu ${CODIGO} ao resumo." >&2 + exit 1 + fi + echo "resumo calculado: $(wc -c < /tmp/resumo.json) bytes (so numeros)" + + CODIGO="$(printf 'header = "x-atm-resumo-token: %s"\n' "${RESUMO_TOKEN}" \ + | curl -sS -K - -X POST "${DESTINO}" -H 'Content-Type: application/json' \ + --data-binary @/tmp/resumo.json -o /tmp/saida.json -w '%{http_code}')" + echo "plataforma: ${CODIGO}" + cat /tmp/saida.json; echo + case "${CODIGO}" in + 2*) ;; + *) echo "ERRO: a plataforma respondeu ${CODIGO}." >&2; exit 1 ;; + esac +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: resumo-para-a-plataforma + namespace: piloto-prod +spec: + # Madrugada, depois da virada do dia em Sao Paulo e fora do horario das + # mensalidades (dia 1, 06h). + schedule: "30 4 * * *" + timeZone: America/Sao_Paulo + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 5 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + backoffLimit: 2 + template: + spec: + restartPolicy: OnFailure + containers: + - name: enviar + image: docker.io/curlimages/curl:8.9.1 + imagePullPolicy: Never + command: ["/bin/sh", "/script/enviar.sh"] + env: + - name: KEYCLOAK_URL + value: http://keycloak.plataforma-prod.svc:8080/realms/athleticmap-1/protocol/openid-connect/token + - name: ISSUER_HOST + value: auth.athleticmap.com + - name: DESTINO + value: https://plataforma.athleticmap.com/api/public/resumo-do-tenant/piloto + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: + name: operacao-credentials + key: client-secret + - name: RESUMO_TOKEN + valueFrom: + secretKeyRef: + name: resumo-credentials + key: token + optional: true + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 200m, memory: 64Mi } + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: resumo-cron diff --git a/tenants/piloto/56-cronjob-retencao.yaml b/tenants/piloto/56-cronjob-retencao.yaml new file mode 100644 index 0000000..7e7434c --- /dev/null +++ b/tenants/piloto/56-cronjob-retencao.yaml @@ -0,0 +1,75 @@ +# ============================================================================= +# P6.3 — a ELIMINACAO automatica (decisao 3 do dono, 2026-09-28): todo dia, o +# runtime-core elimina quem completou 5 anos do fim, anonimiza o fiscal e grava +# o registro. Nada fica elegivel antes de 2031 — ate la a rodada termina com +# zero eliminados, e e isso que prova que ela esta viva. +# +# PENDENTE DE GRAVACAO NO MOLDE (moldes/consolidado/) E NOS 4 TENANTS ANTIGOS +# (tenants/{acme,demo,escolinha,piloto}/). Este arquivo mora aqui ate o dono +# gravar — o mesmo caminho do 55-cronjobs-financeiro.yaml. +# +# Por que CronJob, e nao @Scheduled no runtime: com mais de uma replica, um +# agendador no processo rodaria a eliminacao duas vezes (o mesmo motivo do +# financeiro). Duas chamadas simultaneas nao duplicam nada — a pessoa e travada +# antes de sair —, mas uma so e o desenho certo. +# +# DEPENDE do ConfigMap `financeiro-cron` (55-cronjobs-financeiro.yaml): usa o +# mesmo `disparar.sh`, o mesmo client `operacao` e o mesmo Secret +# `operacao-credentials`. O runtime-core so aceita esta rota com token do client +# `operacao` (SegurancaDoRuntime) — nenhum usuario humano, nem admin. +# +# Titular que falha -> o endpoint devolve 500 -> o Job falha -> KubeJobFailed. +# Silencio aqui seria descobrir em 2031 que a rotina nunca rodou. +# +# ANTES DE APLICAR: o runtime-core do tenant tem de estar numa imagem com o +# modulo servico-retencao (senao a rota da 404 e o Job falha todo dia). +# Para conferir um tenant sem eliminar nada: `?simular=true` (mesmo caminho, +# transacao desfeita, contagens reais). +# ============================================================================= +apiVersion: batch/v1 +kind: CronJob +metadata: + name: retencao-eliminacao + namespace: piloto-prod +spec: + # 04:40, longe do backup (02:30) e das tarefas do financeiro (:15 e :30). + schedule: "40 4 * * *" + timeZone: America/Sao_Paulo + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 5 + jobTemplate: + spec: + activeDeadlineSeconds: 1800 + backoffLimit: 1 + template: + spec: + restartPolicy: OnFailure + containers: + - name: disparar + image: docker.io/curlimages/curl:8.9.1 + imagePullPolicy: Never + command: ["/bin/sh", "/script/disparar.sh"] + env: + - name: KEYCLOAK_URL + value: http://keycloak.plataforma-prod.svc:8080/realms/athleticmap-1/protocol/openid-connect/token + - name: ISSUER_HOST + value: auth.athleticmap.com + - name: ALVO + value: http://runtime-core/api/retencao/eliminacao?limite=200 + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: + name: operacao-credentials + key: client-secret + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 200m, memory: 64Mi } + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: financeiro-cron + defaultMode: 0555 diff --git a/tenants/plataforma/57-cronjob-expurgo-trilha.yaml b/tenants/plataforma/57-cronjob-expurgo-trilha.yaml new file mode 100644 index 0000000..e28fa9d --- /dev/null +++ b/tenants/plataforma/57-cronjob-expurgo-trilha.yaml @@ -0,0 +1,103 @@ +# ============================================================================= +# P6.4 — o EXPURGO das trilhas de auditoria com mais de 5 anos, pelo papel de +# banco proprio `expurgo_trilha` (membro de `atm_expurgo_trilha`). Nunca pela +# aplicacao: o usuario `r_` e dono dos schemas, e os triggers (cadastro +# V020, financeiro V011) recusam o dono e o superusuario. +# +# PENDENTE DE GRAVACAO em tenants/plataforma/. Mora aqui ate o dono gravar. +# +# UM job para todos os tenants, no namespace da plataforma: e aqui que o +# Postgres compartilhado mora, e a credencial do papel de expurgo nunca entra +# num namespace de tenant (onde roda codigo de aplicacao). Descobre os bancos na +# hora, como o 51-backup-compartilhado: `athleticmap_%`. +# +# ── ANTES DE APLICAR ───────────────────────────────────────────────────────── +# +# 1. O DBA cria os papeis e da os GRANTs: scripts/banco/papel_expurgo_trilha.sql +# (fase `papeis` uma vez; fase `grants` em cada database). Senha com +# `\password expurgo_trilha`, na hora — nunca em arquivo. +# 2. O Secret `pg-expurgo-trilha` (chave `password`) e SELADO a partir dessa +# senha (kubeseal) e gravado como 58-sealed-pg-expurgo-trilha.yaml. +# 3. Tenant cujo banco ainda nao tem a V020/V011 aparece como "sem a funcao": +# nao e erro (nao ha o que expurgar com o trigger antigo), e a fase `grants` +# tem de rodar de novo depois que ele migrar. +# 4. Tenant antigo com Postgres PROPRIO (tenants//10-postgres.yaml), se +# algum ainda existir, NAO e coberto por este job: precisa do papel e de um +# job apontando para ele. +# +# Semanal: o prazo e de anos, e a primeira linha so vence em 2031. +# ============================================================================= +apiVersion: v1 +kind: ConfigMap +metadata: + name: expurgo-trilha + namespace: plataforma-prod +data: + expurgar.sh: | + #!/bin/sh + set -u + : "${PGPASSWORD:?falta PGPASSWORD (Secret pg-expurgo-trilha)}" + BANCOS="$(psql -d postgres -Atc "select datname from pg_database where datname like 'athleticmap\_%' order by 1")" \ + || { echo "ERRO: nao consegui listar os bancos." >&2; exit 1; } + [ -n "${BANCOS}" ] || { echo "ERRO: nenhum banco athleticmap_* encontrado." >&2; exit 1; } + FALHOU=0 + for DB in ${BANCOS}; do + for SCHEMA in cadastro financeiro; do + TEM="$(psql -d "${DB}" -Atc "select to_regprocedure('${SCHEMA}.expurgar_trilhas_vencidas()') is not null" 2>&1)" + if [ "${TEM}" != "t" ]; then + echo "${DB} ${SCHEMA}: sem a funcao de expurgo (migracao pendente ou sem acesso): ${TEM}" + continue + fi + if SAIDA="$(psql -v ON_ERROR_STOP=1 -d "${DB}" -Atc "select tabela || '=' || apagados from ${SCHEMA}.expurgar_trilhas_vencidas()" 2>&1)"; then + echo "${DB} ${SCHEMA}: $(echo "${SAIDA}" | tr '\n' ' ')" + else + echo "ERRO ${DB} ${SCHEMA}: ${SAIDA}" >&2 + FALHOU=1 + fi + done + done + exit ${FALHOU} +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: expurgo-trilha + namespace: plataforma-prod +spec: + # Domingo, 05:10 — depois do backup (02:30) e da eliminacao dos tenants (04:40). + schedule: "10 5 * * 0" + timeZone: America/Sao_Paulo + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 1800 + backoffLimit: 1 + template: + spec: + restartPolicy: OnFailure + containers: + - name: expurgar + image: postgres:16 + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "/script/expurgar.sh"] + env: + # Direto no Postgres, como o backup: o PgBouncer so conhece os + # papeis da aplicacao. + - { name: PGHOST, value: "pg-compartilhado" } + - { name: PGUSER, value: "expurgo_trilha" } + - name: PGPASSWORD + valueFrom: + secretKeyRef: { name: pg-expurgo-trilha, key: password } + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 200m, memory: 128Mi } + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: expurgo-trilha + defaultMode: 0555