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 nopackage.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 demongodb-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, iOScom.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 aceitastagingOnly=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:
- dev: Desenvolvimento contínuo, deploys automáticos
- staging: Ambiente de testes pré-produção, deploys automáticos
- prod: Produção, requer approval manual
Programações manuais
As programações guardadas usam as collections MongoDB saved_programmings edaily_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 ecollectionSortOrder, 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 migration20261024_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-devno CAEaudissey-cae-api-dev(audissey-dev-rg, UK South, externo/consumption),audissey-api-stagingno CAEaudissey-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.ymlapontam para a Container App esperadaaudissey-apiemaudissey-prod-rg. A recolha read-only de 25/09/2026 devolveuresource-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-extemaudissey-staging-rg(North Europe, VNet-injected na subnetaudissey-vnet-workers-ne/containerapps-ext, domíniohappypond-5a13597d.northeurope.azurecontainerapps.io). O CAE internoaudissey-cae-northeu(domíniosalmonglacier-eb702166) já não existe. Cada Container App vive no RG do seu ambiente (audissey-dev-rg,audissey-staging-rg);audissey-prod-rgnão existe (verificado 21/10/2026).- ML Service corre em Azure Container Apps no CAE público
audissey-cae-publicemaudissey-shared-rg(North Europe, externo). Migrado do CAE internoaudissey-cae-northeuem Mar 2025 para resolver NXDOMAIN — API Container Apps (UK South) não conseguiam resolver DNS privado do CAE interno (North Europe). Protegido comX-Internal-KeyAPI key middleware. Cada Container App vive no RG do seu ambiente.- Admin Portal corre em Azure Container Apps no CAE público
audissey-cae-publicemaudissey-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(CAEaudissey-cae-northeu-ext,internal: false, ingress público). Container Apps acedem a Redis/KV via Private Endpoints. Private DNS zones estão consolidadas noaudissey-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 viaX-Internal-Keyheader middleware (FastAPI). Chave partilhada entre API/Worker (callers) e ML (server) via Variable GroupML_INTERNAL_API_KEYe Container App secretmlinternalkey.- 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:
- Automática, depois de cada arranque da API, quando
DATA_MIGRATIONS_AUTO_RUN=true(Variable Group, por ambiente;MigrationAutoRunService). Omain.tsarma-a depois dolisten, por isso
os scripts que só criam um application context nunca correm migrations.
Corre em segundo plano 60 s depois do arranque, sem atrasar o arranque nem
os probes, e nunca derruba a API. Corre por ordem as versões pendentes
exceto as marcadasmanuale as que falharam na última tentativa (não
repete a mesma falha a cada arranque) e continua com as seguintes; pára no
primeiro erro. Com o lock ocupado, tenta de novo quando a lease da outra
execução acaba (até 3 vezes). Desde 15/03/2026 (commitbcddbc08) as
migrations eram só manuais: o arranque falhava com o throttling do Cosmos
(16500/429), que deixou de existir com o Atlas. - Manual: Admin Portal → Data Management.
- "Executar Migrações Pendentes" (
POST /admin/data-management/migrations/run
sem body) corre as pendentes não marcadasmanual, incluindo as que
falharam (é a forma de as repetir), e devolve as manuais emwaitingManual. - O botão "Executar" de cada linha pendente pede confirmação (mostra o
motivo das manuais) e envia{"only": ["<versão>"]}: só essa corre,
manual ou não. É a única forma de correr uma manual. - O estado vem de
GET /admin/data-management/migrations, compendingManual(pendentes manuais e o motivo) eautoRun(flag, atraso e
resumo da última execução automática que aplicou ou falhou alguma
coisa).
- "Executar Migrações Pendentes" (
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:
- Criar
DATA_MIGRATIONS_AUTO_RUNnos três Variable Groups (audissey-dev,audissey-staging,audissey-prod) antes do primeiro deploy com esta
alteração: oscripts/ensure-containerapp-env.jsfalha 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 demain) também
falha nos jobs de dev e staging quando isto chegar amainsem a
variável. Começar afalse. - Correr à mão, uma vez, as pendentes acumuladas desse ambiente (ver o estado
no admin portal; usaronlypara separar as manuais). - Pôr a variável a
truee 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 60se,
depois, o resumoAutomatic data migrations: N applied, …. - 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.
_migrationsguarda uma linha por versão (índice únicoversionemdeploy/config/indexes/indexes.json) com o resultado da última tentativa:status,error,attempts,lastError,appliedAt,durationMsetrigger(autooumanual; ausente nas linhas anteriores a out/2026).- Só
status: 'success'conta como aplicada. Uma versão emstatus: 'error'
volta a pendente e corre outra vez na execução manual seguinte (a
automática deixa-a para uma execução manual, para não repetir a mesma falha
a cada arranque); a linha é
atualizada no lugar (upsert porversion), nunca duplicada. Depois de um
retry com sucesso,errorsai, o erro anterior fica emlastErroreattemptsconta as tentativas (linhas antigas semattemptscontam como 1). - A execução pára no primeiro erro; as versões seguintes ficam pendentes.
- Até out/2026 um erro era permanente e só se resolvia editando
_migrationsà
mão. Já não é preciso: corrigir a causa e executar de novo. Estado lido a
24/10/2026: dev tem20250615_002e20250615_005emerrore staging tem20250615_001emerror(throttling Cosmos 16500/429, anterior ao Atlas).
Voltam a correr na próxima execução, seguidas das versões ainda sem registo. - Cada
up()tem de ser seguro para correr outra vez, também depois de uma
execução parcial (filtrar documentos já migrados, verificar collection/índice
antes dedrop). As três migrations abaixo deixaram de ser inseguras num
retry; cada uma tem um spec com umConnectionfalso que corre uma execução
parcial seguida de um retry (apps/api/src/app/admin/migrations/*.spec.ts).
As restantes foram auditadas a 24/10/2026 e já eram seguras.20260804_011: comparapublish_date_1epodcast_id_1_guid_1(ou, se
não houver índice com esse nome, o índice com a mesma chave) com a forma
alvo deindexes.json(chave,unique,sparse, TTL,partialFilterExpression, como oscripts/ensure-atlas-indexes.js) e
cria o índice se faltar ou tiver outra forma. Um retry depois de uma falha
entredropIndexecreateIndexjá recria o índice em vez de o saltar.
Diferença face à versão anterior: um índice em falta passa a ser criado;
se houver episódios duplicados (podcast_id+guidstring), ocreateIndexfalha com E11000 e a 011 (e as versões seguintes) fica em
erro até se removerem os duplicados. Em dev e staging os dois índices já
existem com estes nomes (opções não verificáveis pelo MCP, 24/10/2026).
A troca do cronapi-sync-episodes-dailypara o dispatcher (cada minuto,enabled: true) corre no fim, depois dos índices, e só uma vez: filtra porlabeldiferente da do dispatcher. O Admin Portal só enviacronExpressioneenablede as bases novas já nascem com essa label, por
isso um retry ou uma execução posterior não volta a ligar um cron que um
admin desligou ($setOnInsertnão serve: oupdateOnenão faz upsert).
Nota: oPATCH /admin/cron-configs/:jobNameaceita o body inteiro em$set, por isso um pedido manual comlabeldesfaz este marcador. O
scheduler lê os crons no arranque, por isso a troca só vale depois do
próximo restart da API.
Até out/2026 a 011 procurava a collectioncronconfigs(o schemaCronConfigusacron_configs) e a troca do cron nunca acontecia. A
24/10/2026, dev e staging ainda têmapi-sync-episodes-dailyem0 6 * * *("Sync Episodes (diário)"): o dispatcher corre uma vez por dia
e enfileira no máximoPODCAST_SYNC_DISPATCH_LIMIT(500) podcasts. Na
próxima execução da 011 (seguida de restart da API), o dispatcher passa a
correr a cada minuto e cada podcast sincroniza ao ritmo dePODCAST_SYNC_REGULAR_INTERVAL_MINUTES(default no código: 1440, uma vez
por dia) e os favoritos ao dePODCAST_SYNC_FAVORITE_INTERVAL_MINUTES
(default: 60).
Antes de correr a 011: a 25/10/2026 os Variable Groupsaudissey-dev,audissey-stagingeaudissey-prodtêm estas variáveis a60e15, e as Container Apps da API de dev e staging também. Esses
valores sobrepõem-se aos defaults. Com o dispatcher a cada minuto,
seriam cerca de 173 000 jobs/dia em staging (7 196 podcasts elegíveis),
no limite dePODCAST_SYNC_RATE_PER_MINUTE(120). Oscripts/calculate-podcast-sync-capacity.jsdá 156 feeds/min e 8
réplicas para um SLO de 60 min, contra 6,5 feeds/min e 1 réplica para
1440 min. Pôr os três grupos a1440/60e fazer deploy da API antes da
primeira execução da 011.20260830_012: num retry, se o utilizador já tem a programaçãomigrated_from_legacy, só são cancelados os schedules já copiados para ositems(mesmocontent_type/content_ide mesma hora local de início e
fim; cada item cobre no máximo um schedule e os itens dos schedules que
uma execução anterior já cancelou não contam). Os restantes, por exemplo
criados peloPOST /schedules(deprecated, ainda ativo) depois da primeira
passagem, ficam como schedules standalone ativos e a programação não é
alterada, porque o utilizador pode já a ter editado. Se o utilizador mudou
de timezone entre execuções, os schedules dele ficam ativos (sem perda).
O número de schedules deixados ativos vai para o log da API. Ainda
pendentes a 24/10/2026: 0 em dev, 7 em staging; nenhuma programaçãomigrated_from_legacyem nenhum dos dois.20250615_003: percorre lotes de 1000 por ordem de_idaté não haver
mais, por isso uma execução cobre a collection toda (antes parava aos
50 000 e registava sucesso). Cada escrita filtra tambémexpires_at: null,
para não apagar umexpires_atque o worker escreveu entretanto. Ainda semexpires_ata 24/10/2026: 0 em dev, 391 em staging.20250327_006_backfill-radio-geo-cityexiste mas não está registada.
- Custo Azure: sem recursos nem índices novos e
_migrationstem uma linha
por migration. O20250615_003faz mais idas à base (lotes de 1000) para os
mesmos documentos. A20260804_011liga o dispatcher de episódios a cada
minuto. Com 1440/60 em staging: cerca de 7 200 syncs/dia mais cerca de
1 400 de favoritos (58 favoritos de podcasts), contra cerca de 1 000/dia
hoje (lotes de 500). São mais execuções do worker (Container Apps em
consumo, KEDA 2/2/3 chega) e mais operações no Atlas, mas a carga média é
cerca de 6,5 feeds/min, longe do limite de 120/min. Os primeiros cerca de
7 200 podcasts em atraso drenam em cerca de 1 hora ao limite de 120/min.
Com 60/15 seria cerca de 20× isto; ver a nota da 011 acima. Valor em euros
não estimado.
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.jsresolve os placeholders e reconcilia as Container Apps;ensure-appsettings.jspermanece 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 |
MONGODB_DB_NAME) |
||||
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, adicionarenv: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 viaensure-containerapp-env.js, eAZURE_KEY_VAULT_URLcontinua 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:
audissey-kv-devaudissey-kv-stagingaudissey-kv-production
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:
- No Variable Group, clicar + Link secrets from an Azure key vault
- Selecionar subscription e Key Vault
- 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:
dev(sem approvals)staging(sem approvals)prod(com approvals)
2. Configurar Approvals para Prod
No environment prod:
- Click ... → Approvals and checks
- Add check → Approvals
- Adicionar approvers (e.g., tech leads, product owners)
- 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
- API (dev/staging): Azure Container Apps (CAE externo por ambiente, sem VNet, porta 3000,
minReplicas=1, managed identity) - API (prod desejada):
audissey-apiemaudissey-prod-rg, CAEaudissey-cae-api-prod, porta 3000,minReplicas=1,maxReplicas=5. O release tem stage prod com approval; a recolha de 25/09/2026 não encontrou o recurso. - Admin Portal: Azure Container Apps (public CAE
audissey-cae-public, nginx, port 80, minReplicas=0/dev-staging, minReplicas=1/prod) - Worker: Azure Container Apps (KEDA metrics-api scaler in dev/staging, minReplicas=0; production rollout unverified)
- ML Service: Azure Container Apps (HTTP scaler, minReplicas=0, maxReplicas=2, public CAE
audissey-cae-public, API key middleware)
Atlas URI guardrail (Sep 2026): API release uses
MONGO_URIfrom the environment Variable Group for bothensure-containerapp-env.jsand 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. ExistingsecretRefmappings 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.ymlcontém dev, staging e prod e usaaz containerapp create/update; isto expressa intenção de deploy, não existência. Bootstrap usascripts/ensure-containerapp-env.jsedeploy/config/appsettings/api/{dev,staging,prod}.jsonem 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
- Dockerfile uses
dumb-initas PID 1 to forward SIGTERM from Container Apps to the Node.js process (matches Worker pattern). - NestJS has
app.enableShutdownHooks()inmain.tssoOnModuleDestroylifecycle hooks fire on SIGTERM, ensuring BullMQ Redis connections close gracefully. - CORS defaults in
main.tspoint to Container Apps domains (*.azurecontainerapps.io); actual origins are always overridden by theCORS_ORIGINSenv var from Variable Groups.
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.ioandaudissey-admin-staging.grayisland-44f77667.northeurope.azurecontainerapps.io.
Private Endpoints (current layout)
Staging and prod environments have PEs for Key Vault and Redis:
- dev-rg: All PEs deleted (Jul 2025) — dev Container Apps use public endpoints; the PEs were in
audissey-vnet-devwhich no Container App used. - staging-rg:
audissey-pe-kv-staging,pe-staging-redis(Redis PE → 10.20.2.7). Cosmos PE (audissey-pe-cosmos-staging) is stale — to be decommissioned after Atlas migration rollback window. - prod-rg:
audissey-pe-kv-prod,pe-prod-redis(Redis PE → 10.30.2.7). Cosmos PE (audissey-pe-cosmos-prod) is stale — to be decommissioned after Atlas migration rollback window.
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-weandaudissey-cosmospreviously hadpublicNetworkAccess: Disabled. All access was via Private Endpoints.
Networking fix (Jul 2025): API Container Apps (UK South, sem VNet) não acedem a PEs.publicNetworkAccessfoi habilitado em Redis.Cosmos DB ipRules incluiCosmosDB decommissioned Jul 2026 — see Atlas migration note above.0.0.0.0(Azure datacenter access) e o IP de desenvolvimento.
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-weis 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 callduplicate()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-devandpe-workers-kv-prodhave been deleted fromaudissey-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 withnode 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:
- Variable Groups are the source of truth:
dev,staging, andprod. - API desired-state includes
${BULL_QUEUE_PREFIX}in all three environments. - Worker release injects the same value into the Container App and passes it to
the KEDA reconciler. deploy/config/worker/keda-scaling.jsonis the source of truth for the queue
list. It must include every Worker@Processor()queue; the focused KEDA test
enforces this equality.- Never rely on the BullMQ
bulldefault or the MongoDB database-name fallback
in a deployed environment.
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):
- The API scheduler enqueues jobs through BullMQ.
- One KEDA
metrics-apirule per environment polls authenticatedGET /api/health/worker-scaling. The endpoint reuses the API's shared BullMQ
Redis connection; it creates no Redis client or blocking Worker. - One atomic Lua read covers every configured queue's
waitandactivekeys.
The metric is the maximum list length, matching the previous maximum of
independent Redis rules. Set/hash counts retain the legacy scaler semantics;
unsupported types fail. A wait-to-active transition cannot produce a false
empty reading. Target 1 and activation 0 preserve wake-up from zero and keep
active jobs visible. Delayed/paused lists retain the previous behavior. - Redis initialization/read timeout (3 seconds), unavailable Redis or invalid
results return HTTP 503, never zero. HTTP responses useCache-Control: no-store. - Authentication reuses
WORKER_API_KEYviaX-Worker-Api-Key. The rule references
the existing Container App secretworkerapikey; no Redis access key is given
to KEDA.API_BASE_URLandBULL_QUEUE_PREFIXcome from each Variable Group.
No new VG variable is required. The URL must point to that environment's
deployed API base (including/api); prefix and queue coverage are checked.
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 exactMicrosoft.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 underresourceScopedRoles 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 ID95fabf15-2d2f-4ae7-8d4f-0a41e5fee3cf e app ID8dee7604-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 andstaging:icy-collector:active; bull:icy-collector:* is drift.
ICY collector cleanup, recovery, and observability
- Every ICY Worker cycle performs a bounded Redis
SSCAN(default maximum
2,000 members), reads listener counters in a pipeline, and removes members
whoseradio:<id>:listenerscounter is absent, invalid, zero, or negative.
Only positive counters are queried in MongoDB and collected. - Each adapter call has a hard deadline in addition to its HTTP/socket timeout.
A radio is retried twice, then recorded as failed; timeouts are counted
separately. The run stops starting new batches afterICY_JOB_TIME_BUDGET_MS(default 300,000 ms). - The BullMQ Worker uses a 10-minute renewable lock, checks for stalled jobs
every 30 seconds, and allows one stalled recovery. A second stall fails the
job deterministically. The API scheduler evicts unlocked waiting/active ICY
jobs older than twice the configured job budget and resumes scheduling. - Admin Monitor de Filas includes
icy-collectorwaiting, active, completed,
failed, and delayed counts. Execution history exposes duration, failures,
timeout totals, stale-listener removals, and budget skips. Worker logs emitICY_COLLECTOR_JOBandICY_COLLECTOR_RECOVERYevents without URLs,
credentials, or job payloads.
Staging rollout checks:
- Deploy API, Worker, and Admin; confirm the prefix and KEDA lists with the
commands above. - 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. - Start authenticated Antena 1 playback. Within one configured collector cycle,
confirm REST and SSE return current RTP metadata with a fresh timestamp rather
thansource: lastKnown. - Observe multiple cycles: no permanently waiting/active ICY jobs, stale
active-radioscardinality 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 |
- Startup probe allows up to 93s (
initialDelay 3s + 30 × 3s) for NestJS and all BullMQ consumers to initialize. - Liveness probe checks every 30s. A consumer that cannot recover for 90s with Redis accessible makes the endpoint return 503; three failed probes restart the replica. A Redis outage alone does not trigger repeated restarts.
- Readiness probe checks every 10s that all registered consumers and their existing Redis connections are ready. HTTP responsiveness alone is insufficient. The supervisor samples health every 30s with a bounded deadline and attempts stopped-loop recovery with backoff.
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. SeeNOW_PLAYING_CONTINUITY.md for operation
and fault verification. No new environment variables are required.
Endpoints (from apps/worker/src/app/health/health.controller.ts):
GET /api/health— Returns{status, service: "worker", uptime, timestamp, ready, alive, redisReady, consumers}; HTTP 503 when consumers/dependency are unavailable.GET /api/health/alive— Returns{status: "alive", timestamp}or HTTP 503 withrestart_requiredafter unrecoverable consumer failure or stale supervision.
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 hostname sem
.internal(ex.:audissey-worker-staging.<CAE_DEFAULT_DOMAIN>) não resolve publicamente (NXDOMAIN). Deve ser resolvido via Private DNS na VNet. - O hostname com
.internal(ex.:audissey-worker-staging.internal.<CAE_DEFAULT_DOMAIN>) funciona para chamadas dentro do próprio CAE, mas pode devolverAzure Container App - Unavailable (404)quando usado através do ILB/static IP.
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:
- Zone:
<CAE_DEFAULT_DOMAIN>(ex. histórico:salmonglacier-eb702166.northeurope.azurecontainerapps.io, do CAE interno já removido) - Record: wildcard
*→<CAE_STATIC_IP>(ex.:10.252.1.150)
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:
audissey-ml-dev(audissey-dev-rg)audissey-ml-staging(audissey-staging-rg)audissey-ml-prod(audissey-prod-rg)
How it works:
- ML Service exposes
/healthand/docson port 8000 (uvicorn + FastAPI) - Container Apps HTTP scaler monitors incoming request rate
- When concurrentRequests > 10 per replica → scale up (max 2 replicas)
- After 5 minutes with no requests → scale to zero
- Cold start: ~60-90s (torch + transformers model loading)
Secrets (managed via az containerapp secret set in pipeline):
mongouri→ MONGO_URI (MongoDB Atlas connection string)appinsightsconn→ APPLICATIONINSIGHTS_CONNECTION_STRINGllmapikey→ LLM_API_KEY (OpenAI/LLM provider key)mlinternalkey→ ML_INTERNAL_API_KEY (API key forX-Internal-Keyheader validation)
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 byml-*-restartalerts.
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
- MongoDB Atlas: Single database per environment (migrated from CosmosDB Jul 2026). Connection via
MONGO_URIenv var. Clusters:audissey-dev(M10, West Europe),audissey-staging(M10, West Europe),audissey-prod(M10, UK South). Public endpoint + IP allowlist (no PrivateLink). - Redis Cache: Instância separada (
audissey-redis-we, Standard C0)
Container Registry
- ACR: Partilhado entre ambientes (
audissey.azurecr.io)
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 eazure-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 causaInvalid 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 decreateIndex/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 erole 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 deuserevents: {source:1,eventType:1,occurredAt:-1} e unique{userId:1,eventId:1} compartialFilterExpression: {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 409USER_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 sempreaudit, 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:
WORKER_URL/WORKER_API_URLnos VGsaudissey-dev/audissey-staging
apontavam para o CAE removido (salmonglacier-eb702166, NXDOMAIN). O valor
correto éhttps://audissey-worker-<env>.happypond-5a13597d.northeurope.azurecontainerapps.io/api.
O release da API escondia o problema: aplica o VG e depois reescreveWORKER_URLcom o FQDN vivo do worker. Se o CAE do worker mudar, atualizar os VGs.- Prod não está provisionado (
audissey-prod-rgnão existe). O job Prod só
corre com o parâmetroauditProd=true(defaultfalse); enquanto isso, o jobProdSkippedemite um warning em cada execução. Quando prod existir, mudar o
default paratruee completar o VGaudissey-prod(faltam pelo menosNODE_ENV,MONGODB_DB_NAME,ML_TRAINING_JOB_RESOURCE_GROUP; os URLs de
worker/ML apontam para CAEs inexistentes). - Scopes fora do RG do ambiente. O SP de release de cada ambiente só tem
Contributorno seu RG. Para auditar workspaces Log Analytics noutros RGs usa o
role customAudissey RBAC Auditor
(deploy/config/rbac/roles/audissey-rbac-auditor.json): lê role assignments,
role definitions e metadados do workspace, mas não consulta logs (Reader
dariaworkspaces/query/read). As atribuições esperadas estão declaradas emresourceScopedRolese são também auditadas: dev SP emmanaged-audissey-insights-dev-ws,audissey-log-ws,audissey-law-workers-ne;
staging SP emmanaged-audissey-insights-staging-ws,audissey-log-ws. - Allowlists partilhadas. Recursos partilhados por vários ambientes (Redis,
ACRaudissey,audissey-log-ws,audissey-law-workers-ne) têm a mesma
allowlist em todas as configs que os auditam; caso contrário cada ambiente
marca as identidades do outro como inesperadas. Role assignments órfãos
(identidades apagadas) aparecem comounexpectede devem ser removidos. - Falhas de leitura.
audit-rbac.jsjá não termina com stack trace no
primeiro scope ilegível: audita os restantes, listaerrorscom o código Azure
(AuthorizationFailed,ResourceNotFound, …) e sai com4(ou3se também
houver drift). Não há modo tolerante: um scope ilegível continua a falhar o audit.
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 jobaudissey-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 é:
AZURE_KEY_VAULT_ENABLED=${AZURE_KEY_VAULT_ENABLED}AZURE_KEY_VAULT_URL=${AZURE_KEY_VAULT_URL}CORS_ORIGINS=${CORS_ORIGINS}
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:
WORKER_API_KEY=<secret>
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:
APPLICATIONINSIGHTS_CONNECTION_STRING=<env-connection-string>
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):
ML_SERVICE_URL=${ML_SERVICE_URL}— URL do serviço ML (necessário para o dashboard de monitorização mostrar o ML Service)WORKER_URL=${WORKER_URL}— URL do Worker Container AppWORKER_API_URL=${WORKER_API_URL}— URL da API do WorkerSENDER_API_TOKEN=${SENDER_API_TOKEN}— token Sender (secret) para emails transacionais (login sem palavra-passe e alertas); ver nota «Email» abaixo
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).
- Confirmar pelo Log Analytics (Container Apps)
- KQL (exemplo):
ContainerAppConsoleLogs_CL | where TimeGenerated > ago(24h) | where ContainerAppName_s == 'audissey-worker-staging' | where Log_s has 'reportToApi' | project TimeGenerated, Log_s | order by TimeGenerated desc
- Se aparecer algo como
reportToApi attempt 1/3 failed: 401 Unauthorized, então é mismatch de key.
- Confirmar wiring (sem expor segredos)
- API (Container App): confirmar que o env var usa
secretRef, sem ler o valor:az containerapp show -g audissey-staging-rg -n audissey-api-staging --query "properties.template.containers[0].env[?name=='WORKER_API_KEY']" -o jsonc
- Worker (Container App): confirmar que o env var está a usar secret ref:
az containerapp show -g audissey-staging-rg -n audissey-worker-staging --query "properties.template.containers[0].env[?name=='WORKER_API_KEY']" -o jsonc
- Correção
- Preferido: garantir que o mesmo
WORKER_API_KEYexiste no Variable Group do ambiente e correr os pipelines de release (API + Worker) que aplicam settings + secrets. - Não copiar o secret de um recurso nem colocá-lo na linha de comandos. Corrigir o
VG e voltar a correr os releases API/Worker; depois confirmar nova revisão esecretRefsem imprimir o valor.
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/*.jsonsão consumidos pelo mesmoensure-containerapp-env.jsno 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_KEYeML_INTERNAL_API_KEYtê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, defaultfalse) nos três VGs. Vai para o ML Service comoRECOMMENDATION_WEIGHT_BY_SIGNAL(deploy/config/appsettings/ml/*.json) e para o job de treino (recommendationWeightBySignalemdeploy/config/ml-training-job/*.json, aplicado e auditado porensure-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: verdocs/setup/ML_SERVICE_SETUP.md→ "Sinal de tempo de escuta no treino". Verificar:az containerapp show/az containerapp job show→ envRECOMMENDATION_WEIGHT_BY_SIGNAL; no treino, o logLightFM trained … weighted by signal=….
Worker (Out 2026):
ML_TRAINING_BATCH_SIZEeML_TRAINING_BATCH_DELAY_MS(snapshotml_training_data) passam a estar emdeploy/config/appsettings/worker/*.jsoncomo placeholders dos VGs (1000/100nos 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>→ envML_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 partilhadoworker-shared/src/services/sender-email.client.ts.SENDGRID_API_KEYfoi substituída porSENDER_API_TOKEN(corte direto, sem fallback).SENDER_API_TOKENé secret nos VGsaudissey-dev(id=2),audissey-staging(id=3) eaudissey-prod(id=4) e temenv:mapping explícito emazure-pipelines-api-release.yml,azure-pipelines-worker-release.ymleazure-pipelines-appsettings-audit.yml. O Worker passa também a receberSENDER_API_TOKEN,ALERT_FROM_EMAILeALERT_RECIPIENTS(deploy/config/appsettings/worker/*.json) para enviar o digest de alterações de grelhas. AdicionarSENDER_API_TOKENaos três VGs, eALERT_RECIPIENTSao VGaudissey-prod(hoje não existe), antes do release:ensure-containerapp-env.jsrecusa placeholders sem valor e o release falha. A variável antigaSENDGRID_API_KEYnã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 showda API e do Worker do ambiente (prod:audissey-api/audissey-worker) → envSENDER_API_TOKENpresente (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, garanteAcrPull 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 eWORKER_CONTAINER_APP_NAME acima aos VGs audissey-dev, audissey-staging eaudissey-prod. Os desired-state sãodeploy/config/ml-training-job/{dev,staging,prod}.json edeploy/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 comContainerAppsJobOperationInProgress. 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 aplicaresourceScopedRoles: exige Container Apps Jobs Operator exatamente no job eAcrPull exatamente no ACR e acusa uma atribuição equivalente mais ampla.
Ordem de rollout: (1) aplicar os índices Atlas, sobretudo o unique+sparse deml_training_runs.active_key e os compostos de correlação dejob_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 mesmorun_id; (6) confirmar health/recommend em duas réplicas e reload deactiveVersion. 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} econtent_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, incluindoMONGO_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 paraPOST /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 ouremoving: 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 paraready 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 dodispatch_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 runqueued pristine, sem orchestrator_job_id nem prova de vínculo anterior, pode
ser criado como perda simples. Se já existia vínculo — incluindo replacementready 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> emidempotency_key/idempotency_keys) ficam com esse job enquanto ele não estiverfailed nem passar a lease de 6 h; depois disso exigem recovery explícito (verdocs/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 conservartrainingOwner: 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 aadmin. 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 ecom.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 emusers.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):
- Adicionar
BRAVE_SEARCH_API_KEYaos VGsaudissey-dev(id=2),audissey-staging(id=3),audissey-prod(id=4) como secret. - Adicionar
BRAVE_SEARCH_MONTHLY_BUDGET=1800aos três VGs (não-secret). - O pipeline
azure-pipelines-worker-release.ymlmapeiaBRAVE_SEARCH_API_KEYno blocoenv:e fazaz containerapp secret set+az containerapp update --set-env-varsautomaticamente — mesmo padrão daMISTRAL_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:
- Atomicidade KEDA-safe: o contador
brave_search_usage:YYYY-MMusafindOneAndUpdate({ $inc }, { upsert: true })— múltiplas réplicas do worker nunca conseguem colectivamente ultrapassar o budget. - Rate gate ≥ 1.25 s/req serializado por
inFlightPromise chain dentro doBraveSearchClient— deixa headroom sobre o limite de 1 req/s da free plan. - Budget exhausted / upstream fail →
search()devolvenulle o pipeline de discovery cai para a fase seguinte. Nunca há exception a propagar para o job do BullMQ.
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 porPUPPETEER_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) erenderPage 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 denode -, 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, porqueaz containerapp logs show --tail só cobre linhas recentes e 72% dos renders
acontecem entre as 03h e as 04h UTC. Em scraping_logs, procurarstatus: "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 temMONGO_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 mesmochromium~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 emDockerfile.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 emv3.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 dedl-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:
scripts/check-renovate-worker-report.js: o Renovate sai com 0 e sem PR se um Dockerfile
mudar de nome, um regex deixar de apanhar a linha, um lookup não devolver nada, o push ou
o PR forem recusados, ou alguém fizer commit no branch (pr-edited). O script lê o
relatório da run e falha nestes casos e em qualquer warning do Renovate.- Branch do Alpine: compara
node:24-alpinecomnode:24-alpine3.24. O Renovate mantém o
sufixo-alpine3.24e nunca propõe o 3.25, e essa branch deixa de receber Chromium. O
passo falha até se mudar o tag à mão nos três ficheiros.
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 repoAudissey, 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 deAudissey 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 paradev, 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).
- Desired state:
deploy/config/appsettings/api/{dev,staging,prod}.jsonmaps"ATLAS_SEARCH_ENABLED": "${ATLAS_SEARCH_ENABLED}".ensure-containerapp-env.jsapplies it in the release pipeline, and the daily appsettings audit reports drift (prod stays audit-only, so prod reports drift until the next prod API release). - Variable Groups:
audissey-dev,audissey-stagingandaudissey-prodall holdATLAS_SEARCH_ENABLED=true(non-secret, noenv:mapping). Before 2026-10 no pipeline step copied it to the Container Apps, so dev and staging ran the regex path. - Startup: with the flag on, the API creates any missing search indexes and probes
radios_search; if the probe fails it logsAtlas Search probe failed — falling back to regex searchand keeps the regex path. - Verify:
az containerapp show -g audissey-staging-rg -n audissey-api-staging --query "properties.template.containers[0].env[?name=='ATLAS_SEARCH_ENABLED']", the startup logAtlas Search is available — using $search aggregation, and non-emptyhighlightsin/api/search?q=tsfresults. - Cost: no new resource. The search indexes already exist (
Ensure Atlas Search indexesstep) and already consume mongot CPU/RAM on the cluster for indexing; queries add mongot CPU. Watch cluster CPU and search metrics after the first release with the flag on.
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_ENABLEDtoAPI_SCHEDULER_ENABLEDbecause 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):
- 10
@Cronmethods covering all periodic work (6 original + 4 migrated from Worker inline services) onModuleInitcatch-up for all daily aggregation jobs- Stalled job cleanup on startup for all 10 queues
- Source:
apps/api/src/app/admin/services/api-job-scheduler.service.ts
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:
JobSchedulerService— manual triggers, queue stats, warmup, stalled cleanup (no crons)- Domain services (
SchedulesRunnerService,ContentPopularityAggregationService,UserFeaturesAggregationService,MlTrainingDataService) — pure business logic, invoked by BullMQ processors ScheduleModulefully removed from Worker
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)
- Pipeline detecta merge para
main - Completa stages de dev e staging
- Pausa no stage de prod
- Approver recebe notificação
- Approver revisa changes e aprova
- 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:
--untaggedapaga primeiro as tags que o--filterapanhar e só depois os manifests.--filter 'repo:.*' --ago 0d --untaggedapaga todas as tags, por desenho (docs). O registo ficou vazio em 2026-03-25 com--ago 0d --untagged --keep 0(o relatório não regista o filtro).--untagged-onlynão toca em tags, e os manifests filhos de um índice com tag ficam protegidos (código).- O
acr purgenão sabe o que está em uso. Antes de apagar tags, listar as imagens das revisões activas e dos jobs, e também as imagens anteriores de cada app. Staging só recebe builds demain, por isso as últimas N builds por número podem não incluir nenhum alvo de rollback de staging. Uma revisão inactiva cuja imagem foi apagada deixa de poder ser activada; as pipelines não activam revisões, fazem deploy por tag (imageTagOverride). - Cada build tem duas tags (
Ne1.0.N), por isso--keep 20guarda 10 builds.
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:
acr purge --untagged-only --ago 7d: manifests sem tag com mais de 7 dias, em todos os repositórios.acr purge --ago 7d --untagged --filter ...: build tags (Ne1.0.N) fora do keep set, emaudissey-{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:
- uma leitura do
azfalhar ou vier incompleta; - uma app ou job da política não for visível, o que apanha uma identidade que só lê um ambiente;
- uma app não tiver imagem activa, ou correr uma imagem sem tag no registo;
- o dry run não listar exactamente as tags do plano, ou listar uma imagem do keep set ou um manifest com menos de 7 dias;
- um
deletepassar o limitemaxTagDeletions(200 por omissão), ou uma imagem em uso mudar depois do plano.
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 pipelineazure-pipelines-monitoring-health-alerts.yml e pelo desired-statedeploy/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:
audissey-insights-devaudissey-insights-stagingaudissey-insights-production
Alertas
deploy/config/alerts/healthchecks.json cobre:
- mais de 10 requests falhados em 15 minutos (Application Insights), por ambiente;
- qualquer restart de Worker/ML, por ambiente;
- Redis partilhado acima de 200 clientes ligados (
maxclientsé 256 no Standard C0).
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:
- Disponibilidade: não existem web tests, por isso
availabilityResultsnão tem
dados. Adicionar web test e alerta quando prod existir. - Latência: a média de
requests/durationé dominada pelos health probes (pico de
16 ms em 14 dias). Precisa de um alerta de logs p95 sem/api/health*. - Jobs failed/stuck e queue delay: ainda não existe um contrato de métrica Azure
verificado. - Backup missing: depende de notificação/auditoria Atlas.
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 ignoraenvironments: para fazer rollout de ensure sem ele na pipeline, usar tambémhealthAlertsOnly=true, que deixa de fora os alertas de podcast e radio recognition.
scripts/ensure-podcast-sync-alerts.js (podcast-sync.json eradio-recognition.json) usa os mesmos códigos e o mesmo --environments, que aceitashared sem efeito porque a pipeline passa a mesma lista aos dois scripts:
- Cada ambiente indica o componente em
appInsights: todos estão emaudissey-shared-rg, e o de prod chama-seaudissey-insights-production. A regra
fica no resource group do ambiente. 3incluimissing,target-missing,action-group-missing,ensure-failed[código]eunreconciled-after-ensure. SóResourceNotFoundeResourceGroupNotFoundcontam como inexistente; qualquer outra falha de leitura dá4e nunca cria a regra. Umcreateque falha dá3(ensure-failed[código]).auditsó verifica se a regra existe, não compara as definições.ensurenão altera
regras existentes.az monitor scheduled-queryeaz monitor app-insightssão extensões. A tarefaAzureCLI@2usa umAZURE_CONFIG_DIRnovo em cada execução, por isso oaz version
no início do log não diz o que está disponível para o script. Noaz2.81 a extensão
instala-se sozinha sem prompt; se a instalação falhar, o resultado é4.
Estado (30/10/2026): a pipeline continua vermelha por três motivos independentes:
- Usa
azure-prod-connection, cujo SPaudissey-sp-prodnão lê o Action Group nem
os recursos dev/staging. Os passos health, podcast e radio recognition terminam em4. - Com um login que leia os recursos, faltam 10 regras em dev e staging: 8 de
podcast-synce 2 deradio-recognition. O resultado é3até correrensure. - O budget
audissey-monthly-operational-ceilingnã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:
PodcastSync*ePodcastFavoriteFeedsDuesó são emitidas comNODE_ENV === 'production'(podcast-sync-dispatcher.service.ts). Dev usadevelopmente staging usastaging. Em staging, o dispatcher só corre uma vez por
dia (api-sync-episodes-daily,0 6 * * *emcron_configs; a migração20260804_011, que o passa a cada minuto, não está em_migrations), por isso uma
janela de 15 minutos só teria dados nesse momento. Em dev o scheduler está desligado.RadioRecognitionFailureRateé emitida pelo Worker, mas os Container Apps do Worker
em dev e staging não têmAPPLICATIONINSIGHTS_CONNECTION_STRING, e emcustomMetricssó aparecem instâncias da API.
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
- Least Privilege: Cada pipeline só acede ao ambiente necessário
- Secret Rotation: Rodar secrets de prod mensalmente
- Audit Logs: Todos os deployments são auditados
- 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:
azure-dev-connection→ Apenas recursos devazure-staging-connection→ Apenas recursos stagingazure-prod-connection→ Apenas recursos prod
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-…) |
- Cada app tem uma federated credential com o nome da connection:
- issuer
https://login.microsoftonline.com/<tenantId>/v2.0; - subject
/eid1/c/pub/t/<tenantId b64url>/a/<AzDO app 499b84ac… b64url>/sc/<orgId>/<endpointId>; - audience
api://AzureADTokenExchange.
- issuer
- O subject usa o id da connection, não o nome: renomear a connection não a parte.
- Os roles Azure continuam nos mesmos SPs. A conversão não mexeu em RBAC.
- O
AzureCLI@2faz login comaz login --service-principal … --federated-token. Não usaraddSpnToEnvironment+$servicePrincipalKey, que não existe com WIF; usar$idToken.
Comandos que peçam um token novo mais de ~10 min depois do início do step podem falhar comAADSTS700024; nesses casos, obter o token logo no início do script. - Verificar:
az devops service-endpoint list --query "[].{n:name,s:authorization.scheme}" -o table
eaz ad app credential list --id <appId>(deve vir vazio). Nos logs dos pipelines, o login tem de
mostrar--federated-token.
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:
- Criar primeiro a federated credential. Se ela não existir, o ADO rejeita a conversão com
no valid configuration existse 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 =instanceIddehttps://dev.azure.com/tiagocosta0526/_apis/connectionData):az ad app federated-credential create --id <appId> --parameters @fic.json - 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 comauthorization = {scheme: WorkloadIdentityFederation, parameters: {serviceprincipalid, tenantid}}.
A resposta trazworkloadIdentityFederationIssuer/Subject, que têm de coincidir com a credential. - Rollback:
az ad app credential reset --id <appId> --appende, 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):
Azure subscription 1: só a connection. A appAudissey-DevOps-ServicePrincipal(f9cd6360-…,
Key Vault Secrets User em kv-dev/kv-staging) ficou, e o secret dela expira a 2026-12-26 sem impacto.Azure subscription 1 (7141477e-…): secret expirado a 2026-03-27. Foram removidos a connection,
a app1836265f-…(soft delete, recuperável durante 30 dias) e o seuContributorna subscription.
Docker registry connections (não são ARM):
audissey-acr-connection-shared(creationMode: Automatic, app43646bb4-…, AcrPush) continuaServicePrincipal, com o secret expirado a 2026-09-17.- Os pipelines de build registados não a usam: fazem
docker logincomACR_USERNAME/ACR_PASSWORD.
Só oazure-pipelines-docker.yml, que não está registado, a referencia. - Só se converte na UI (Service connections →
audissey-acr-connection-shared→ Convert), porque
o ADO bloqueia REST para endpoints ACR.
- Os pipelines de build registados não a usam: fazem
Azure Container Registry(UsernamePassword,hub.docker.com): sem uso.
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-publicnow 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
- Verificar branch trigger no pipeline
- Verificar approvals pendentes (prod)
- Verificar service connection autorizada
Variáveis não disponíveis
- Variable Group linkado ao pipeline?
- Key Vault com permissões corretas?
- Service principal tem acesso ao Key Vault?
Deploy falha
- Verificar logs da Container App
- Verificar imagem existe no ACR
- Verificar variáveis de ambiente configuradas
- 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:
azure-pipelines-phase3-operations-audit.yml: semanal, só leitura; recolhe
30 dias de ActualCost, 14 dias de métricas e inventário/FQDN/revisões/scale/VG.
Publica JSON/Markdown redigidos e declara consultas/dados ausentes. O snapshot
versionado é regenerável a partir do input agregado redigido adjacente; a
resposta Azure bruta não foi versionada, portanto Git não permite repetir nem
auditar independentemente a recolha remota original.azure-pipelines-monitoring-health-alerts.yml: diário emaudit; compara
condição, namespace, dimensions,autoMitigate, severidade, janela,
frequência, scope e Action Group dos alertas, além do budget 350/400/500 €.
Executa health, podcast, radio recognition e budget mesmo quando um deles falha;ensure
continua exclusivamente manual e recusa criar budget sem moeda de billing
confirmada e igual a EUR.
O procedimento completo, formato de input manual Atlas/fornecedores, guards de
sizing e política dev on-demand está emdocs/operations/PHASE3_COST_CAPACITY_AND_LAUNCH.md.
O runbook RPO 24h/RTO 4h e o ensaio mensal estão emdocs/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 emdocs/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)
- O quê: site de marca + páginas legais (pt-PT predefinido + EN), HTML/CSS estáticos em
website/(páginas de marca/legais sem build, JS ou pedidos a terceiros)./cast/aloja
o receiver TV e carrega o SDK oficial Google CAF com CSP própria. Detalhes (estrutura, regras de edição,
deploy manual, DNS) emWEBSITE.md— fora dewebsite/para não ser publicado. - Onde: Azure Static Web Apps Free
audissey-website(RGaudissey-shared-rg,
West Europe), host por omissãogreen-rock-02d624a03.3.azurestaticapps.net.audissey.appcontinua só para deep links (Firebase Hosting) — não é tocado. - Deploy:
azure-pipelines-website-release.yml, disparado por alterações emwebsite/**ou no
próprioazure-pipelines-website-release.yml, emmain(ambiente SWAproduction) edev
(ambiente previewdev); execuções manuais de outros branches vão sempre paradev. O deploy é umdeployment:job no ambiente ADOprod(a partir demain) oudev(restantes), pelo que herda as
aprovações/verificações desses ambientes, como os outros pipelines de release; temcheckout: self
explícito (os deployment jobs não fazem checkout sozinhos). O token de deploy é lido em runtime comaz staticwebapp secrets list(service connection por ambiente:azure-prod-connectionemmain,azure-dev-connectionnos outros branches; cada SP temContributorsó no recursoaudissey-website) e passado à SWA CLI2.0.10como variável secreta. Sem Variable Group, sem segredos guardados, sem app settings
(não se aplica o passo de bootstrap appsettings). - Config de runtime:
website/staticwebapp.config.json— login SWA desativado
(/.auth/login/{github,aad,x,twitter}→ 404, primeiras regras deroutes), redirects 301
(/privacy,/terms,/delete-account→ EN;/privacy-policy,/politica-de-privacidade→/privacidade/), 404 personalizada, CSP estritadefault-src 'self', HSTS semincludeSubDomains(há subdomínios de email), cache de
7 dias em/assets/*(CSS: 1 hora). - Receiver Cast:
https://audissey.io/cast/, ficheiros runtime emwebsite/cast/.
Publicado manualmente em produção em 2026-10-29; 20 checks HTTP passaram,
incluindo os bytes das oito páginas existentes e recursos do receiver.
CSP Google/IPC apenas nessa rota; política global preservada. MIME.mjsexplícito,nosniff, sem cache e sem indexação. Mesmo payload nos ambientes do website;
sem appsettings, Variable Groups ou recursos novos. Verificar comnode --test scripts/tests/website-cast.test.mjsenode scripts/verify-cast-website.mjs --base <URL-do-ambiente>. Registo Google,
ID da build móvel e validação em TV são passos separados. Impacto de custo baixo,
limitado a armazenamento/tráfego da quota Free existente.
Registo Google concluído em 2026-10-29: Audissey, ID55A8EF69, Unpublished.
A configuração móvel partilha esse ID entre flavors e inclui o Bonjour iOS;
Mi Box S 3rd Gen e MDZ-16-AB foram autorizadas na consola em 2026-10-29,
ambas Ready For Testing. Reinício das boxes e validação física pendentes. - Passos únicos (manuais):
- Criar a definição do pipeline no ADO a partir do YAML (Pipelines → New pipeline → Azure Repos
Git →Audissey→ Existing Azure Pipelines YAML file →/azure-pipelines-website-release.yml). - Na primeira execução, autorizar o pipeline a usar as service connections
azure-dev-connection/azure-prod-connectione os ambientesdev/prod(Permit).
A connectionAzure subscription 1 (7141477e-…), cujo client secret expirou a 2026-03-27
("Azure login failed / Secret expired"), foi apagada a 2026-10-21. - DNS na Porkbun (feito pelo Tiago, 2026-10-21):
TXT @= código de validação da SWA para o apex,ALIAS @→green-rock-02d624a03.3.azurestaticapps.net(a Porkbun faz flattening:digmostra um IP),CNAME www→ mesmo host.
MX/SPF da Zoho intocados.audissey.ioewww.audissey.ioestãoReadycom HTTPS. - Validar os domínios personalizados na SWA (
az staticwebapp hostname set/show, comandos emWEBSITE.md) e, depois de validados, definiraudissey.iocomo predefinido.
- Criar a definição do pipeline no ADO a partir do YAML (Pipelines → New pipeline → Azure Repos
- Custo: 0 € (SWA Free: 100 GB/mês de banda, 2 domínios personalizados, 3 ambientes de
preview; pipeline no pool self-hostedDefault). Único risco de custo: se o tráfego se
aproximar da quota de 100 GB/mês, avaliar o plano Standard (~9 USD/mês por app, confirmar
preço atual no portal) — vigiar a métrica Data Transfer da SWA. - Verificar: o pipeline faz smoke check (
/,/privacidade/,/en/= 200,/privacy= 301, página inexistente = 404, header CSP presente). Manualmente:curl -sI https://audissey.io/ | grep -i content-security-policyecurl -s -o /dev/null -w '%{http_code}' https://audissey.io/.auth/login/github(= 404).