🌍

Ambientes e Deployment

Ambientes e Deployment

CI backend: binário MongoDB dos testes de integração

O azure-pipelines.yml define MONGOMS_DISTRO=ubuntu-22.04 nos testes de
integração da API e do Worker. A deteção automática da distribuição do agente
gerava um pedido para ubuntu2304 que devolvia HTTP 403; o binário oficial LTS ubuntu2204 está
disponível para a mesma versão MongoDB 8.0.34 e arquitetura ARM64. A cache
inclui distribuição, SO, arquitetura e versão. A versão continua fixada no
package.json e verificada pelo buildInfo() do servidor.

Verificar ambos os passos de integração numa nova execução da pipeline e, em
cache fria Linux ARM64, o download de
mongodb-linux-aarch64-ubuntu2204-8.0.34.tgz. Custo Azure adicional esperado:
nenhum; apenas uma nova entrada de cache do binário (~100 MB de download), sem
alterações aos recursos ou à configuração dos ambientes de runtime.

Versão do Node.js

O backend usa Node 24 (LTS, fim de vida a 2028-04-30). Antes corria Node 20 no
CI e nas imagens (fim de vida a 2026-04-30) e sem versão fixada localmente, o
que deixava passar localmente testes que falhavam no CI.

Onde Como
.nvmrc Fonte de verdade (só o major)
azure-pipelines.yml, azure-pipelines-admin-build.yml UseNode@1 lê o .nvmrc
azure-pipelines-{api,worker,admin,ml,website}-release.yml, -appsettings-audit.yml, -acr-cleanup.yml, -monitoring-health-alerts.yml, -phase3-operations-audit.yml UseNode@1 logo a seguir ao checkout: self de cada job que corre node
package.json engines (avisa, não bloqueia o npm ci); @types/node 24.x
Dockerfile* FROM node:24-alpine; worker com digest (ver Worker — Chromium)
azure-pipelines-website-release.yml Container node:24-bookworm (SWA CLI)
.husky/pre-push Avisa se o major local diferir do .nvmrc
nx.json node -v entra na chave de cache do Nx
scripts/node-version-alignment.test.js Falha no Scripts_Tests se engines, Dockerfile* ou o container do website diferirem do .nvmrc

Para mudar de versão, altere em conjunto .nvmrc, engines, Dockerfile* e o
container do website (o teste acima falha se não coincidirem) e suba @types/node
para o mesmo major (o teste não verifica este). Os filtros de caminhos da Backend
CI incluem Dockerfile*, **/Dockerfile* e a pipeline do website para que o
teste corra quando só estes mudam.

O @types/node 20 declarava Array.prototype.at como global; o 24 já não. Por isso
o lib do tsconfig.base.json passou de es2020 a es2022, que é o que o Node 24
suporta em runtime e o que o código já usava. Não muda o código gerado.

As pipelines de release e de auditoria corriam antes os scripts no Node do agente:
v18.20.8 no nodeserver, visto nos logs dos builds 7113, 7117, 7118 e 7127
(2026-10-29/30). O agente vps não anuncia Node nenhum. Agora instalam a versão
do .nvmrc, por isso o resultado já não depende de qual agente apanha o job.

Localmente: nvm install && nvm use. O Node 24 traz OpenSSL 3.5 com nível de
segurança 2, que rejeita chaves RSA abaixo de 2048 bits; num teste de handshake
(2026-10-30) a 3400 hosts das rádios em staging, só radio.pt.org.br falhou
apenas nesse nível.

Verificar: o passo Setup Node.js (.nvmrc) da Backend CI e de cada release,
node -v dentro dos containers depois do primeiro deploy em dev, a memória do
worker e os erros ERR_SSL_* do scraping. No primeiro release em dev, confirmar
também Ensure Atlas indexes: se o mongosh do nodeserver veio de npm -g, o
shebang passa a usar o Node 24. Reverter: reverter o commit desta alteração (as
imagens node:20-* continuam a existir, mas já não recebem atualizações).

Custo Azure adicional esperado: nenhum. Não há recursos, réplicas nem SKUs novos.
O UseNode@1 descarrega o Node de nodejs.org para a cache _tool dos agentes
self-hosted (só o disco do agente e alguns segundos em cada job). Validar em dev
que o consumo de memória não aproxima os limites das Container Apps.

App Flutter: canais de teste das lojas

A preparação dos pipelines no repositório audissey_app acrescenta seleção
manual de Google Play interno (distributionMethod=play) e TestFlight
(distributionMethod=testflight) para staging. As tags automáticas mantêm
Firebase durante a configuração. Os binários conservam o backend de staging e
identificadores próprios: Android com.audissey.audisseyapp.staging, iOS
com.audissey.audisseyApp.staging.

As referências de credenciais ficam no Variable Group audissey-staging e as
chaves/perfis em Secure Files. O TestFlight usa um perfil App Store Connect
separado do Ad Hoc. O primeiro envio Apple apenas carrega a build; Google Play
prepara uma release interna em rascunho por predefinição. A ativação depende de
registos nas lojas, permissões e validação de instalação por um tester.
Guia: audissey_app/docs/development/STORE_TESTING_SETUP.md no repositório da app.

Para lançar apenas staging, o pipeline Release Tags da app aceita
stagingOnly=true numa execução manual de dev: guarda o bump numa branch
própria e cria só a tag staging, sem atualizar dev/main nem criar tag prod.
O comportamento automático existente mantém-se. Os grupos das lojas são
privados, com membros reutilizados do grupo Firebase de staging após autorização.

Os pipelines verificam identidade, versão e número de build antes do envio;
Android compara ainda o certificado do AAB com o keystore configurado e iOS
valida a assinatura e o canal do perfil incorporado.
Verificar que a build usa o identificador de staging, aparece no canal correto
e permite login/reprodução após instalação pela loja. Não há alterações à API,
ao desired-state de App Settings nem a recursos Azure. Custo fixo esperado:
nenhum; custo incremental baixo, não quantificado, por tempo de CI, retenção de
artefactos e uploads. Validar capacidade/retenção do agente macOS.

Arquitetura de Ambientes

A Audissey utiliza três ambientes isolados:

Programações manuais

As programações guardadas usam as collections MongoDB saved_programmings e
daily_programming_selections. A seleção diária expira automaticamente após
sete dias; os itens do dia são materializados na collection schedules e usam
o executor já existente. Não existem novas variáveis de ambiente, serviços
Azure ou recursos always-on associados a esta funcionalidade. Antes do rollout,
aplicar os índices declarados em deploy/config/indexes/indexes.json e executar
a migration 20260830_012_migrate-saved-programmings.

Ordem dos favoritos

"Todos" e cada coleção de favoritos têm ordens independentes (sortOrder e
collectionSortOrder, ver docs/api/FAVORITES_API.md). Antes do rollout,
aplicar o índice { userId: 1, collectionId: 1, collectionSortOrder: -1, _id: -1 }
de deploy/config/indexes/indexes.json (a API também o tenta criar no arranque)
e, logo após o deploy, executar a migration
20261024_013_split-favorite-sort-orders (marcada manual: a execução
automática não a corre; usar only). A versão da app que ordena as
coleções por collectionSortOrder deve sair antes ou com este deploy. Sem
variáveis de ambiente nem recursos Azure novos.

Nota (Azure): recursos estão separados por ambiente + shared.

  • API (dev/staging): Azure Container Apps — audissey-api-dev no CAE audissey-cae-api-dev (audissey-dev-rg, UK South, externo/consumption), audissey-api-staging no CAE audissey-cae-api-staging (audissey-staging-rg, UK South, externo/consumption). Migrado de App Service em Jul 2025. Nota: CAEs em UK South (sem VNet) porque westeurope tinha AKS capacity issues e northeurope atingiu quota de 2 CAEs.
  • API (prod): o desired-state e azure-pipelines-api-release.yml apontam para a Container App esperada audissey-api em audissey-prod-rg. A recolha read-only de 25/09/2026 devolveu resource-not-found; configuração versionada não prova existência nem prontidão remota.
  • Recursos partilhados (ACR/Redis): audissey-shared-rg
  • App Insights (Mar 2026): Reactivado com retenção reduzida a 30 dias (de 90). Daily caps configurados em todos os Log Analytics workspaces (dev=0.5GB, staging=1.0GB, production=2.0GB, workers-ne=1.0GB, shared=1.0GB) para prevenir custos de ingestão descontrolados.
  • Worker (dev/staging) corre em Azure Container Apps no CAE externo audissey-cae-northeu-ext em audissey-staging-rg (North Europe, VNet-injected na subnet audissey-vnet-workers-ne/containerapps-ext, domínio happypond-5a13597d.northeurope.azurecontainerapps.io). O CAE interno audissey-cae-northeu (domínio salmonglacier-eb702166) já não existe. Cada Container App vive no RG do seu ambiente (audissey-dev-rg, audissey-staging-rg); audissey-prod-rg não existe (verificado 21/10/2026).
  • ML Service corre em Azure Container Apps no CAE público audissey-cae-public em audissey-shared-rg (North Europe, externo). Migrado do CAE interno audissey-cae-northeu em Mar 2025 para resolver NXDOMAIN — API Container Apps (UK South) não conseguiam resolver DNS privado do CAE interno (North Europe). Protegido com X-Internal-Key API key middleware. Cada Container App vive no RG do seu ambiente.
  • Admin Portal corre em Azure Container Apps no CAE público audissey-cae-public em audissey-shared-rg (North Europe, externo — internalLoadBalancerEnabled: false). Domínio: grayisland-44f77667.northeurope.azurecontainerapps.io. Migrado de App Service em Jul 2025.
  • Worker Container Apps estão VNet-injected via audissey-vnet-workers-ne/containerapps-ext (CAE audissey-cae-northeu-ext, internal: false, ingress público). Container Apps acedem a Redis/KV via Private Endpoints. Private DNS zones estão consolidadas no audissey-shared-rg (redis, vaultcore) com VNet links para dev, staging e workers-ne. Database migrada para MongoDB Atlas (Jul 2026) — acesso via connection string directa (MONGO_URI).
  • ML Service Container Apps foram migradas para o CAE público (audissey-cae-public) e não usam VNet. Segurança garantida via X-Internal-Key header middleware (FastAPI). Chave partilhada entre API/Worker (callers) e ML (server) via Variable Group ML_INTERNAL_API_KEY e Container App secret mlinternalkey.
  • API Container Apps (dev/staging) estão em UK South (consumption, sem VNet). Database migrada para MongoDB Atlas (Jul 2026). Redis publicNetworkAccess=Enabled (Jul 2025) permite conectividade directa.

Data migrations (automáticas e admin portal)

As data migrations (apps/api/src/app/admin/migrations/, registadas por ordem
em MIGRATIONS em apps/api/src/app/admin/services/migration-runner.service.ts)
correm de duas formas:

Uma execução de cada vez, entre réplicas e entre as duas formas: um lease no
documento data-migrations da collection _migration_lock (10 min, renovado
a cada minuto enquanto corre, libertado no fim, também em erro; uma réplica que
morre perde-o ao expirar). Com o lock ocupado, os botões respondem 409 e a
execução automática espera (ver acima). Uma execução que perde o lock (outra
réplica ficou com ele, ou as renovações falharam quase até ao fim da lease)
pára antes da migration seguinte e regista esse erro. O mesmo documento guarda
o resumo da última execução automática (lastAutoRun). Só procura por _id:
sem índices novos.

Marcar uma migration como manual: campo manual com o motivo (aparece no
admin portal), quando apaga dados, depende de um lançamento da app ou de uma
alteração de configuração, ou precisa de uma janela fora de horas. Uma
migration automática nunca pode depender de uma manual anterior (a execução
automática salta a manual e continua). Marcadas a 25/10/2026:
20250615_003 (dá expires_at às grelhas antigas: ficam logo expiradas,
entram no rescrape e o GC pode apagá-las com os programas; 391 em staging),
20260408_009, 20260804_011, 20260830_012, 20261023_013,
20261023_014, 20261024_013 (spec migration-runner.service.spec.ts fixa a
lista).

Ligar a execução automática num ambiente:

  1. Criar DATA_MIGRATIONS_AUTO_RUN nos três Variable Groups (audissey-dev,
    audissey-staging, audissey-prod) antes do primeiro deploy com esta
    alteração: o scripts/ensure-containerapp-env.js falha o deploy se uma
    variável do desired-state (deploy/config/appsettings/api/*.json) não
    existir no grupo (ou estiver vazia). O audit diário
    (azure-pipelines-appsettings-audit.yml, corre a partir de main) também
    falha nos jobs de dev e staging quando isto chegar a main sem a
    variável. Começar a false.
  2. Correr à mão, uma vez, as pendentes acumuladas desse ambiente (ver o estado
    no admin portal; usar only para separar as manuais).
  3. Pôr a variável a true e fazer deploy da API (o deploy aplica-a à
    Container App). Na Container App, a variável deve aparecer com o valor
    escolhido; nos logs da API, Automatic data migrations run in 60s e,
    depois, o resumo Automatic data migrations: N applied, ….
  4. Produção só depois de comprovado em staging.

Custo Azure: sem recursos novos (corre na Container App da API) e um documento
em _migration_lock; nenhum impacto esperado.

Variable Groups (source of truth)

Princípio fundamental: os ADO Variable Groups são o único source of truth para valores variáveis por ambiente. Os desired-state JSONs (deploy/config/appsettings/) contêm apenas placeholders ${VAR_NAME} — nunca URLs, chaves, ou valores hardcoded. scripts/ensure-containerapp-env.js resolve os placeholders e reconcilia as Container Apps; ensure-appsettings.js permanece apenas para App Services legados.

Cada ambiente tem um Variable Group no Azure DevOps:

VG Name VG ID Pipeline env
audissey-dev 2 dev
audissey-staging 3 staging
audissey-prod 4 prod

Criar/editar em: Project Settings → Pipelines → Library

Variáveis por Variable Group

A tabela abaixo lista todas as variáveis necessárias. Variáveis marcadas com 🔒 são secret e precisam de env: mapping explícito no pipeline YAML; as restantes são auto-disponíveis como env vars nos bash tasks.

Variable Consumidor dev staging prod Secret?
ENVIRONMENT Worker dev staging production
AZURE_KEY_VAULT_ENABLED API false false true
AZURE_KEY_VAULT_URL API (unused — CA) (unused — CA) https://audissey-kv-production.vault.azure.net/
NODE_ENV API development staging production
LOG_LEVEL API debug info info
CORS_ORIGINS API https://audissey-admin-dev.grayisland-44f77667.northeurope.azurecontainerapps.io https://audissey-admin-staging.grayisland-44f77667.northeurope.azurecontainerapps.io https://audissey-admin.grayisland-44f77667.northeurope.azurecontainerapps.io
ALERT_FROM_EMAIL API/Worker alerts@audissey.io alerts@audissey.io alerts@audissey.io
ALERT_RECIPIENTS API/Worker env-specific env-specific env-specific
PASSWORDLESS_ANDROID_PACKAGE API com.audissey.audisseyapp/dev com.audissey.audisseyapp com.audissey.audisseyapp
ML_SERVICE_URL API env-specific env-specific env-specific
ML_INTERNAL_API_KEY API/Worker/ML env-specific env-specific env-specific 🔒
WORKER_URL API env-specific env-specific env-specific
WORKER_API_URL API env-specific env-specific env-specific
API_URL API/Worker env-specific env-specific env-specific
MONGODB_DB_NAME API/ML release audissey-dev audissey-staging audissey
BULL_QUEUE_PREFIX API/Worker dev staging prod
COSMOS_DB_NAME API deprecated — deleted (migrated to Atlas Jul 2026, use MONGODB_DB_NAME) deprecated deprecated
AZURE_SUBSCRIPTION_ID API/Worker 7141477e-... same same
ML_TRAINING_JOB_NAME Worker/ML release env-specific env-specific env-specific
ML_TRAINING_JOB_RESOURCE_GROUP Worker/ML release env-specific env-specific env-specific
ML_CONTAINERAPPS_ENVIRONMENT ML release env-specific env-specific env-specific
WORKER_CONTAINER_APP_NAME ML release audissey-worker-dev audissey-worker-staging audissey-worker
ML_TRAINING_JOB_CPU ML release começar em 1 começar em 1 validar por ensaio
ML_TRAINING_JOB_MEMORY ML release começar em 2Gi começar em 2Gi validar por ensaio
ML_TRAINING_JOB_REPLICA_TIMEOUT ML release 3600 3600 validar por duração medida
ML_TRAINING_JOB_REPLICA_RETRY_LIMIT ML release 1 1 1
AZURE_BILLING_ACCOUNT_ID API 3c332990-... same same
API_SCHEDULER_ENABLED API true true true
DATA_MIGRATIONS_AUTO_RUN API false (→ true depois de correr à mão as pendentes acumuladas) false (→ true depois de comprovado em dev) false (→ true depois de staging)
SCHEDULER_ENABLED API false false true
ATLAS_SEARCH_ENABLED API true true true
API_BASE_URL Admin https://audissey-api-dev.<CAE_DOMAIN>/api https://audissey-api-staging.<CAE_DOMAIN>/api FQDN da Container App prod esperada
LLM_PROVIDER ML openai openai openai
LLM_KEY API/ML env-specific env-specific env-specific 🔒
LOG_ANALYTICS_WORKSPACE_ID API env-specific env-specific env-specific
WORKER_API_KEY API/Worker env-specific env-specific env-specific 🔒
SENDER_API_TOKEN API/Worker env-specific env-specific env-specific 🔒
MONGO_URI API/ML env-specific env-specific env-specific 🔒
JWT_SECRET API env-specific env-specific env-specific 🔒
REDIS_CONNECTION_STRING API/Worker env-specific env-specific env-specific 🔒
PODCAST_INDEX_API_KEY API env-specific env-specific env-specific 🔒
PODCAST_INDEX_API_SECRET API env-specific env-specific env-specific 🔒
LISTEN_NOTES_API_KEY API env-specific (optional — Phase 0 validation; PRO plan for batch lookup) env-specific env-specific 🔒
BRAVE_SEARCH_API_KEY Worker env-specific (optional — see Phase 5) env-specific env-specific 🔒
BRAVE_SEARCH_MONTHLY_BUDGET Worker 1800 (default) 1800 1800
GOOGLE_OAUTH_CLIENT_IDS API env-specific env-specific env-specific 🔒
GOOGLE_SERVER_CLIENT_ID API env-specific env-specific env-specific 🔒
FACEBOOK_APP_ID API env-specific env-specific env-specific 🔒
FACEBOOK_APP_SECRET API env-specific env-specific env-specific 🔒

Convenção de nomes: quando serviços diferentes precisam de valores distintos para a mesma setting (ex.: WEBSITES_PORT), usamos prefixo por serviço (ADMIN_WEBSITES_PORT=80, ML_WEBSITES_PORT=8000). O desired-state JSON de cada serviço mapeia "WEBSITES_PORT": "${ML_WEBSITES_PORT}" etc.

Adicionar nova variável: 1) Adicionar ao VG de cada ambiente. 2) Adicionar placeholder ${VAR_NAME} ao JSON correspondente. 3) Se a variável for secret, adicionar env: mapping no pipeline YAML. 4) Commit e deploy.

Key Vault bootstrap: dev/staging usam AZURE_KEY_VAULT_ENABLED=false; prod mantém o valor definido no respetivo VG. Nos três ambientes os segredos geridos pela Container App vêm dos Variable Groups via ensure-containerapp-env.js, e AZURE_KEY_VAULT_URL continua obrigatório como bootstrap versionado. O JSON mapeia nomes de VG para os nomes runtime esperados sem guardar valores. O recurso prod continua por comprovar remotamente.

Variáveis Secretas (Key Vault)

Criar Key Vaults separados:

Nota: o antigo vault legacy de produção foi descontinuado/apagado. O único vault suportado para produção é audissey-kv-production.

Cada Key Vault contém:

mongo-uri: <MongoDB Atlas connection string>
redis-key: <redis-key-específica>
jwt-secret: <jwt-secret-específico>
openai-api-key: <api-key-compartilhada-ou-separada>

# Social Auth
google-oauth-client-ids: <csv de OAuth client ids (Web) aceites como audience>
google-server-client-id: <Google Web client ID (para server-side token verification)>
facebook-app-id: <facebook app id>
facebook-app-secret: <facebook app secret>

Linkar Key Vault aos Variable Groups:

  1. No Variable Group, clicar + Link secrets from an Azure key vault
  2. Selecionar subscription e Key Vault
  3. Autorizar pipelines específicos (não "all pipelines")

Environments e Approvals

1. Criar Environments no Azure DevOps

Project Settings → Pipelines → Environments

Criar três environments:

2. Configurar Approvals para Prod

No environment prod:

  1. Click ... → Approvals and checks
  2. Add check → Approvals
  3. Adicionar approvers (e.g., tech leads, product owners)
  4. Configurar:
    • Approvers: Lista de emails
    • Timeout: 30 days
    • Allow approvers to approve their own runs: ❌ Desativar
    • Minimum number of approvers: 1

Infraestrutura por Ambiente

Recursos Azure Necessários

Cada ambiente precisa:

Container Apps

Atlas URI guardrail (Sep 2026): API release uses MONGO_URI from the environment Variable Group for both ensure-containerapp-env.js and Atlas index reconciliation. It no longer replaces that value with live Container App drift or reads Key Vault data-plane secrets from CI. Before the first release/repair after this change, verify that each VG contains the current Atlas URI; a stale VG must be corrected before rollout. Existing secretRef mappings are preserved. Atlas Search index reconciliation allows up to five minutes for initial/cold index creation; the release job remains bounded to ten minutes in dev/staging.

Contrato atual: azure-pipelines-api-release.yml contém dev, staging e prod e usa az containerapp create/update; isto expressa intenção de deploy, não existência. Bootstrap usa scripts/ensure-containerapp-env.js e deploy/config/appsettings/api/{dev,staging,prod}.json em todos os ambientes.

API (Container Apps) — Health probes

The API release pipeline runs scripts/ensure-api-probes.sh --env <env> on every deploy. The script exports the current Container App YAML, merges health probes, and applies via az containerapp update --yaml (idempotent, preserves existing env vars and image).

Probe Type Path Port Interval Timeout Failures InitialDelay
Startup HTTP /api/health/alive 3000 10s 3s 30 —
Liveness HTTP /api/health/alive 3000 30s 5s 3 10s
Readiness HTTP /api/health/alive 3000 10s 3s 3 5s

Manual run:

./scripts/ensure-api-probes.sh --env dev           # apply to dev
./scripts/ensure-api-probes.sh --env staging        # apply to staging
./scripts/ensure-api-probes.sh --env dev --dry-run   # preview changes

API (Container Apps) — Signal handling and graceful shutdown

App Service Plans (histórico removido)

All App Service Plans deleted (Jul 2025). All services now run on Azure Container Apps. Legacy plans (audissey-plan-dev-ws, audissey-plan-staging, audissey-plan-prod-ws) and their webapps (audissey-api-dev, audissey-api-staging, audissey-admin-dev) have been fully decommissioned and removed.

History: API, Admin, Worker, and ML all migrated from App Service to Azure Container Apps between Mar-Jul 2025. Admin FQDNs: audissey-admin-dev.grayisland-44f77667.northeurope.azurecontainerapps.io and audissey-admin-staging.grayisland-44f77667.northeurope.azurecontainerapps.io.

Private Endpoints (current layout)

Staging and prod environments have PEs for Key Vault and Redis:

Atlas migration (Jul 2026): Database migrated from CosmosDB (accessed via Private Endpoints) to MongoDB Atlas (accessed via public endpoint + IP allowlist). Cosmos PEs will be deleted as part of Phase 8 (CosmosDB decommission).

Security (Jun 2025): Both audissey-redis-we and audissey-cosmos previously had publicNetworkAccess: Disabled. All access was via Private Endpoints.
Networking fix (Jul 2025): API Container Apps (UK South, sem VNet) não acedem a PEs. publicNetworkAccess foi habilitado em Redis. Cosmos DB ipRules inclui 0.0.0.0 (Azure datacenter access) e o IP de desenvolvimento. CosmosDB decommissioned Jul 2026 — see Atlas migration note above.
Current state (Jul 2026): Redis: publicNetworkAccess=Enabled. CosmosDB: decommissioned (migrated to MongoDB Atlas). All services connect to Atlas via public endpoint + IP allowlist. VNet peering is a future security task for Redis only.
Redis connection sharing (Jul 2025): audissey-redis-we is Standard C0 (256 max clients, v6.0). Worker and API share a single ioredis instance per process (lazyConnect: true, maxRetriesPerRequest: null). Queues reuse the instance; Workers call duplicate() for their blocking sockets. The historical estimate of ~57 total connections predates the current scaler topology; it is not the current observed baseline.
Connection audit (07/10/2026): a bounded read-only trace confirmed 144 persistent KEDA Redis polling clients: 36 queues × 2 list states × 2 deployed environments. Dev keeps its 72 polling clients even with zero Worker replicas. The measured baseline was 192 connections, including 48 application/other clients. See the incident audit. Redis remains C0; no scaling topology was changed by this audit.
Legacy PEs cleaned (Jul 2025): pe-workers-kv-dev and pe-workers-kv-prod have been deleted from audissey-staging-rg.

Worker (Container Apps) — DNS interno

O Worker corre num Container Apps Environment interno (publicNetworkAccess=Disabled). Nestas condições:

Worker — KEDA autoscaling (scale-to-zero)

Dev/staging use one KEDA metrics-api scaler, verified on 07/10/2026.
Desired-state defines the same aggregate rule for production; its rollout was
not performed or verified in this incident. The previous direct Redis topology
monitored the exact
<BULL_QUEUE_PREFIX>:<queue>:wait and
<BULL_QUEUE_PREFIX>:<queue>:active lists. The wait rule wakes a scaled-to-zero
Worker; the active rule prevents scale-down while a job is running. After a
5-minute cooldown with both lists empty, the Worker scales back to zero.

Previously each queue generated two Redis scalers, each maintaining a Redis client
to poll its list even when no jobs or Worker replicas existed. The 36-queue
configuration therefore used 72 Redis connections per deployed environment,
separate from application connections. CLIENT LIST.cmd is the last command,
not evidence of a currently executing command or job. Reproduce attribution with
node scripts/audit-redis-connections.js; add --trace-seconds 35 to correlate
allowlisted queue polls with client IDs. The trace discards all other command
arguments in memory and never emits keys, credentials or job payloads beyond the
allowlisted queue names. It requires Azure Redis key-read permissions and network
access; it closes all temporary diagnostic connections. This diagnostic adds no
fixed infrastructure cost. Tests: node --test scripts/audit-redis-connections.test.js.

Config Dev Staging Prod
minReplicas 0 0 0
maxReplicas 2 2 3
pollingInterval 30s 30s 30s
cooldownPeriod 300s 300s 300s

Prefix contract:

The API Docker builder copies deploy/config/worker/keda-scaling.json before
compilation; webpack bundles the shared configuration into the API. The file is
also an explicit API build input and an API pipeline path trigger, so changing
the queue allowlist invalidates the Nx cache and builds a new image. Build 6573
failed because this file was absent from the builder, despite being available
locally. Verify with npx nx build api --prod --skip-nx-cache and the Docker
build pipeline. This packaging fix adds no Azure resources or fixed cost.

How it works (aggregated scaling, 07/10/2026):

Release ordering and migration: deploy the API endpoint first, then release
Worker. Before Worker secret/env revisions, scripts/ensure-worker-keda.sh
verifies HTTPS/authentication, prefix and all 36 queues. It waits up to 10
minutes for a concurrently deploying API (404/503 or transient network failure).
Rejected credentials, redirects, invalid schema or wrong prefix fail immediately;
unavailable metrics block before changes.
The script replaces all direct Redis rules with one API rule only after a
measured headroom check. During that conversion, the new revision is capped at
one replica and keeps the existing image/probes. It then waits for the normal
headroom check before restoring the desired max (2 dev/staging, 3 prod).
If the second check fails, the aggregate rule remains capped at one; rerunning
resumes reconciliation. New consumer probes ship atomically with the Worker
image through --image, avoiding probes against endpoints absent in the old
image. All rule metadata/auth and replica limits are checked for idempotence.

Redis connection headroom: the gate uses the real maximum of the last
15-minute connectedclients samples and the Redis resource's maxclients.
It reserves new replicas × (36 blocking clients + 3 shared/event clients) plus
10% safety margin. An API scaler adds zero direct Redis connections; old
scaler/replica clients remain included in the observed peak. For C0 (256), the
conversion reserve is 65 (one replica); normal dev/staging reserve is 104 and
prod reserve is 143. Existing 190 plus conversion reserve 65 is safe; 194 plus
65 is blocked. The gate can wait up to 20 minutes per phase (polling every 30s)
for the rolling peak to fall; it never subtracts projected savings or ignores
missing metrics/authorization failures. Serialise releases because these reads
cannot atomically reserve shared Redis capacity. The gate needs Reader for
Redis metadata/Azure Monitor metrics and never reads Redis/Key Vault secrets.

Worker release jobs allow 90 minutes: reconciliation plus image deployment can
include three bounded 20-minute headroom waits and two 10-minute API readiness
waits. The former 10-minute job limit cancelled release 6579 while its
preflight was still waiting; a cancelled job does not prove a metric/read failure.
This increases the possible occupation of the existing CI agent during waits,
with no new Azure resource, replica limit or fixed infrastructure cost.

One-time idle bootstrap (dev, 07/10/2026): the first conversion was blocked
by the old 15-minute peak. The operator verified a single dev revision with
zero replicas, authenticated queue metric zero (all configured wait/active
queues empty), and the API URL/prefix from the dev Variable Group. The idle
revision was deactivated reversibly; all 72 traced dev scaler client IDs
disappeared and current non-diagnostic clients fell from 190 to 118. Only then
was the desired aggregate scale rendered by worker-keda-config.py and applied,
preserving the existing image, env/secret references and probes. Actual 118 plus
the full two-replica reserve 104 was below 256; no projected savings were used.
Revision 0000110 retained min/max 0/2 and one metrics-api rule. A subsequent
trace found no dev Redis pollers; the total was 158 while one dev Worker replica
had started (36 blocking clients plus shared/event connections). Normal image
release gates still use the 15-minute peak, including this observed startup.
Do not use this bootstrap on a revision with running replicas or nonempty queue
metrics; migrate such environments through the normal guarded release instead.
See the incident follow-up.

Verified rollout (dev/staging, 07/10/2026): staging API release 6581
deployed image 6575, then the standard guarded Worker reconciler replaced its
72 Redis rules. Revision 0000079 retained Worker image 6523, one metrics-api
rule and min/max 0/2. After revision retirement, a full 35-second trace at
21:55 UTC found zero direct Redis scaler clients and 48 total clients, down
from the pre-migration baseline of 191. Those 48 included 36 staging blocking
consumers, four subscribers and eight other application clients. Totals vary
with replica activity; this is a measured snapshot, not a new fixed budget.
The normal full 15-minute headroom gate passed with peak 123 plus reserve 104
below 256; an earlier transient peak of 233 correctly blocked until it aged out.
Staging ICY/recognition completions advanced to 21:56 UTC with both queues empty.
Worker dev release 6585 resumes image 6559 using the corrected job limit;
at 21:58 UTC it was waiting for a newly reported migration peak of 165 to age
out (165 + 104 > 256), despite the lower current count.
At 22:08 UTC the unchanged gate passed with peak 97 plus reserve 104 below 256.
The dev release subsequently hit Azure InternalServerError in secret update,
leaving the app Succeeded with its prior image; the same stage was retried.
The unchanged secret-update task succeeded on the retry at 22:11 UTC.
Release 6585 succeeded at 22:13 UTC: dev revision 0000111, image 6559,
Healthy/Running, one API rule, min/max 0/2 and all three consumer probes.
Its image-deployment gate passed with peak 51 plus reserve 104 below 256.
At 22:14 UTC clients were 87 while the new dev replica was running; all 144
original direct poller IDs remained absent and staging completions kept advancing.
At 22:15 UTC the inventory was 86 (72 blocking consumers, five subscribers,
nine other clients), and post-release headroom passed with peak 90 plus 104.
No queue/history deletion, manual socket kill or Redis upgrade was required.

Operational tradeoff/cost: the API must remain reachable and able to read
Redis for scaling. Monitor HTTP failures/KEDA metric errors and fresh queue
completions. Active jobs protect scale-to-zero, but this change does not repair
orphaned jobs or processors stuck on I/O; use selective queue maintenance and
consumer health monitoring. Removing 72 direct scaler clients per environment
adds no fixed resource cost and retains scale-to-zero/normal replica ceilings.
It adds a small periodic API/Redis read workload and HTTP log ingestion; validate
compute/Log Analytics volume after rollout. The one-replica migration cap is
temporary and can reduce throughput until safe limits are restored. No SKU
upgrade or always-on replica is introduced.

References: KEDA Metrics API,
Azure custom scaling.

Each environment's release service principal requires Reader at the exact
Microsoft.Cache/Redis/audissey-redis-we resource scope in audissey-shared-rg.
Contributor on the environment's own RG does not cover this shared resource.
The three release principals are recorded in the same shared allowlist under
resourceScopedRoles in deploy/config/rbac/{dev,staging,prod}.json. Bootstrap
these grants with an identity allowed to assign roles; the drift audit remains
audit-only and the release principals cannot grant themselves access. Reader
permits resource/metric reads and does not permit listing Redis access keys.
The preflight logs only the failing operation, an allowlisted Azure error code
and CLI exit status; it never prints raw CLI output or credentials. Each Azure
read has a 60-second timeout. Authorization, malformed responses, missing metrics
and insufficient capacity continue to block the release. Verify a successful
read separately from safe=true: fixing Reader access does not add connection
capacity. These grants and diagnostics add no expected fixed infrastructure cost
or new resources; any SKU increase still needs separate cost approval.

Bootstrap administrativo de dev (não executar com a identidade do release):
o principal audissey-sp-dev tem object ID
95fabf15-2d2f-4ae7-8d4f-0a41e5fee3cf e app ID
8dee7604-f193-4756-a2e7-7b58de2f6418. Confirmar a identidade da service
connection antes de aplicar; usar o object ID para a atribuição. A allowlist e
o auditor não criam esta permissão automaticamente.

headroom_redis_id="$(az redis show -g audissey-shared-rg \
  -n audissey-redis-we --query id -o tsv)"
az role assignment create \
  --assignee-object-id 95fabf15-2d2f-4ae7-8d4f-0a41e5fee3cf \
  --assignee-principal-type ServicePrincipal --role Reader \
  --scope "$headroom_redis_id" -o none
az role assignment list --scope "$headroom_redis_id" --include-inherited \
  --query "[?principalId=='95fabf15-2d2f-4ae7-8d4f-0a41e5fee3cf'].{role:roleDefinitionName,scope:scope}" \
  -o table

Repetir o preflight com a service connection para comprovar leitura de recurso
e métricas, depois da propagação RBAC. Uma execução local como Owner não
comprova o acesso da service connection. O release dev 6564 de 07/10/2026
falhou em redis.show com AuthorizationFailed; uma leitura independente
mostrou também connected_peak=199, revision_reserve=176, maxclients=256.
Corrigir Reader resolve a consulta, mas não transforma essa margem em segura.
O bootstrap Reader de dev foi aplicado em 07/10/2026 às 19:25 UTC, no âmbito
exato do recurso Redis; a atribuição está descrita no
relatório do incidente.
Falta comprovar o acesso efetivo numa nova sessão do pipeline e resolver a
margem de ligações antes de publicar o Worker.
A releitura confirmou a atribuição; o gate com a conta administrativa continuou
a devolver safe=false, com pico 195 e reserva 176 para limite 256.

Em 07/10/2026 às 19:40 UTC, a inspeção confirmou Reader apenas para dev neste
recurso partilhado. A identidade de release de staging (audissey-sp-staging,
object ID e1952205-5824-422e-b08f-3746c6398203) também necessita deste Reader:
o Contributor de audissey-staging-rg não cobre audissey-shared-rg. A autorização
e atribuição anteriores abrangeram só dev; staging continuou sem atribuição e não
foi alterado nesta inspeção. A fotografia Redis mostrou 190 clientes, dos quais
144 eram pollers KEDA (72 por ambiente). Ver o
inventário e avaliação de redução.
Fechar sockets ou limpar histórico de jobs não substitui a alteração da
topologia de scaling nem comprova margem segura no preflight.

Após autorização explícita, Reader foi atribuído a audissey-sp-staging em
07/10/2026 às 20:25 UTC, exclusivamente em audissey-redis-we, e confirmado por
uma nova listagem. A atribuição 8b97eb11-4318-4874-94c9-d88b2f1b387c corresponde
ao papel já previsto em deploy/config/rbac/staging.json; o auditor continua
audit-only. Não há custo fixo adicional nem acesso às chaves Redis. O próximo
preflight com azure-staging-connection, após propagação RBAC, deve comprovar
a leitura efetiva de configuração/métricas e a margem de ligações.

Desired-state: deploy/config/worker/keda-scaling.json

Manual run:

API_BASE_URL='https://<dev-api>/api' \
  ./scripts/ensure-worker-keda.sh --env dev --dry-run
./scripts/ensure-worker-keda.sh --env dev

Verify the deployed contract (does not expose the Redis password):

az containerapp show -g audissey-staging-rg -n audissey-worker-staging \
  --query "properties.template.containers[0].env[?name=='BULL_QUEUE_PREFIX']" -o table

az containerapp show -g audissey-staging-rg -n audissey-worker-staging \
  --query "properties.template.scale.rules[].custom.metadata.listName" -o tsv \
  | sort

node --test scripts/ensure-worker-keda.test.js

For staging, the output must contain staging:icy-collector:wait and
staging:icy-collector:active; bull:icy-collector:* is drift.

ICY collector cleanup, recovery, and observability

Staging rollout checks:

  1. Deploy API, Worker, and Admin; confirm the prefix and KEDA lists with the
    commands above.
  2. Scale the Worker to zero, enqueue one ICY job through the existing admin job
    trigger/scheduler, and confirm KEDA starts a replica and consumes the job.
  3. Start authenticated Antena 1 playback. Within one configured collector cycle,
    confirm REST and SSE return current RTP metadata with a fresh timestamp rather
    than source: lastKnown.
  4. Observe multiple cycles: no permanently waiting/active ICY jobs, stale
    active-radios cardinality converges toward positive listener counters, and
    recovery/timeout totals remain visible in Admin and Container Apps logs.

No additional Azure resource or replica is introduced. Expected infrastructure
cost impact is low/no additional fixed cost; fewer stale probes should reduce
Worker/Redis usage. Validate Application Insights/Log Analytics ingestion after
staging rollout because the new structured lifecycle events add a small amount
of telemetry.

Manutenção seletiva de filas (API / Worker / Admin)

Publicar os três serviços para disponibilizar a pré-visualização no Monitor
de Filas. As limpezas amplas anteriores devolvem 410; recuperação conserva
ID/dados de ativos antigos sem lock e limpeza de histórico respeita 48 horas
para concluídos e sete dias para falhados. Trabalho em espera/agendado e locks
de processadores são preservados. O plano de execução, no Redis existente,
expira em cinco minutos e só pode ser usado uma vez. As transições revalidam
estado, idade e lock atomicamente. Ver operação, limites de deduplicação e
testes em QUEUE_MAINTENANCE.md.

Não requer variáveis/VGs, recursos ou ligações Redis adicionais. Impacto
esperado baixo, sem custo fixo novo; validar memória do histórico retido,
latência Redis e ingestão de logs. Esta mudança não reduz os clientes KEDA
nem remove o bloqueio de release por margem insuficiente no Redis.

Worker (Container Apps) — Health probes

All Worker Container Apps must have explicit startup, liveness, and readiness probes configured. Without explicit probes, Container Apps applies a default TCP probe with a 1-second timeout, which fails during NestJS boot (~3-6 seconds) causing noisy warnings in system logs.

Probe Type Path Port Interval Timeout Failures
Startup HTTP /api/health 3001 3s 3s 30
Liveness HTTP /api/health/alive 3001 30s 5s 3
Readiness HTTP /api/health 3001 10s 3s 3

The desired probes are in deploy/config/worker/keda-scaling.json; the KEDA
reconciler verifies them as well as scale rules and applies them before the
release changes the image. ICY/recognition share one publisher/cache client and
one subscriber per process. Shutdown hooks close consumers before the shared
BullMQ client, and close the now-playing Redis client. See
NOW_PLAYING_CONTINUITY.md for operation
and fault verification. No new environment variables are required.

Endpoints (from apps/worker/src/app/health/health.controller.ts):

Cost impact: low, with no additional Azure resources/replicas or fixed cost.
Health checks reuse existing connections and sharing removes two now-playing
connections per process. Validate structured-log ingestion after rollout. A Redis
SKU increase may be necessary if the headroom gate fails; its recurring cost must
be approved and priced separately before rollout.

CRITICAL: When updating Container App containers with az containerapp update --yaml, the entire container spec is replaced. Always include all environment variables alongside probes in the YAML, otherwise env vars will be stripped and the app will fail (Redis ECONNREFUSED, missing secrets).

O release reconcilia as probes; números de revisão não são desired-state e não
ficam registados aqui. Confirmar a revisão ativa e as probes por az containerapp show no ambiente selecionado. Em particular, a configuração prod versionada
não prova que a app exista.

Worker (Container Apps) — DNS interno (internal networking)

O antigo percurso API App Service + VNet já não é válido: as APIs dev/staging
correm em CAEs sem VNet e não devem depender deste DNS privado. Para um caller
que esteja efetivamente ligado à VNet do Worker, usar o hostname sem .internal
e garantir que existe um A record na Private DNS zone do CAE:

Isto permite apenas ao caller ligado à VNet resolver o endpoint via Azure DNS;
validar a topologia real antes de configurar um health check entre serviços.

ML Service (Container Apps) — HTTP autoscaling (scale-to-zero)

The ML Service runs on Azure Container Apps with HTTP request-based autoscaling.
When no HTTP requests arrive for the cooldown period (5 minutes), the ML Service scales to zero replicas. When traffic arrives, KEDA scales up from 0 → 1+ replicas based on concurrent requests per replica.

Config Dev Staging Prod
minReplicas 0 0 0
maxReplicas 2 2 2
concurrentRequests 10 10 10
cooldownPeriod 300s 300s 300s
cpu 1.0 1.0 2.0
memory 2Gi 2Gi 4Gi

Container App names:

How it works:

Secrets (managed via az containerapp secret set in pipeline):

Enforcement: The ML release pipeline runs scripts/ensure-ml-http-scaler.sh --env <env> on every deploy to prevent drift.

Desired-state: deploy/config/ml/http-scaling.json

Setup (one-time):

# Create ML Container App (requires secrets as env vars)
MONGO_URI=... APPLICATIONINSIGHTS_CONNECTION_STRING=... LLM_KEY=... \
  ./scripts/setup-ml-containerapp.sh --env dev

Manual scaler run:

./scripts/ensure-ml-http-scaler.sh --env dev       # apply to dev
./scripts/ensure-ml-http-scaler.sh --env dev --dry-run  # preview changes

Networking: ML Container Apps run on the public CAE (audissey-cae-public in audissey-shared-rg). FQDNs are publicly resolvable (*.grayisland-44f77667.northeurope.azurecontainerapps.io). No VNet/Private DNS required.

Security — X-Internal-Key middleware: ML endpoints (except /health and /docs) require an X-Internal-Key header matching ML_INTERNAL_API_KEY. The middleware is implemented in apps/ml-service/src/main.py (InternalKeyMiddleware). All API and Worker callers inject the header automatically from process.env.ML_INTERNAL_API_KEY. The key is stored as Container App secret mlinternalkey on all ML, API, and Worker CAs.

Why public CAE: The internal CAE (audissey-cae-northeu) uses vnetInternal: true with Private DNS zones linked to North Europe VNets. API Container Apps in UK South have no VNet peering to North Europe, causing getaddrinfo ENOTFOUND when calling ML. Moving ML to the public CAE resolves DNS while the API key middleware prevents unauthorized access.

ML Container App FQDNs (after migration to public CAE):

App RG FQDN
audissey-ml-dev audissey-dev-rg audissey-ml-dev.grayisland-44f77667.northeurope.azurecontainerapps.io
audissey-ml-staging audissey-staging-rg audissey-ml-staging.grayisland-44f77667.northeurope.azurecontainerapps.io
audissey-ml-prod audissey-prod-rg (not yet migrated — still on internal CAE)

Migration history: ML Service migrated from App Service to Container Apps (internal CAE) in 2025. Then migrated from internal CAE (audissey-cae-northeu) to public CAE (audissey-cae-public) in Mar 2025 to fix DNS resolution. Old health alerts (ml-*-healthcheck) replaced by ml-*-restart alerts.

Admin Portal (Container Apps) — Public CAE

The Admin Portal (nginx + Angular SPA) runs on Azure Container Apps in a dedicated public (external) CAE — separate from the internal Worker/ML CAE.

CAE: audissey-cae-public in audissey-shared-rg, North Europe, Consumption workload profile, internalLoadBalancerEnabled: false.

Container App names and FQDNs:

App RG FQDN
audissey-admin-dev audissey-dev-rg audissey-admin-dev.grayisland-44f77667.northeurope.azurecontainerapps.io
audissey-admin-staging audissey-staging-rg audissey-admin-staging.grayisland-44f77667.northeurope.azurecontainerapps.io
audissey-admin audissey-prod-rg audissey-admin.grayisland-44f77667.northeurope.azurecontainerapps.io

Scale settings:

Config Dev Staging Prod
minReplicas 0 0 1
maxReplicas 2 2 3
cpu 0.25 0.25 0.25
memory 0.5Gi 0.5Gi 0.5Gi

Runtime: nginx container, port 80. docker/admin-entrypoint.sh substitutes $API_BASE_URL into the nginx config at container startup.

ACR auth: System-assigned managed identity + AcrPull role on audissey.azurecr.io. Assigned idempotently by the release pipeline.

Key env var: API_BASE_URL — reconciliada pelo desired-state em cada deploy. O valor vem do ADO Variable Group e aponta para a API Container App do mesmo ambiente.

Why a public CAE for Admin + ML: The internal CAE (audissey-cae-northeu) has internalLoadBalancerEnabled: true (private IP 10.252.1.150). DNS does not resolve publicly — apps are only reachable within the Azure VNet. The Admin Portal must be accessible from the public internet, and the ML Service must be reachable from API Container Apps in UK South (no VNet peering). Both use the shared public CAE.

CORS: The API's CORS_ORIGINS setting must include the admin portal FQDN. Update the audissey-dev/staging/prod ADO Variable Group when FQDNs change.

Migration: Migrated from App Service (App Service Plans audissey-plan-dev-ws/audissey-plan-staging) in Jul 2025. Old webapps audissey-admin-dev and audissey-admin-staging deleted after migration.

Rede da API atual

As APIs dev/staging usam CAEs externos em UK South sem VNet. O antigo contrato de
VNet integration de App Service foi removido com os Web Apps; não deve ser usado
como procedimento operacional. A conectividade com Worker/Redis/Atlas deve ser
validada a partir das Container Apps e dos endpoints descritos nas secções acima.

Bases de Dados

Container Registry

Scripts de Setup

Ver scripts/setup-environment.sh para criar infraestrutura completa de um ambiente.

Pipelines de Deployment

Estrutura de Pipelines

Os pipelines de release atuais são azure-pipelines-api-release.yml,
azure-pipelines-worker-release.yml, azure-pipelines-ml-release.yml e
azure-pipelines-admin-release.yml. Cada ficheiro é a fonte de verdade para os
stages e approvals do serviço; não inferir que um recurso existe apenas porque o
stage correspondente está versionado.

Ordem API/Worker no now-playing: a API só envia recognitionEligible: true
com o heartbeat radio-recognition:heartbeat que o Worker grava em cada
execução do reconhecimento. Ao introduzir esse heartbeat, fazer deploy do
Worker antes (ou com) a API; ver
NOW_PLAYING_CONTINUITY.md.

Schemas de jobs no arranque SWC

Os campos jobType e status de JobExecutionLog, na API e no Worker,
declaram type: String explicitamente. O SWC usado no bundle emite o objeto
do enum importado em design:type; inferir esse tipo causa
Invalid schema configuration: sync-radios ... SYNC_RADIOS antes do arranque.
Os valores permitidos, índices e TTL de 7 dias não mudam.

Verificar com node --test scripts/job-execution-schemas.test.js; o teste
também corre na suite Jest da API, em-processo (sem subprocesso nem timeout de
relógio), a partir da mesma lógica em scripts/lib/swc-job-execution-schemas.js.
O teste usa metadata SWC, não apenas o compilador dos testes TypeScript. Depois do deploy, confirmar revisão Healthy,
imagem correta e tráfego antes do smoke autenticado de A tua Audissey.
Não são necessários novos recursos, variáveis ou migrations.

Atlas index release gate

Os releases API e Worker executam scripts/ensure-atlas-indexes.js --mode ensure com MONGO_URI secreto antes de continuar o deploy. O agente self-hosted
tem de disponibilizar um mongosh executável no PATH; ausência ou falha ao
iniciar o shell bloqueia o release em vez de simular sucesso. Depois de
createIndex/reconciliação, o mesmo processo relê getIndexes() e verifica
chaves, nome e opções geridas (unique, sparse, TTL, partial filter e opções de
texto). Índice ausente ou divergente também bloqueia o release. --mode audit
permanece offline/read-only: valida e lista o desired-state sem ligar ao Atlas.
Nos índices text, a verificação normaliza a representação MongoDB _fts/_ftsx
e compara os campos/pesos; nos índices compostos conserva a ordem das chaves.
O processo de reconciliação e verificação tem um deadline de dez minutos;
o deployment API staging dispõe de vinte minutos para incluir o restante
deploy. O antigo limite de dois minutos interrompia a aplicação sequencial
dos 249 índices antes da verificação final. Timeout continua a bloquear o
release; inspecionar os index builds e a carga Atlas antes de repetir. Confirmar
no log Summary que todos os índices foram verificados e há zero falhas.

O arranque da API cria os índices do schema de users com createIndexes().
Não usa syncIndexes(), que removeria os índices firstName, lastName e
role declarados apenas no desired-state. A remoção automática desses três
índices durante um arranque concorrente pode causar post-ensure verification failed no release. A limpeza específica de índices sociais unique não-sparse
legados mantém-se. Após publicar a correção, confirmar que um restart da API
preserva os três índices e que o release termina com zero falhas. Não requer
migração de documentos nem alterações nas Variable Groups. Impacto esperado
em custos Azure/Atlas: sem alteração; preserva os índices já exigidos no deploy.

Para a telemetria Magic, aplicar dev → staging → prod os dois novos índices de
userevents: {source:1,eventType:1,occurredAt:-1} e unique
{userId:1,eventId:1} com
partialFilterExpression: {eventId: {$type: 'string'}}. O filtro parcial deixa
os eventos legados sem eventId fora da restrição unique. Fazer esta verificação
antes de publicar a API com idempotência e antes do rollout Flutter → Admin.
O contrato exige eventId e metadata.activation_id UUID v4,
source=magic_button, occurredAt ISO-8601 com timezone e metadata restrito
aos cinco campos documentados em Magic Button.

occurredAt conserva o instante do cliente; createdAt é a receção no servidor
e mantém exclusivamente o TTL de sete dias (604800 segundos). Replay offline
não altera o instante usado pelo TTFA e pela janela de métricas. Retry
equivalente retorna 201 com o evento original e não renova os timestamps;
payload divergente para o mesmo userId + eventId retorna 409
USER_EVENT_IDEMPOTENCY_CONFLICT. Verificar no log de cada release que todos
os índices estão no total verified e fazer smoke de criação, replay
equivalente e conflito, confirmando uma única linha e timestamps estáveis.
Verificar também o dashboard; 30/90 dias continuam limitados pela retenção
atual, e activationEventsTruncated deve ser visível ao atingir 50 000 eventos.

Não há novo recurso Azure, réplica, variável ou dependência. O impacto esperado
é baixo, limitado ao campo/data, storage dos dois B-trees e trabalho de
índice por insert no Atlas. Retentativas podem aumentar o tráfego da API e logs.
Não há estimativa monetária validada: confirmar volume real de eventos,
tamanho da collection/índices e métricas de writes, latência e ingestão de logs
antes de produção. Nenhum índice remoto ou deploy foi executado neste fecho
local de 30/09/2026.

Bootstrap contract (anti-drift)

Para evitar regressões por drift de variáveis e secrets das Container Apps,
os pipelines de release aplicam sempre, de forma idempotente, o conjunto gerido
por ambiente antes do preflight/health check.

Camada 1 (source of truth versionado): o estado desejado vive em deploy/config/appsettings/<service>/<env>.json. Todos os valores são placeholders ${VAR_NAME} — nunca hardcoded. Os valores reais vêm dos ADO Variable Groups (audissey-dev, audissey-staging, audissey-prod), que são o source of truth centralizado. O script scripts/ensure-containerapp-env.js resolve os placeholders via process.env e aplica/audita API, Admin, ML e Worker. ensure-appsettings.js é reservado a App Services legados.

Camada 2 (auditoria agendada): o pipeline azure-pipelines-appsettings-audit.yml corre diariamente em modo audit (não mexe) e falha se houver drift. Para correção controlada, pode-se executar manualmente em modo repair (dev/staging). Em produção, o pipeline só faz audit.

Desde setembro de 2026, a auditoria cobre API, Admin, ML e Worker nos três
ambientes, mesmo quando um dos serviços falha. RBAC e o desired-state do
Container Apps Job de treino também são auditados nessa execução.
repair é traduzido para ensure só em dev/staging; prod executa sempre
audit, inclusive quando o parâmetro escolhido é repair. Um recurso ausente,
uma variável em falta ou uma falha de leitura não são apresentados como ausência
de drift. O modo repair nunca altera o Container Apps Job de treino: JOB_MODE
permanece explicitamente audit; a reconciliação mutável do job pertence ao
release ML. As definições prod representam recursos previstos e a existência das
quatro apps deve ser confirmada no ambiente antes do lançamento.

O reconciliador só lê segredos associados às chaves geridas. Uma falha ao ler
um segredo interrompe a operação antes de qualquer reparação. Valores sensíveis,
incluindo URIs MongoDB/Redis, são ocultados nos logs; erros do Azure CLI não
imprimem argumentos nem respostas que possam conter credenciais. Novas chaves
sensíveis usam secretRef; referências existentes são preservadas. É necessário
reiniciar as revisões ativas ou publicar uma nova revisão para ativar uma rotação
de segredo. O modo ensure confirma configuração, não a ativação em processos já
em execução; os releases publicam a imagem depois do bootstrap. Ver
comportamento de segredos nas Container Apps.

Os releases da API, Admin e ML fazem checkout: self e aplicam o respetivo
desired-state antes do deploy, preflight ou health check. No Admin, a criação
idempotente do recurso ocorre primeiro quando a Container App ainda não existe;
o bootstrap versionado corre imediatamente depois e antes de publicar a imagem.

Verificação local sem acesso ao Azure:
node --test scripts/ensure-containerapp-env.test.js. Depois da aplicação dos
VGs e do deploy, executar o pipeline em audit e confirmar as três apps e RBAC;
se houver rotação isolada em repair, confirmar também a ativação numa revisão.

Camada 3 (guardrails de RBAC - audit-first): além de App Settings, o drift pode acontecer via permissões (alguém ganha Owner/Contributor e consegue editar settings no Portal). Para reduzir esse risco, existe um estado desejado (allowlist) para RBAC por ambiente em deploy/config/rbac/<env>.json e um auditor em scripts/audit-rbac.js.

O pipeline azure-pipelines-appsettings-audit.yml também executa esse auditor e falha se encontrar assignments inesperados (incluindo herdados a partir do scope da subscription). Por padrão, é audit-only: não remove permissões automaticamente.

Estado do audit (21/10/2026). O pipeline 23 nunca tinha ficado verde no
histórico retido. Causas e correções:

Criar o role e as atribuições (identidade com Owner/User Access Administrator;
custo Azure: nenhum):

az role definition create --role-definition deploy/config/rbac/roles/audissey-rbac-auditor.json
az role assignment create --assignee-object-id <sp-object-id> --assignee-principal-type ServicePrincipal \
  --role "Audissey RBAC Auditor" --scope <workspace-resource-id>

Verificação: node --test scripts/audit-rbac.test.js; simulação local
(só leitura, com uma conta que leia tudo):
ML_TRAINING_JOB_NAME=audissey-ml-training-dev WORKER_CONTAINER_APP_NAME=audissey-worker-dev node scripts/audit-rbac.js --config deploy/config/rbac/dev.json --label rbac:dev;
depois executar o pipeline 23 em audit e confirmar Dev/Staging verdes e só o
warning de ProdSkipped.

Aplicado em 21/10/2026: VGs 2/3 corrigidos; role Audissey RBAC Auditor
(7e09d8c2-f76b-4819-a3f5-5e2c3a34bc28) e as 5 atribuições criados; Reader no
Redis para o SP prod e Container Apps Jobs Operator do worker-staging no job
audissey-ml-training-staging atribuídos; 42 role assignments órfãos removidos
(40 AcrPull no ACR, 2 Log Analytics Reader). Run 6959 (audit): Dev e Staging
sem drift, RBAC OK, warning ProdSkipped. prod.json ainda espera 1a8790b1
(identidade apagada) no workspace prod: atualizar quando a API prod existir.

Camada 3 (monitorização): azure-pipelines-monitoring-health-alerts.yml
corre diariamente em audit; ensure só é manual. A execução tenta, mesmo após
uma falha, os alertas health, de podcast e de radio recognition e o budget, acumulando o
estado e falhando apenas no fim. deploy/config/alerts/healthchecks.json cobre
falhas via Application Insights, restarts das Container Apps Worker/ML e clientes
Redis. O reconciliador compara também namespace, dimensions e autoMitigate,
além de condição, severidade, janela, frequência, scope, enabled e Action Group.
O parâmetro environments (default dev,staging,shared) limita os alertas health:
prod fica de fora até audissey-prod-rg existir.

No API, o contrato mínimo é:

E, para permitir que o Worker reporte resultados de scraping (persistidos em scraping_logs e visíveis no Admin Portal), o API tem de ter também:

Este valor é sensível (não pode ser commitado) e deve ser fornecido via Variable Group (ex.: audissey-staging) para os pipelines de release aplicarem o valor através de deploy/config/appsettings/api/<env>.json (placeholder ${WORKER_API_KEY}).

E, para garantir observabilidade e evitar falhas de preflight por drift:

Nota: por ser um valor sensível (pode ser usado para enviar telemetria), não guardamos o connection string em git. O desired-state da API usa ${APPLICATIONINSIGHTS_CONNECTION_STRING} e os três Variable Groups têm de fornecer o secret por env: mapping explícito aos pipelines. ensure-containerapp-env.js mantém o valor fora dos logs.

Adicionalmente, as seguintes settings usam placeholders ${...} resolvidos a partir dos Variable Groups (não-secret, auto-disponíveis como env vars nos tasks de pipeline):

Princípio: os Variable Groups são o source of truth para todos os valores variáveis por ambiente. O desired-state JSON apenas referencia placeholders; nunca contém URLs ou chaves hardcoded.

Isto garante que, mesmo que alguém limpe/edite settings no Portal, o próximo deploy volta a colocar o serviço num estado arrancável e com CORS correto.

Troubleshooting: Scraping logs não aparecem (staging/dev)

Sintoma típico: o Worker executa scraping (vês logs de jobs a correr), mas o Admin Portal não mostra entradas em Scraping Logs.

Na prática, isto quase sempre é o Worker a falhar o POST para POST /api/admin/scraping/logs com 401 Unauthorized (mismatch do WORKER_API_KEY).

  1. Confirmar pelo Log Analytics (Container Apps)
  1. Confirmar wiring (sem expor segredos)
  1. Correção

Outros serviços também aplicam um conjunto mínimo (ex.: Admin garante API_BASE_URL/WEBSITES_PORT) para evitar drift de endpoints, falhas de warmup e regressões em operações como o treino manual.

ML (Sep 2026): os ficheiros deploy/config/appsettings/ml/*.json são consumidos pelo mesmo ensure-containerapp-env.js no release e na auditoria. Bootstrap ocorre antes do preflight. O nome lido pelo serviço é MONGO_DB_NAME, mapeado para ${MONGODB_DB_NAME} da VG. MONGO_URI, LLM_KEY e ML_INTERNAL_API_KEY têm mapping secreto explícito nos três releases e na auditoria; prod exige a mesma proteção de API interna.

ML (Out 2026): nova variável não secreta ML_RECOMMENDATION_WEIGHT_BY_SIGNAL (true/false, default false) nos três VGs. Vai para o ML Service como RECOMMENDATION_WEIGHT_BY_SIGNAL (deploy/config/appsettings/ml/*.json) e para o job de treino (recommendationWeightBySignal em deploy/config/ml-training-job/*.json, aplicado e auditado por ensure-ml-training-job.js). Tem de existir nos VGs antes do release ML e da auditoria diária: os dois scripts recusam placeholders sem valor. Efeito: ver docs/setup/ML_SERVICE_SETUP.md → "Sinal de tempo de escuta no treino". Verificar: az containerapp show / az containerapp job show → env RECOMMENDATION_WEIGHT_BY_SIGNAL; no treino, o log LightFM trained … weighted by signal=….

Worker (Out 2026): ML_TRAINING_BATCH_SIZE e ML_TRAINING_BATCH_DELAY_MS (snapshot ml_training_data) passam a estar em deploy/config/appsettings/worker/*.json como placeholders dos VGs (1000 / 100 nos três ambientes). Até aqui os VGs tinham valores que nunca chegavam ao worker, e um dia de snapshot demorava ~57 min com os defaults (50 / 1000 ms). Verificar: az containerapp show -n audissey-worker-<env> → env ML_TRAINING_BATCH_*.

Email (Out 2026): o email transacional passou de SendGrid para Sender (POST https://api.sender.net/v2/message/send), através do cliente partilhado worker-shared/src/services/sender-email.client.ts. SENDGRID_API_KEY foi substituída por SENDER_API_TOKEN (corte direto, sem fallback). SENDER_API_TOKEN é secret nos VGs audissey-dev (id=2), audissey-staging (id=3) e audissey-prod (id=4) e tem env: mapping explícito em azure-pipelines-api-release.yml, azure-pipelines-worker-release.yml e azure-pipelines-appsettings-audit.yml. O Worker passa também a receber SENDER_API_TOKEN, ALERT_FROM_EMAIL e ALERT_RECIPIENTS (deploy/config/appsettings/worker/*.json) para enviar o digest de alterações de grelhas. Adicionar SENDER_API_TOKEN aos três VGs, e ALERT_RECIPIENTS ao VG audissey-prod (hoje não existe), antes do release: ensure-containerapp-env.js recusa placeholders sem valor e o release falha. A variável antiga SENDGRID_API_KEY não é apagada pelo ensure e fica nas Container Apps até ser removida à mão. Guia completo (DNS, rollout, verificação, limpeza): docs/setup/EMAIL_SENDER_SETUP.md. Verificar: az containerapp show da API e do Worker do ambiente (prod: audissey-api / audissey-worker) → env SENDER_API_TOKEN presente (não imprimir o valor).

Passwordless email (Out 2026): adicionar estas três variáveis não secretas aos VGs 2/3/4 antes do próximo release da API. Os três desired-state deploy/config/appsettings/api/*.json usam apenas placeholders ${VAR}; ensure-containerapp-env.js falha se faltar algum valor. Não é necessário mapping env: no pipeline.

VG AUTH_FROM_EMAIL PASSWORDLESS_HTTP_LINK_BASE PASSWORDLESS_DEEP_LINK_BASE
audissey-dev (2) no-reply@audissey.io https://dev.audissey.app/auth/passwordless audissey-dev://app/auth/passwordless
audissey-staging (3) no-reply@audissey.io https://staging.audissey.app/auth/passwordless audissey-staging://app/auth/passwordless
audissey-prod (4) no-reply@audissey.io https://audissey.app/auth/passwordless audissey://app/auth/passwordless

Fluxo: email → universal link HTTPS → app do mesmo ambiente; a página de fallback fica no mesmo host. Emails antigos com /auth/passwordless/open usam o custom scheme configurado, sem mostrar o token no texto da página. Logo: https://audissey.app/email/audissey-logo.png (PNG 320×72 em fundo opaco #15121B, apresentado a 160×36). O logo e a página de fallback são servidos pelo Firebase Hosting (site audissey-2224d, partilhado por audissey.app, dev.audissey.app e staging.audissey.app), a partir de audissey_app/firebase-hosting/public/ (email/audissey-logo.png, auth/passwordless.html). Para publicar preservando os outros ficheiros ativos, ver audissey_app/docs/development/DEEP_LINKS_PER_FLAVOR.md. Verificar no email de cada ambiente que o botão usa o respetivo host e abre a app correta; validar logo e botão no iOS Mail em modo escuro. Sem novos recursos ou alterações de escala Azure; impacto de custo esperado: nenhum. Detalhes: docs/setup/EMAIL_SENDER_SETUP.md.

Treino ML one-shot (fase 3 / B13)

O treino LightFM usa um Container Apps Job manual por ambiente e a mesma imagem
versionada do ML Service. azure-pipelines-ml-release.yml cria/atualiza o job,
roda o secret MONGO_URI, configura python -m src.training_job, garante
AcrPull para a identidade do job e atribui ao Worker Container Apps Jobs Operator apenas no scope desse job. Este papel permite start/stop e, por
limitação do serviço Azure, deve ser tratado como acesso confiável aos secrets
disponíveis ao job. Não atribuir no resource group ou subscription.

ensure-ml-training-job.js nunca inclui o valor de MONGO_URI no argv do Azure
CLI nem o herda para processos az. A rotação usa az rest --body @ficheiro
com payload temporário num diretório privado e modo 0600; o ficheiro é removido
em sucesso, erro, SIGINT e SIGTERM, e o conteúdo não é escrito nos logs.

Adicionar as oito variáveis ML_TRAINING_*/ML_CONTAINERAPPS_ENVIRONMENT e
WORKER_CONTAINER_APP_NAME acima aos VGs audissey-dev, audissey-staging e
audissey-prod. Os desired-state são
deploy/config/ml-training-job/{dev,staging,prod}.json e
deploy/config/appsettings/worker/{dev,staging,prod}.json; todos os valores são
placeholders de VG. A auditoria diária pode reparar appsettings de Worker em
dev/staging, mas mantém o job audit-only em todos os ambientes; prod é sempre
audit-only. A criação/reconciliação mutável do job ocorre no release ML.
Quando o CAE vive noutro resource group, ML_CONTAINERAPPS_ENVIRONMENT deve ser
o resource ID completo; usar apenas o nome faz o Azure CLI procurá-lo no mesmo
resource group do Job. command e args são aplicados pelo PUT REST seguro,
porque az containerapp job create/update --args -m ... interpreta -m como
uma opção do próprio CLI. Na primeira criação, o ensure mantém a imagem pública
bootstrap até garantir AcrPull e configurar o registry com a identidade do
Job; só depois aplica a imagem privada versionada, evitando validação anónima ou
falhas durante a propagação inicial de RBAC.
Após o PUT REST de secrets, o ensure aguarda provisioningState=Succeeded
antes de modificar registry/imagem: o ARM pode aceitar o PUT antes de concluir
a operação e rejeitar alterações concorrentes com
ContainerAppsJobOperationInProgress. A espera é limitada a cinco minutos.

O audit do job compara ambiente, imagem/repositório, CPU, memória,
command/args, trigger manual, retry/timeout, parallelism/completion, identidade,
registry e env não secreto. O ensure repete a auditoria depois das alterações;
antes de rodar MONGO_URI, remove identidades/registries inesperados, volta a
ler o recurso e constrói o PUT secreto a partir desse estado atual para não
reintroduzir drift;
drift do Container Apps Environment é imutável e falha com instrução de
recriação, sem apagar o recurso automaticamente. audit-rbac.js também aplica
resourceScopedRoles: exige Container Apps Jobs Operator exatamente no job e
AcrPull exatamente no ACR e acusa uma atribuição equivalente mais ampla.

Ordem de rollout: (1) aplicar os índices Atlas, sobretudo o unique+sparse de
ml_training_runs.active_key e os compostos de correlação de
job_execution_logs; (2) release ML para criar o job; (3) release
Worker/API/Admin; (4) trigger manual no Admin e confirmar queued → running → completed; (5) durante outro run, reiniciar o Worker e consultar o mesmo
run_id; (6) confirmar health/recommend em duas réplicas e reload de
activeVersion. Não declarar B13 validado remotamente antes destes passos.

Antes do passo Admin, aplicar também os três índices Atlas usados pelos
diagnósticos paginados: userfeatures {updatedAt:-1,_id:-1},
listeninghistories {playedAt:-1,_id:-1} e
content_features {updatedAt:-1,_id:-1}. Fazer rollout dev → staging → prod,
fora do pico, e confirmar a conclusão do build antes de libertar o Admin. O
build inicial lê as collections e consome CPU/IO; os B-trees ocupam storage e
amplificam writes (updatedAt em cada atualização e playedAt em cada
insert/alteração). O impacto é baixo a médio, mas só collection sizes, key sizes,
tier e billing Atlas permitem quantificá-lo. Estes índices estão versionados;
não foram aplicados remotamente por esta alteração.

Se um crash deixar trainingOwner preso e a reconciliação normal não avançar,
primeiro confirmar no Azure que a execução indicada pelo run está terminal ou
ausente. Só depois executar, com as variáveis do ambiente e o mesmo ID nas duas
flags:

node scripts/recover-ml-training-run.js \
  --runId '<run-id>' \
  --confirmRunId '<run-id>'

O script recusa execução não terminal, valida que o run ainda detém a slot e
autoriza transacionalmente a geração de recovery seguinte; dentro da mesma
transação recusa um trainingOwner diferente e exige confirmar a libertação do
owner do próprio run. Os subprocessos az recebem uma allowlist mínima:
PATH, home/configuração Azure, identificadores Azure não secretos,
locale/temporários e certificados. Variáveis de aplicação ou segredos, incluindo
MONGO_URI, DATABASE_URL, REDIS_URL, SENTRY_DSN e credenciais/tokens, não
são herdados. O script não remove nem recria Bull jobs: imprime um nonce
único que deve ser enviado por um admin com escrita para
POST /admin/ml/train/recover no corpo
{"runId":"<run-id>","recoveryNonce":"<nonce>"}. A API compara o hash do
nonce, a geração e o payload do job. Um claim com lease retoma authorized ou
removing: remove o job exato se ainda estiver failed, ou aceita a ausência
se ele expirou/foi removido antes de um crash; depois avança por CAS para
ready e recria jobId=runId. Job ativo, geração errada ou nonce diferente
são recusados sem remoção.
Se ARM ainda não tiver devolvido um nome, a correlação usa a janela do
dispatch_requested_at e recusa resultados sem timestamp por serem ambíguos.
O novo claim recebe um lease_token; heartbeat, conclusão/falha e publicação
do lifecycle exigem esse token, impedindo a execução confirmada como antiga de
alterar a tentativa retomada com o mesmo runId.
Nunca limpar por idade de heartbeat.

Cada dispatch ARM reclama antes um token de ownership por CAS. Só o vencedor do
CAS chama start; concorrentes observam o run partilhado. A escrita da
observação é cercada por owner, operação, estado e execution name; um observer
atrasado recarrega o owner novo e não retoma a execução antiga. A outbox verifica
os runs ativos queued|running|started com Queue.getJob(runId): só um run
queued pristine, sem orchestrator_job_id nem prova de vínculo anterior, pode
ser criado como perda simples. Se já existia vínculo — incluindo replacement
ready ou consumed —, ou se o run está em running|started, job
ausente/expirado ou terminalmente falhado exige recovery explícito. Runs criados
dentro de um job ml-auto-train (cron, auto-train do Admin, rebuild de
privacidade; chave bull:ml-auto-train:<jobId> em
idempotency_key/idempotency_keys) ficam com esse job enquanto ele não estiver
failed nem passar a lease de 6 h; depois disso exigem recovery explícito (ver
docs/features/ML_MODEL_PERSISTENCE.md). A geração e
o nonce do replacement são vinculados no Mongo antes de queue.add; falha antes
de ready → consumed devolve apenas essa geração exata a required. O estado
Bull required|authorized|removing|ready|consumed é separado do dispatch_state
ARM. O reconciliador nunca reclama authorized: isso exige o POST protegido por
AdminWriteGuard e o nonce. Jobs normais/cron consultam primeiro a slot ativa e
falham antes de devolver triggered=false, observar ou iniciar ARM.

Uma retoma automática também recusa avançar enquanto o lifecycle conservar
trainingOwner: esse estado pode significar que uma escrita remota antiga ainda
está em curso. O retry BullMQ termina com erro, sem devolver sucesso falso nem
libertar a slot. O script acima é o único percurso operacional para limpar esse
owner, depois da confirmação ARM e do duplo runId.

O marcador de publicação é sempre o par {privacyEpoch, activeVersion}. API,
Worker e ML capturam esse par antes de ler embeddings/artefactos, filtram pela
versão exata e confirmam-no novamente antes de responder. Caches Redis incluem a
versão; uma promoção dentro do mesmo epoch não pode servir resultados antigos.
Embeddings sem modelVersion são compatíveis apenas no epoch zero e devem ser
substituídos pelo primeiro treino versionado antes de remover o suporte legado.

Depois de publicar Worker/API/Admin, o Queue Monitor deve mostrar a execução
queued/ativa separada do último terminal. Confirmar queueName, Bull jobId,
runId, queue delay, tentativa, heartbeat e publicação do treino. partial
deve aparecer como warning. Ausência de publishedAt/resultFreshnessAt
significa “metadata não instrumentada”; não converter completedAt em frescura.
GET continua disponível a viewer; limpeza e triggers continuam reservados a
admin. Esta monitorização não acrescenta variáveis de ambiente.

Impacto de custo qualitativo: baixo a médio, variável. Não há réplica de
treino always-on; o custo nasce apenas por execução e depende de CPU, memória,
duração, frequência, logs e tráfego entre regiões. Os valores iniciais 1 CPU
e 2Gi são ponto de ensaio, não capacidade comprovada. Confirmar billing real e
pico de memória antes de alterar o sizing.

Autenticação Apple e passwordless — preparação da fase 1

Adicionar APPLE_CLIENT_IDS como variável não secreta aos três VGs antes do
deploy. É uma lista CSV de audiences Apple aceites; a API recusa Apple login
se a lista estiver vazia. Os bundle IDs nativos confirmados no projeto Flutter
são com.audissey.audisseyApp.dev, com.audissey.audisseyApp.staging e
com.audissey.audisseyApp, respetivamente. Se houver um Services ID Apple para
outro fluxo autorizado, acrescentar o identificador registado ao ambiente
correspondente; não aceitar automaticamente audiences de outros ambientes.
O desired-state usa ${APPLE_CLIENT_IDS}; a variável não secreta é autoexposta
pelo Azure DevOps.

Aplicar os índices versionados antes de ampliar o rollout: TTL expiresAt
e lookup userId em passwordless_tokens, recuperação de pedidos em
users.deletionRequestedAt/_id e anonimização em dedup_candidates.resolved_by.
A unicidade do token é assegurada pelo _id MongoDB. Links passwordless
emitidos antes do deploy não têm registo de consumo e devem ser pedidos de novo.
Atualizar todas as réplicas da API: uma réplica antiga ainda aceita links sem
esse controlo. Ver contrato de sessão.

Impacto de custo esperado: baixo, usando MongoDB e compute existentes. Há
gravação/consumo de tokens temporários, índices pequenos, recuperação de pedidos
de eliminação uma vez por minuto e leituras adicionais na auditoria. Não são
criadas réplicas permanentes, SKUs, private endpoints ou serviços novos. Os
valores em euros dependem de operações, armazenamento e faturação atuais;
confirmar VGs/Atlas e consumo antes do rollout.

Nota importante (Key Vault Private Endpoint): como os vaults têm PublicNetworkAccess=Disabled, checks de data-plane (ex.: listar/ler secrets) podem falhar a partir do agente/pipeline. Por isso, o anti-drift foca-se em garantir o bootstrap (AZURE_KEY_VAULT_*) e em validar settings não-secret; os checks de secrets no pipeline devem ser best-effort (warn), nunca bloqueando um deploy quando o bootstrap está correto.

Worker — Brave Search (Phase 5)

O Worker usa a Brave Search API (free plan: 2000 queries/mês, 1 req/s) para descobrir/repair URLs de programação entre a path heuristic e o Gemini. Substitui chamadas caras de LLM por queries web estruturadas — particularmente útil quando o domínio da rádio mudou e a path heuristic (origin-locked) falha.

Variável Tipo Default Descrição
BRAVE_SEARCH_API_KEY 🔒 secret (unset → feature disabled) Subscription token (X-Subscription-Token). Se ausente, discovery cai para path heuristic → Gemini → homepage crawl.
BRAVE_SEARCH_MONTHLY_BUDGET não-secret 1800 Cap mensal atómico persistido em WorkerSetting (brave_search_usage:YYYY-MM, $inc atómico). Reset automático por mês.

Como configurar (uma vez por VG):

  1. Adicionar BRAVE_SEARCH_API_KEY aos VGs audissey-dev (id=2), audissey-staging (id=3), audissey-prod (id=4) como secret.
  2. Adicionar BRAVE_SEARCH_MONTHLY_BUDGET=1800 aos três VGs (não-secret).
  3. O pipeline azure-pipelines-worker-release.yml mapeia BRAVE_SEARCH_API_KEY no bloco env: e faz az containerapp secret set + az containerapp update --set-env-vars automaticamente — mesmo padrão da MISTRAL_API_KEY. Não há hard-fail se a chave estiver ausente; logs do pipeline mostram ℹ️ BRAVE_SEARCH_API_KEY not set – Brave Search disabled.

Anti-drift guarantees:

Cost impact: Brave free plan cobre 1800–2000 queries/mês (suficiente para ~64/dia, em sintonia com os batches de repair diários). No expected impact on Azure infra cost — não há réplicas extra, Log Analytics extra, ou storage novo; apenas 1 nova collection key em worker_settings.

Worker — Chromium / headless browser (Phase 4)

O HeadlessBrowserService (consent-aware renderer para SPA shells + consent walls)
corre sob puppeteer-core com o Chromium do Alpine, indicado por
PUPPETEER_EXECUTABLE_PATH, que tem prioridade em launchBrowser.
@sparticuz/chromium só é usado quando a variável não está definida. É glibc-only:
em Alpine falha com spawn /tmp/chromium ENOENT, porque não existe
/lib64/ld-linux-x86-64.so.2.

Antes desta correção, o render falhava sempre nas imagens Alpine. Em staging,
867 de 867 lançamentos falharam (2026-09-30 a 2026-10-30, 219 rádios) e
renderPage devolveu null.

Setting Onde Valor Notas
PUPPETEER_EXECUTABLE_PATH Dockerfiles do worker /usr/bin/chromium-browser Set em Dockerfile.worker, Dockerfile.worker.acr e Dockerfile.worker.deploy.
apk packages Dockerfiles do worker chromium~155 nss freetype harfbuzz ca-certificates ttf-freefont Layer de ~303 MiB comprimidos. O major só muda num commit (ver "Pin da imagem").
Launch args headless-browser.service.ts sem --single-process Chromium 155 (Alpine 3.24, node:24-alpine) crasha no arranque (Target closed).
MAX_CONCURRENT_PAGES Worker runtime 3 Limita páginas em simultâneo, não garante margem de memória (ver abaixo).

Smoke test no build: azure-pipelines-worker-build.yml constrói a imagem para o
daemon local (--load), corre scripts/worker-chromium-smoke.js dentro dela com
--network none e só depois faz push. O script usa o Node e o puppeteer-core da
imagem, PUPPETEER_EXECUTABLE_PATH e as flags de launchBrowser, lidas do main.js
da imagem, por isso uma flag nova no serviço também é testada. Abre uma página data:
e outra em HTTP no loopback, que passa pela rede do Chromium e pela request
interception, e confirma que o JavaScript delas correu. Se falhar, não há push nem
release.

O passo de push volta a usar as camadas em cache, por isso publica a imagem testada.
Depois compara as layers da imagem testada com as da publicada: sem cache, o apk volta
a resolver os pacotes e pode instalar outras versões. Se diferirem, o pipeline falha e o
release não corre, mas as tags desse build ficam no ACR.

Para correr localmente:

docker buildx build --platform linux/amd64 --load -t audissey-worker-smoke:local -f Dockerfile.worker .
docker run --rm -i --platform linux/amd64 --network none audissey-worker-smoke:local node - < scripts/worker-chromium-smoke.js

Esperado: chromium smoke OK: Chrome/<versão>, ... e exit 0. Erros de dbus, GPU e
Vulkan no log do Chromium são normais. Com node - --single-process em vez de
node -, deve falhar com exit 1 (Target closed). Confirmar a linha OK, não só o
exit code: node - também devolve 0 com stdin vazio.

Medido em 2026-10-30 com Chromium 155. No nodeserver (build 7140, antes do pin da
imagem base), o passo demorou 33 s, com 13 s a lançar e 10 s a renderizar, mais 68 s de
--load. Num Mac ARM, 8 s em Rosetta e 10 a 14 s em QEMU 10.2.1 de tonistiigi/binfmt
(3 de 3). Os limites (5 min para lançar, 10 min no total) só contam num Chromium
pendurado: uma imagem partida falha em segundos. Se o passo falhar só no nodeserver,
ver o log do Chromium nesse passo antes de culpar a imagem. O pipeline regista o binfmt
antes do build porque o docker run precisa dele, ao contrário do BuildKit, que traz
QEMU próprio.

Cost impact do smoke test: no expected impact no Azure. Sem recursos novos, sem
tags novas no ACR e sem mais layers enviadas. Uma imagem rejeitada deixa de ser enviada.
O agente é self-hosted, por isso os ~100 s extra do job não têm custo de Azure.

Verificação pós-deploy:

# 1. Container tem chromium
az containerapp exec -g audissey-staging-rg -n audissey-worker-staging --command "/usr/bin/chromium-browser --version"
# 2. Binário usado e falhas de lançamento
WS=$(az containerapp env show -g audissey-staging-rg -n audissey-cae-northeu-ext --query properties.appLogsConfiguration.logAnalyticsConfiguration.customerId -o tsv)
az monitor log-analytics query -w "$WS" -o table --analytics-query 'ContainerAppConsoleLogs_CL | where TimeGenerated > ago(7d) | where ContainerAppName_s == "audissey-worker-staging" | where Log_s contains "Launching headless browser" or Log_s contains "Failed to launch headless browser" or Log_s contains "Headless render of" | extend evento=case(Log_s contains "Failed to launch headless browser", "launch_failed", Log_s contains "Headless render of", "render_ok", extract(@"Launching headless browser: ([^\x1b\s]+)", 1, Log_s)) | summarize n=count(), t1=max(TimeGenerated) by evento'

Esperado: /usr/bin/chromium-browser e render_ok com contagens acima de zero, e
nenhum launch_failed. Usar Log Analytics, porque
az containerapp logs show --tail só cobre linhas recentes e 72% dos renders
acontecem entre as 03h e as 04h UTC. Em scraping_logs, procurar
status: "success" com extraction_method: "headless-browser". Um render seguido
de day-fanout regista day-fanout.

Memória: em staging (14 dias), o máximo horário de WorkingSetBytes tem mediana
de ~700 MiB às 03–04h UTC e picos de 927 MiB, com limite de 1 GiB. O Chromium soma
~300–500 MiB (medido em container local arm64 com 1 a 3 páginas, não em Azure).
MAX_CONCURRENT_PAGES não limita processos, por isso páginas com muitos iframes de
outros sites podem gastar mais (não medido). Sem mais memória, há risco de OOM nessa
janela. Vigiar WorkingSetBytes e reinícios de réplica depois do primeiro deploy.

Segurança: o Chromium corre com --no-sandbox e executa JavaScript de sites de
terceiros no mesmo container e com o mesmo utilizador do worker, que tem
MONGO_URI, REDIS_CONNECTION_STRING e API keys no ambiente.

Cost impact: low sem alterar recursos. A imagem não muda e deixa de se extrair
o binário de 183 MB para /tmp. Tempo activo extra: no máximo ~10 h/mês
(867 renders × ~40 s), menos de 1 USD/mês a 0,000015 USD/s (0,5 vCPU, 1 GiB, North
Europe). Subir para 0,75 vCPU / 1,5 GiB custa mais ~0,027 USD por hora activa, até
~20 USD/mês por réplica sempre activa. Sem réplicas extra. O volume de logs mantém-se
semelhante. Fora do Azure: o HTML renderizado é maior, por isso cada chamada ao LLM
pode gastar mais tokens (não quantificado).

Pin da imagem: o Node, o Alpine e o major do Chromium só mudam num commit. Os três
Dockerfiles do worker usam o mesmo FROM node:24-alpine3.24@sha256:… e o mesmo
chromium~155. scripts/node-version-alignment.test.js falha se um deles faltar ou
diferir entre ficheiros, ou se um ARG ou LABEL voltar a ficar antes do apk add em
Dockerfile.worker. Nenhuma pipeline constrói .acr nem .deploy.

São precisos os dois, porque o digest não segura o Chromium. O apk resolve-o em
v3.24/community, que recebe majors novos dentro da mesma branch (de 149 a 155 entre
junho e outubro de 2026: 14 versões, 4 mudanças de major) e só guarda a última. Uma
versão exacta (chromium=…) partiria os builds sem cache a cada ~10 dias.

Em Dockerfile.worker, os ARG e LABEL de build ficam no fim porque mudam a cada
build e qualquer RUN depois deles falha a cache. Assim o apk add só volta a correr
se mudar o digest ou a própria linha:

Situação Resultado
Rebuild sem mudar o pin Layer vem da cache (BuildKit ou audissey-worker:buildcache): os mesmos bytes, mesmo com o Alpine mais à frente
Sem cache, Alpine ainda no 155 Instala o 155.x mais recente e as versões actuais dos outros pacotes da layer, sem revisão
Sem cache, Alpine noutro major apk add falha (breaks: world[chromium~155]) e nada é publicado: fazer o bump abaixo

A buildcache é partilhada por dev e main e só guarda a cadeia do último build.
Depois de um bump, um build com o pin antigo (main antes do merge, ou um git revert)
pode ficar sem cache e falhar no apk add, se o Alpine já tiver avançado. Para voltar
atrás, fazer redeploy de uma tag antiga do ACR em vez de reverter e reconstruir.

Cadência: entre bumps o Chromium não recebe patches, e corre com --no-sandbox
sobre JavaScript de terceiros (ver Segurança). Antes, os patches entravam sem revisão
em cada build; agora chegam no PR mensal do Renovate (ver abaixo). Se houver um CVE
explorado, não esperar pelo dia 1: correr a pipeline à mão ou seguir os passos abaixo.
O 3.23 parou no 149 em 2026-06-06 e o 3.22 no 142 em 2025-11-01, por isso é de esperar
que o 3.24 pare quando sair a minor seguinte: nessa altura, subir também o tag, à mão.

Para fazer o bump à mão:

# 1. Digest do índice multi-arch (não o de uma plataforma)
docker buildx imagetools inspect node:24-alpine3.24 | grep -m1 '^Digest:'
# 2. Chromium actual nessa branch do Alpine
curl -fsS https://dl-cdn.alpinelinux.org/alpine/v3.24/community/x86_64/APKINDEX.tar.gz | tar -xzO -f - APKINDEX | grep -A1 '^P:chromium$'
# 3. Mudar os FROM e o chromium~NNN em Dockerfile.worker, .acr e .deploy, e o CHROMIUM_BUILD
#    em Dockerfile.worker (versão do passo 2, com o -rN), e confirmar
node --test scripts/node-version-alignment.test.js
# 4. Construir e correr o smoke test
docker buildx build --platform linux/amd64 --load -t audissey-worker-smoke:local -f Dockerfile.worker .
docker run --rm -i --platform linux/amd64 --network none audissey-worker-smoke:local node - < scripts/worker-chromium-smoke.js

Esperado: chromium smoke OK: Chrome/<versão>, .... Depois do deploy, seguir a
verificação acima e vigiar WorkingSetBytes: um major novo pode gastar mais memória.

Se o digest e o major não mudarem, ou num patch urgente, basta mudar o CHROMIUM_BUILD:
a linha do apk add muda e a layer refaz-se. Não apertar para chromium~155.0.8059.NN,
que falha sem cache assim que o Alpine avançar.

PR mensal (Renovate): azure-pipelines-renovate.yml corre no dia 1 às 06:00 UTC, num
agente Linux do pool Default, e lança a imagem do Renovate fixada por digest.
renovate.json limita-o aos três Dockerfiles do worker. Abre um só PR contra dev, no
branch renovate/worker-base-image-and-chromium, com Audissey Team como reviewer. Um PR
fechado sem merge volta a ser aberto no mês seguinte. Não faz automerge.

O que há de novo O que o PR traz
Digest de node:24-alpine3.24 Os FROM nos três ficheiros
Major do Chromium chromium~NNN nos três ficheiros e CHROMIUM_BUILD, com (new Chromium major) no título e um aviso no corpo
Só um patch do Chromium (digest e major iguais) CHROMIUM_BUILD em Dockerfile.worker
Nada Sem PR

O Renovate lê o Chromium da listagem de
dl-cdn.alpinelinux.org/alpine/v<branch do FROM>/community/x86_64/. Com o digest e o major
iguais não haveria diff, e o Alpine publica um Chromium novo a cada ~10 dias. Por isso
existe o CHROMIUM_BUILD: não faz nada no comando, mas uma linha diferente falha a cache
e o apk vai buscar o build novo. Assim este caso chega como PR, com reviewer, em vez de
um aviso numa run agendada que ninguém abre.

Dois passos da pipeline cobrem o que não gera PR, e falham a run:

Smoke test num PR: o Azure Repos ignora o pr: do YAML, por isso corre por uma build
validation em dev. Nesse caso, azure-pipelines-worker-build.yml pára depois do smoke
test: sem tags, sem buildcache e sem release. A policy é opcional porque uma obrigatória
impediria os pushes directos para dev (documentação da Microsoft, não testado). Se alguém fizer merge com o smoke test vermelho, o
build de dev corre o mesmo teste e não publica. Num PR só correm o build e o smoke test:
no nodeserver, 1088 s e 33 s no build 7140, que teve de refazer o npm ci e o nx build.

Configurar uma vez, depois de renovate.json e da pipeline chegarem a origin/dev. Já
feito em 2026-10-30: pipeline 36 e policy 6. Para refazer:

az pipelines create --org https://dev.azure.com/tiagocosta0526 --project Audissey --name 'Renovate Worker Image' --repository Audissey --repository-type tfsgit --branch dev --yml-path azure-pipelines-renovate.yml --skip-first-run
az repos policy build create --org https://dev.azure.com/tiagocosta0526 --project Audissey --repository-id e2b44882-401b-416e-9989-2cea07f0c800 --branch dev --build-definition-id 7 --display-name 'Worker smoke test' --blocking false --enabled true --manual-queue-only false --queue-on-source-update-only false --valid-duration 0 --path-filter '/Dockerfile.worker;/scripts/worker-chromium-smoke.js'

Depois, correr a pipeline à mão com dryRun ligado. O pool Default não está aberto a
todas as pipelines, por isso a primeira run pede autorização (Permit). Sem isto, a run
do dia 1 fica à espera, sem falhar.

O System.AccessToken corre como Audissey Build Service (tiagocosta0526), porque o
projecto limita o âmbito ao projecto. Precisa de:

Permissão Para quê
Read, Contribute Clonar e fazer push do branch
View project-level information, ver e editar work items Resolver o reviewer; confirmar que não há Dependency Dashboard
Create branch Criar renovate/*
Contribute to pull requests Abrir e actualizar o PR, juntar o reviewer
Force push (rewrite history and delete branches) Actualizar o branch (--force-with-lease) e apagá-lo

Não as dar em todos os repos: qualquer pipeline poderia fazer force push em dev nos repos
de que faz checkout. Confirmado nas ACLs em 2026-10-30: Contribute to pull requests no repo
Audissey, Create branch e Force push só na pasta renovate/. Read e Contribute vêm do
projecto, por isso o token continua a poder fazer push para dev. Para repor:

# Descritor de grafo do build service: a forma Microsoft.TeamFoundation.ServiceIdentity;… falha no az
SUBJECT='svc.MWZiOTVjNDctMzdiNy00MTgyLTlmODktY2Y5ZjgyYzllNWI5OkJ1aWxkOjFiZjEzNWU4LTJiMmEtNGRmNC04ODQwLTAzY2QzZjhjOGM4Zg'
REPO_TOKEN='repoV2/1bf135e8-2b2a-4df4-8840-03cd3f8c8c8f/e2b44882-401b-416e-9989-2cea07f0c800'
az devops security permission update --org https://dev.azure.com/tiagocosta0526 --id 2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87 --subject "$SUBJECT" --token "$REPO_TOKEN" --allow-bit 16384
# refs/heads/ + "renovate" em hex UTF-16LE. 24 = Force push (8) + Create branch (16)
az devops security permission update --org https://dev.azure.com/tiagocosta0526 --id 2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87 --subject "$SUBJECT" --token "$REPO_TOKEN/refs/heads/720065006e006f007600610074006500/" --allow-bit 24

Falhas: a subscrição Renovate Worker Image run failed envia email a todos os membros de
Audissey Team, com os filtros Status = Failed e Definition name = Renovate Worker Image.
Ao refazê-la, mudar "Deliver to": por omissão entrega por papel (last changed by, requested
by, requested for), e uma run agendada provavelmente não tem nenhum (não verificado). O
filtro evita que se perca: a Monitoring Health falhou as 28 runs agendadas que ainda estão
guardadas (2026-10-30).

Limites: um PR aberto há meses dá uma run verde todos os meses, porque nada olha para a
idade do PR. Um reviewer que deixe de existir não falha a run: no Azure, o Renovate só o
regista em debug ou info. O lembrete é o PR e o email ao reviewer. As subscrições da equipa
para PRs estão ligadas, mas a entrega do email ainda não foi observada.

Segurança do token: o System.AccessToken corre num container de terceiros. O digest
limita o código que corre, não o que o token pode fazer. Esta identidade pode fazer push para
dev, por isso um YAML alterado em dev, ou num PR com a build validation, pode pedir um
Variable Group. Em 2026-10-30, audissey-staging e audissey-prod (18 secrets) deixaram de
estar autorizados para todas as pipelines, e ficaram só as autorizadas uma a uma, incluindo
os CD da app no outro repo. Não voltar a ligar "all pipelines". Uma pipeline nova que os use
pede Permit na primeira run. audissey-dev continua aberto a todas. As permissões acima
valem para todas as pipelines do projecto, que partilham esta identidade.

Para actualizar o Renovate, mudar a variável renovateImage à mão: ele não vê esta pipeline.

Para verificar: correr a pipeline com dryRun ligado. Esperado: Renovate saw the pin,
as duas verificações verdes e nenhum branch novo. Localmente, numa pasta com os três
Dockerfiles e o renovate.json (o .git de um worktree fica fora do volume):

docker run --rm -v "$PWD":/usr/src/app -w /usr/src/app -e RENOVATE_PLATFORM=local -e LOG_LEVEL=debug ghcr.io/renovatebot/renovate:44.198.2

Esperado no log: packageFiles with updates, com node e o currentDigest do FROM.

Testado em 2026-10-30 com o Renovate 44.198.2: dry run local com o pin actual; PRs reais
num Gitea local com o pin envelhecido (major, patch, digest, branch inexistente, PR e push
recusados, commit humano no branch, repetição sem PR duplicado); dry run full na pipeline
(run 7151), no nodeserver, com o token do build service e o pin em origin/dev: 97 s,
Renovate saw the pin: 7 dependencies, as duas verificações verdes e sem branch novo. A
imagem com o pin passou o smoke test no nodeserver (build 7147,
Chrome/155.0.8059.84). Por testar até ao primeiro PR real: a criação do branch e do PR
no Azure com estas permissões, e o reviewer, porque não havia nada para actualizar. O
caminho de erro do Azure foi lido no código do Renovate, não executado.

Do Dependabot no Azure DevOps só encontrei os security updates do GitHub Advanced
Security, com licença própria (roadmap de 2024, estado actual por confirmar).

Cost impact do Renovate: no expected impact da pipeline: sem recursos novos, sem
Variable Groups, e o pool Default é self-hosted (isHosted: false), por isso os minutos
não contam. Os PR builds não enviam nada para o ACR. O driver é o merge de cada PR, como em
qualquer bump. Só com um patch, refaz as layers do apk e do npm ci de produção, ~430 MiB.
Com um digest novo, pode refazer tudo o que está na buildcache, ~790 MiB (20 layers,
medido em 2026-10-30). Com um merge por mês são até ~9 GiB por ano, sem retenção no ACR, a
0,10 USD/GB/mês (Basic, West Europe, preço de retalho): cada mês soma até ~0,08 USD à fatura
mensal seguinte. Low. Validar com az acr show-usage -n audissey se o registo crescer
mais do que isto.

Cost impact do pin: low, a descer. Sem recursos novos, e a imagem em runtime é a
mesma (Chromium 155, Node 24.22.2), por isso sem compute extra. O driver é o storage do
ACR: antes, cada build enviava ~413 MiB comprimidos mesmo sem mudar dependências (builds
7124 e 7136: layers de 303 MiB do apk e de 109 MiB do npm ci com digests diferentes).
Agora, um build só de código muda 1 de 9 layers, de ~1 MiB. O registo é Basic (10 GB
incluídos), tinha 108,6 GiB em 2026-10-30 e não tem retenção, por isso cada build evita
storage pago (ver "Storage do ACR"). Quanto custa um bump: ver "Cost impact do Renovate".

A cache foi medida em Docker Desktop, não no nodeserver. Confirmar depois de dois builds
seguidos: az acr manifest show -r audissey -n audissey-worker@<digest amd64> deve listar
a layer de ~303 MiB com o mesmo digest.

EPG scraping pipeline (Phase 1–6)

Documentação operacional da pipeline (cadeia de descoberta, day-fanout, RTP slug fallback, gates de qualidade, regression guard) vive em docs/EPG_SCRAPING_PIPELINE.md. Os env vars relevantes:

Variável Serviço Onde é resolvido
BRAVE_SEARCH_API_KEY Worker azure-pipelines-worker-release.yml (mapeado em env: + az containerapp secret set)
BRAVE_SEARCH_MONTHLY_BUDGET Worker Variable Group, default 1800
ML_SERVICE_URL API/Worker Variable Group
ML_INTERNAL_API_KEY API/Worker/ML Variable Group (secret), replicado para os 3 services
PUPPETEER_EXECUTABLE_PATH Worker Hardcoded nos Dockerfiles do worker

Deploy ordering: a enum extraction_method da API (apps/api/src/app/admin/schemas/scraping-log.schema.ts:ExtractionMethod) é required. Os novos valores (day-table, day-panels, bauer-grelha, day-fanout, planet-radio) chegam no mesmo release train da API ou antes do Worker que os emite. Um valor não mapeado faz POST /api/admin/scraping/logs devolver 400 e o log é silenciosamente perdido.

Atlas Search (`ATLAS_SEARCH_ENABLED`)

The API search (/api/search) uses Atlas $search only when the Container App has ATLAS_SEARCH_ENABLED=true; otherwise it uses the unindexed regex fallback (a COLLSCAN of the ~2 M-document episodes collection on staging, ~3 s per search).

Scheduler-on-API (`API_SCHEDULER_ENABLED`)

All periodic scheduling lives in the API (ApiJobSchedulerService). The Worker is a pure BullMQ consumer with zero @Cron decorators — @nestjs/schedule has been fully removed from the Worker.

Variable Service Default Description
API_SCHEDULER_ENABLED API false When true, the API enqueues all BullMQ jobs on their defined schedules

The variable was renamed from SCHEDULER_ENABLED to API_SCHEDULER_ENABLED because the API and Worker share the same Azure DevOps Variable Group — a single variable name cannot hold different values for each service.

Desired state (appsettings): deploy/config/appsettings/api/{dev,staging,prod}.json — API_SCHEDULER_ENABLED=true. The Worker no longer reads any scheduler variable.

API scheduler (ApiJobSchedulerService):

BullMQ queues (all enqueued by API, processed by Worker):

Queue Schedule Processor
sync-radios Daily 3 AM Lisbon SyncRadiosProcessor
sync-programs Daily 4 AM Lisbon SyncProgramsProcessor
sync-podcasts Daily 5 AM Lisbon SyncPodcastsProcessor
sync-episodes Every minute SyncEpisodesProcessor
backfill-podcast-history Low priority BackfillPodcastHistoryProcessor
send-scheduled-emails Every minute SendScheduledEmailsProcessor
check-stream-health Dynamic (5-60 min) CheckStreamHealthProcessor
run-schedules Every minute RunSchedulesProcessor
aggregate-content-popularity Daily 3 AM Lisbon AggregateContentPopularityProcessor
aggregate-user-features Daily 2 AM Lisbon AggregateUserFeaturesProcessor
snapshot-ml-training Daily 4 AM Lisbon SnapshotMlTrainingProcessor

Worker architecture:

Deduplication: The API uses deterministic jobIds based on date (e.g., sync-radios-2025-07-12). BullMQ rejects duplicate job IDs, ensuring idempotent enqueue.

Scale-to-zero: With all scheduling on the API, the Worker uses minReplicas=0 and a KEDA metrics-api rule (verified in dev/staging). The aggregate wait/active metric wakes it when jobs wait and protects running work; after the configured cooldown it returns to zero.

Trigger Strategy

# Branches principais
main → staging + prod (com approval)
dev → dev environment (automático)

# Feature branches
feature/* → sem deploy automático (manual trigger)

Workflow de Deployment

1. Deploy para Dev (Automático)

# Commit para branch dev
git checkout dev
git add .
git commit -m "feat: nova funcionalidade"
git push origin dev

# Pipeline automático:
# 1. Build images (azure-pipelines-docker.yml)
# 2. Deploy to dev (azure-pipelines-deploy-*.yml)

2. Deploy para Staging (Automático após merge)

# Criar PR de dev → main
git checkout main
git merge dev
git push origin main

# Pipeline automático:
# 1. Build images
# 2. Deploy to staging

3. Deploy para Prod (Manual Approval)

  1. Pipeline detecta merge para main
  2. Completa stages de dev e staging
  3. Pausa no stage de prod
  4. Approver recebe notificação
  5. Approver revisa changes e aprova
  6. Deploy para prod executa

Rollback

Storage do ACR

O registo audissey é Basic: 10 GiB incluídos, depois US$ 0,10/GB/mês (preço de lista da Retail Prices API). Em outubro de 2026 o Cost Management faturou €7,07 de Data Stored até dia 29.

Layers: BUILD_DATE, VERSION e VCS_REF mudam a cada build. Um ARG destes acima de um RUN faz esse RUN falhar a cache: as layers ganham digests novos e o ACR guarda-as outra vez (em outubro, 413 MiB por build no worker e 44 MiB na API). Por isso ficam no fim do stage em Dockerfile, Dockerfile.worker e apps/ml-service/Dockerfile, e scripts/node-version-alignment.test.js falha se voltarem para cima (o mesmo em tools/go-collector/Dockerfile, que hoje não os declara). Dockerfile.admin não os recebe. Dockerfile.acr fica como está: nenhuma pipeline o constrói e az acr build não usa esta cache.

Para confirmar depois de dois builds seguidos, a layer do npm ci deve ter o mesmo digest nos dois (<digest> é o manifest amd64 do índice da tag):

az acr manifest show -r audissey -n audissey-api:<build> --query "manifests[].{digest:digest,arch:platform.architecture}"
az acr manifest show -r audissey -n audissey-api@<digest> --query "layers[].{digest:digest,size:size}"

Crescimento que resta: cada build move a tag buildcache e deixa o manifest anterior sem tag. Só um acr purge os remove. A limpeza manual de 2026-10-30 levou o registo de 109 para 9,2 GiB: apagou 1.364 tags e cerca de 2.700 manifests, mantendo buildcache, as últimas 10 builds e, por app, a imagem em uso e as 2 anteriores. Sem limpeza periódica volta a passar dos 10 GiB.

Retenção: a política de retenção só existe em Premium e, mesmo aí, não cobre manifests OCI, que são os que o BuildKit publica (docs). Subir de SKU não resolve.

Antes de apagar:

Cost impact: low, a descer. Sem recursos novos. O az acr run usa Task vCPU Duration: os primeiros 6000 s têm preço 0 na Retail Prices API (1644 s em outubro, €0). Um primeiro build depois desta alteração volta a enviar as layers uma última vez.

Storage do ACR: limpeza agendada

azure-pipelines-acr-cleanup.yml corre scripts/acr-cleanup.js todos os domingos às 05:00 UTC, a partir de main, com a política em deploy/config/acr-cleanup.json. Faz duas purgas, cada uma verificada por um dry run na mesma execução:

  1. acr purge --untagged-only --ago 7d: manifests sem tag com mais de 7 dias, em todos os repositórios.
  2. acr purge --ago 7d --untagged --filter ...: build tags (N e 1.0.N) fora do keep set, em audissey-{admin,api,ml,worker}.

Keep set, calculado em cada execução: as últimas 10 builds por repositório e, para cada Container App e job, o que corre e as 2 imagens anteriores. Staging só recebe builds de main, por isso as suas imagens costumam estar fora das últimas 10. Os jobs não têm revisões: as imagens anteriores vêm das execuções. O filtro só apanha build tags, logo buildcache e tags com nome (rebuild-1, sse-fix-*) nunca são apagadas por esta pipeline. Uma tag com menos de 7 dias também fica, mesmo fora do keep set, e uma tag bloqueada nunca é apagada (az acr repository update -n audissey --image <repo>:<tag> --delete-enabled false).

Pára sem apagar (exit 3) se:

Dry run por omissão. Um schedule não passa parâmetros, por isso as execuções agendadas só reportam até o default de mode passar a delete num commit. Até lá, apagar é manual: Run pipeline, mode: delete.

Estado em 2026-10-30: a pipeline 37 (ACR Cleanup) está registada, e o primeiro dry run com a sua identidade (run 7155, no nodeserver) passou em 6,5 minutos. A limpeza inicial já correu: o registo ficou em 9,20 GiB, abaixo dos 10 GiB incluídos, sem tags com nome. O schedule só dispara quando o YAML chegar a main, e ainda não correu nenhum delete com esta identidade.

Identidade: audissey-sp-prod (azure-prod-connection, autorizada pipeline a pipeline). Tem Container Registry Tasks Contributor no registo, que está na allowlist de deploy/config/rbac/*.json, e Reader em audissey-dev-rg e audissey-staging-rg, que o audit de RBAC não lista.

Para passar a apagar: depois de dois domingos sem avisos em main, mudar o default de mode para delete.

Tags com nome: esta pipeline não as apaga. A limpeza inicial removeu as que havia; uma tag nova com nome fica até sair à mão, e conta para os 10 GiB.

O que se perde: rollback além do keep set exige rebuild, e uma revisão inactiva sem imagem deixa de poder ser activada. Uma build à espera de promoção há mais de 7 dias, e fora das últimas 10, é apagada, e o deploy dela falha ao validar a tag. O keep set só protege o que a identidade consegue ver: uma app noutro resource group ou noutra subscrição, ou algo que use as credenciais de admin fora do Azure, fica de fora. audissey-sp-prod faz os deploys de prod, por isso, quando audissey-prod-rg existir, essas apps ficam visíveis e protegidas, com um aviso até serem juntas à política. Reader na subscrição não serve: o audit-rbac.js acusa-o como mais amplo do que o Reader no Redis.

Admin portal: o estado não aparece lá, e não é omissão. As páginas de jobs leem job_execution_logs, que é por ambiente, com TTL de 7 dias e JobType validado, e esta pipeline trata um registo partilhado, semanal, sem acesso à BD. Levar o estado para lá exigiria MONGO_URI nesta pipeline, um tipo novo de job e uma escolha arbitrária do ambiente, e a entrada de domingo expiraria no domingo seguinte. O estado fica nas execuções da pipeline e nos artifacts, e o resultado (o custo do registo) já se vê em azure-costs agrupado por ServiceName. Falta um alerta: o ensure-health-metric-alerts.js ainda não suporta o metric StorageUsed do ACR.

Verificar:

node --test scripts/acr-cleanup.test.js
node scripts/acr-cleanup.js --mode dry-run --config deploy/config/acr-cleanup.json --max-tag-deletions 200 --output /tmp/acr-cleanup

O segundo não apaga nada, mas corre duas ACR Tasks e requer as permissões acima. O tempo é sobretudo um az acr manifest show por imagem que fica. Nas duas purgas, os logs estão em untagged-dry-run.log e tags-dry-run.log, e o plano com o motivo de cada build em plan.json.

Cost impact: low, a descer. Sem recursos novos e o agente é self-hosted. O storage acima de 10 GiB custa US$ 0,10/GB/mês, e sem limpeza o registo cresceu 15,5 GiB/mês em média desde agosto. O az acr run usa Task vCPU Duration: os dois dry runs da run 7155 somaram 16 s, e os primeiros 6000 s têm preço 0 (1644 s em outubro). Validar em Cost Management no mês seguinte ao primeiro delete.

Rollback de Container

# Listar tags disponíveis
az acr repository show-tags \
  --name audissey \
  --repository audissey-api \
  --orderby time_desc

# Fazer deploy de versão anterior numa Container App existente
az containerapp update \
  --name audissey-api \
  --resource-group audissey-prod-rg \
  --image audissey.azurecr.io/audissey-api:1.0.0

Rollback de Variáveis

Variable Groups mantêm histórico de changes. Restaurar versão anterior no UI.

Monitorização por Ambiente

Podcast sync incremental

Antes do deploy, adicionar aos Variable Groups audissey-dev,
audissey-staging e audissey-prod:

Variável Valor inicial Consumidor
PODCAST_SYNC_DISPATCH_LIMIT 500 API
PODCAST_SYNC_REGULAR_INTERVAL_MINUTES 1440 API
PODCAST_SYNC_FAVORITE_INTERVAL_MINUTES 60 API
PODCAST_SYNC_LEASE_MINUTES 10 API
PODCAST_SYNC_RATE_PER_MINUTE 120 API + Worker
PODCAST_SYNC_CONCURRENCY 10 Worker
PODCAST_BACKFILL_RATE_PER_MINUTE 10 Worker

São variáveis não secretas. Os desired-state JSON da API usam apenas
placeholders e o pipeline Worker reconcilia as três variáveis que consome. Não
ativar o cron antes de as variáveis existirem nos três grupos. Os intervalos
eram 60/15 até out/2026 e ficaram em 1440/60 (uma vez por dia por
podcast, de hora a hora para favoritos) com defaults iguais no código. A
25/10/2026 os grupos ainda tinham 60/15: é preciso atualizá-los à mão.

Capacidade mínima:

node scripts/calculate-podcast-sync-capacity.js \
  --active-feeds <catalogo-ativo> \
  --p95-seconds <p95-segundos> \
  --concurrency 10 \
  --slo-minutes 60 \
  --margin 1.3 \
  --global-rate-per-minute 120

Em 2026-08-04 o catálogo público de staging reportava 200 podcasts. Com p95 de
planeamento de 30 segundos, concorrência 10 e margem de 30%, são necessários
4,34 feeds/minuto e uma réplica tem capacidade para 20 feeds/minuto. KEDA mantém
os limites 2/2/3 (dev/staging/prod), suficientes para o SLO e para recuperação de
backlog. Depois das primeiras 24 horas, substituir o p95 de planeamento pelo
observado e aumentar maxReplicas se o resultado do script for superior.
Aumentar réplicas sem aumentar o limiter melhora concorrência em feeds lentos,
mas não ultrapassa 120 pedidos/minuto.

Alertas são reconciliados pela pipeline
azure-pipelines-monitoring-health-alerts.yml e pelo desired-state
deploy/config/alerts/podcast-sync.json.

Application Insights

O desired-state de observabilidade referencia um Application Insights por
ambiente; confirmar a existência remota antes do lançamento:

Alertas

deploy/config/alerts/healthchecks.json cobre:

O reconciliador compara condição, metric namespace, dimensions, autoMitigate,
severidade, janela, frequência, estado, scope e Action Group. A pipeline corre
diariamente em audit, tenta health, podcast, radio recognition e budget e só falha
depois de executar todos. O ficheiro também codifica o budget 350/400/500 € sem shutdown;
criação/reconciliação exige confirmar a moeda de billing da subscription.

Estão marcados como gaps, sem alerta:

Exit codes de scripts/ensure-health-metric-alerts.js:

Código Significado
0 Sem drift
1 Crash, por exemplo ficheiro de configuração inexistente
2 Argumentos inválidos, flag desconhecida ou alerta sem environment
3 Drift: missing, different[campos], target-missing, action-group-missing, ensure-failed[código] ou unreconciled-after-ensure
4 Auditoria incompleta: uma leitura falhou por outro motivo que não "não encontrado" (permissão, login expirado, extensão az em falta, resposta inválida). Não é drift

O drift (3) tem prioridade sobre 4 e ambos são listados. A exceção é o Action
Group: se não puder ser lido, a execução termina em 4 sem verificar nenhum alerta.
--environments lista no log os alertas que ficaram de fora. O budget ignora
environments: para fazer rollout de ensure sem ele na pipeline, usar também
healthAlertsOnly=true, que deixa de fora os alertas de podcast e radio recognition.

scripts/ensure-podcast-sync-alerts.js (podcast-sync.json e
radio-recognition.json) usa os mesmos códigos e o mesmo --environments, que aceita
shared sem efeito porque a pipeline passa a mesma lista aos dois scripts:

Estado (30/10/2026): a pipeline continua vermelha por três motivos independentes:

  1. Usa azure-prod-connection, cujo SP audissey-sp-prod não lê o Action Group nem
    os recursos dev/staging. Os passos health, podcast e radio recognition terminam em
    4.
  2. Com um login que leia os recursos, faltam 10 regras em dev e staging: 8 de
    podcast-sync e 2 de radio-recognition. O resultado é 3 até correr ensure.
  3. O budget audissey-monthly-operational-ceiling não existe.

Antes de correr ensure nestas regras: nos 14 dias até 30/10/2026 nenhuma das
métricas apareceu em customMetrics, por isso as regras não disparariam em dev nem
staging:

Custo se forem criadas: alerta de logs a 5 minutos custa 1,32 €/mês por regra (preços
de retalho Azure, West Europe, 30/10/2026): 13,20 €/mês por 10 regras, 19,80 €/mês com
prod.

Verificar: node --test scripts/ensure-health-metric-alerts.test.js scripts/ensure-podcast-sync-alerts.test.js
e, com um login que leia os resource groups,

node scripts/ensure-health-metric-alerts.js --mode audit --config deploy/config/alerts/healthchecks.json --environments dev,staging,shared
node scripts/ensure-podcast-sync-alerts.js --mode audit --config deploy/config/alerts/podcast-sync.json --environments dev,staging
node scripts/ensure-podcast-sync-alerts.js --mode audit --config deploy/config/alerts/radio-recognition.json --environments dev,staging

Segurança

Princípios

  1. Least Privilege: Cada pipeline só acede ao ambiente necessário
  2. Secret Rotation: Rodar secrets de prod mensalmente
  3. Audit Logs: Todos os deployments são auditados
  4. Network Isolation: Ambientes com VNets separadas (opcional)

Container Security (non-root)

All containers run as non-root users (Jul 2025):

Service Dockerfile(s) User Notes
API Dockerfile, Dockerfile.acr node (uid 1000) Built-in Node.js Alpine image user
Worker Dockerfile.worker, Dockerfile.worker.deploy node (uid 1000) Built-in Node.js Alpine image user
ML Service apps/ml-service/Dockerfile, Dockerfile.acr mluser (system user) Created via useradd -r -s /bin/false mluser
Admin Portal Dockerfile.admin, Dockerfile.admin.acr nginx (worker) nginx master runs as root for port 80 binding; worker processes run as nginx user — acceptable for Container Apps

Ownership is set via chown -R <user>:<group> /app before the USER directive. This prevents privilege escalation inside containers.

Service Connections

Criar service connections separadas:

Não usar "Grant access to all pipelines" - autorizar explicitamente.

Autenticação: Workload Identity Federation (desde 2026-10-21)

As três service connections ARM usam WorkloadIdentityFederation com o issuer do Microsoft Entra.
Não têm client secret, por isso não há nada a expirar. Os secrets antigos, que expiravam a
2026-12-27, foram removidos das app registrations.

Service connection Endpoint id App registration (client id)
azure-dev-connection f50757cc… audissey-sp-dev (8dee7604-…)
azure-staging-connection 6c15c991… audissey-sp-staging (9c4113c3-…)
azure-prod-connection bfd8d432… audissey-sp-prod (8f088c75-…)

Criar ou converter uma connection manual (creationMode: Manual). Estas connections não têm o
botão Convert do ADO nem o revert de 7 dias, porque o ADO não gere a app. Converter assim:

  1. Criar primeiro a federated credential. Se ela não existir, o ADO rejeita a conversão com
    no valid configuration exists e não altera nada, por isso esta ordem evita indisponibilidade.
    Para obter os segmentos b64url:
    python3 -c "import uuid,base64;print(base64.urlsafe_b64encode(uuid.UUID('<guid>').bytes_le).decode().rstrip('='))"
    (orgId = instanceId de https://dev.azure.com/tiagocosta0526/_apis/connectionData):
    az ad app federated-credential create --id <appId> --parameters @fic.json
  2. Esperar ~1 min e fazer
    PUT https://dev.azure.com/tiagocosta0526/Audissey/_apis/serviceendpoint/endpoints/<id>?operation=ConvertAuthenticationScheme&api-version=7.1
    (az rest --resource 499b84ac-1321-427f-aa17-267ca6975798). O body é o endpoint atual com
    authorization = {scheme: WorkloadIdentityFederation, parameters: {serviceprincipalid, tenantid}}.
    A resposta traz workloadIdentityFederationIssuer/Subject, que têm de coincidir com a credential.
  3. Rollback: az ad app credential reset --id <appId> --append e, na UI, voltar a connection a Secret.

O scripts/setup-service-connections.sh ainda cria
connections com secret. Depois de o correr, converter com os passos acima.

Removidas a 2026-10-21 (sem uso em YAML nem no histórico de execução):

Docker registry connections (não são ARM):

Shared CAE RBAC requirement

As Container Apps dos ambientes (Admin, Worker, ML) vivem nas RGs dos respetivos ambientes (audissey-dev-rg, audissey-staging-rg, audissey-prod-rg) mas fazem join em CAEs que vivem em audissey-shared-rg. Para que os pipelines de release consigam fazer az containerapp create/update, cada service connection SP precisa de Contributor (ou pelo menos Microsoft.App/managedEnvironments/join/action) no CAE:

CAE Resource Group SPs que precisam de acesso
audissey-cae-public audissey-shared-rg azure-dev-connection, azure-staging-connection, azure-prod-connection

Note (Mar 2025): audissey-cae-public now hosts Admin Portal CAs and ML Service CAs (dev/staging). All service connection SPs need Contributor on this CAE.

Sem esta permissão, o pipeline falha com LinkedAuthorizationFailed ao tentar associar a Container App ao managed environment.

Troubleshooting

Pipeline não executa

  1. Verificar branch trigger no pipeline
  2. Verificar approvals pendentes (prod)
  3. Verificar service connection autorizada

Variáveis não disponíveis

  1. Variable Group linkado ao pipeline?
  2. Key Vault com permissões corretas?
  3. Service principal tem acesso ao Key Vault?

Deploy falha

  1. Verificar logs da Container App
  2. Verificar imagem existe no ACR
  3. Verificar variáveis de ambiente configuradas
  4. Testar container localmente com mesmas variáveis

Fase 3: auditoria operacional e capacidade

O bootstrap mínimo da API inclui agora, em dev, staging e prod, os três
placeholders de Variable Group AZURE_KEY_VAULT_ENABLED,
AZURE_KEY_VAULT_URL e CORS_ORIGINS. A URL tem de existir também quando o
Key Vault está desativado; o valor pode apontar para o vault do ambiente, mas
nunca deve ser hardcoded no desired-state. Admin e ML mantêm os respetivos
placeholders de porta/API documentados acima.

Os seguintes pipelines complementam a auditoria diária de appsettings/RBAC:

O procedimento completo, formato de input manual Atlas/fornecedores, guards de
sizing e política dev on-demand está em
docs/operations/PHASE3_COST_CAPACITY_AND_LAUNCH.md.
O runbook RPO 24h/RTO 4h e o ensaio mensal estão em
docs/operations/DISASTER_RECOVERY.md.

Nenhum destes desired-states prova que prod existe ou está pronto. Antes do
lançamento, escolher explicitamente o ambiente, obter um snapshot real, ensaiar
restauro isolado, testar entrega do Action Group e fechar os gaps de jobs,
queue delay e backup. Não automatizar pausa/scale de dev antes de provar que
todas as dependências são exclusivas desse ambiente.

Snapshot read-only de 25/09/2026: dev e staging devolveram os quatro serviços;
os quatro nomes prod esperados devolveram resource-not-found. ActualCost/
PreTaxCost de 30 dias foi 417,45 EUR, incluindo 216,74 EUR de Atlas marketplace;
fornecedores externos, impostos/ajustes e denominadores de uso não estavam
disponíveis. A evidência redigida e limites estão em
docs/operations/evidence/PHASE3_SNAPSHOT_2026-09-25.{json,md}. Não tratar este
valor pré-imposto como custo agregado final nem somar novamente a fatura Atlas
quando ela corresponder à cobrança marketplace já presente.

Website público (audissey.io)

Referências