#!/usr/bin/env bash # Atualiza o DeskcommCRM na VPS: código novo + banco + app — com BACKUP antes e # CHECAGEM DE SAÚDE depois. Um comando só, pensado pra quem não é técnico: # # bash hostgator-setup-kit/update.sh # # Flags: # --force instala a versão pedida mesmo que ela seja igual ou ANTERIOR # à que já está aqui (é o jeito explícito de voltar no tempo) # --skip-backup pula o backup automático (não recomendado) # --to instala essa tag em vez da mais recente publicada # Absoluto e resolvido ANTES do `enter_project`, que faz `cd`: depois dele um # `dirname "$0"` relativo apontaria para o lugar errado, e o único sintoma seria # um script do kit "não encontrado" no meio da atualização. KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" source "$KIT_DIR/_common.sh" # O aviso que assume a porta enquanto o CRM esta parado. Fica em arquivo # proprio porque so a ATUALIZACAO para o CRM — install.sh e agent.sh sourceiam # `_common.sh` e nao tem o que anunciar. # shellcheck source=manutencao.sh source "$KIT_DIR/manutencao.sh" enter_project FORCE=""; SKIP_BACKUP=""; TARGET_TAG="" while [ $# -gt 0 ]; do case "$1" in --force) FORCE=1 ;; --skip-backup) SKIP_BACKUP=1 ;; --to) shift; TARGET_TAG="$1" ;; esac shift done # ── 0-. Esta cópia do repo é a dona dos contêineres? ───────────────────────── # Antes do cron e antes do git: uma segunda cópia que atualiza por cima recria o # parque com o .env DELA. Foi o que deixou o WhatsApp de uma VPS real três dias # em 401. Ver `recusar_projeto_de_outra_arvore` em _common.sh. recusar_projeto_de_outra_arvore || die "Atualização interrompida para não quebrar a instalação que está no ar." # Single-server: o Supabase desta VPS também tem dono. E o e-mail de acesso # (GoTrue) acompanha o SMTP do CRM AQUI, antes da decisão de versão: é este # comando que o instalador ensina a rodar depois de configurar /admin/email, e # "já está na versão mais recente" sairia sem entregar a troca. if [ "${SINGLE_SERVER:-0}" = "1" ]; then recusar_supabase_de_outra_arvore || die "Atualização interrompida para não mexer no Supabase de outra instalação." # A porta direta do GoTrue acompanha o `signup_mode` da instalação (#1653). # Antes do SMTP de propósito: é o caminho que roda MESMO quando o update não # tem nada a atualizar (a saída "você já está na versão mais recente" fica # mais abaixo), então quem trocou "só convite" na tela e rodou o update leva # o `DISABLE_SIGNUP` no mesmo comando — e é ele que fecha # `POST /auth/v1/signup` para quem tem a anon key. Esta chamada roda com o # kit ANTERIOR ao checkout; a da versão nova fica dentro de # `atualizar_supabase_single_server` (_common.sh), mais abaixo. if sincronizar_signup_mode_do_gotrue; then dc_supabase up -d --no-deps auth >/dev/null 2>&1 || c_ylw "⚠ Não consegui reiniciar o auth do Supabase com o modo de cadastro (#1653)." fi if sincronizar_smtp_do_gotrue; then dc_supabase up -d --no-deps auth >/dev/null 2>&1 || c_ylw "⚠ Não consegui reiniciar o auth do Supabase com o SMTP do CRM." else c_ylw "⚠ Sem SMTP no CRM: 'esqueci a senha' e a confirmação de cadastro não enviam e-mail. Configure em /admin/email e rode o update.sh de novo." fi fi # ── 0. Liga o agente da tela ANTES de qualquer decisão de versão ───────────── # Instalar o cron aqui, e não no fim, é o que faz o bootstrap ter fim: os # caminhos "já está na versão mais recente" e "essa versão é anterior à sua" # saem do script mais abaixo, e se o cron dependesse deles a atualização pela # tela nunca ligaria justamente em quem já está em dia. É idempotente. setup_update_agent_cron # ── 1. Tem atualização mesmo? ──────────────────────────────────────────────── step "Procurando atualizações" git fetch --tags --quiet origin 2>/dev/null || c_ylw "⚠ não consegui falar com o GitHub — sigo com o código que já está aqui." # A AUTORIDADE é a release publicada, NUNCA a maior tag — ver # `ultima_release_estavel` em _common.sh. Um `TARGET_TAG` passado à mão # continua valendo (instalar uma versão específica é operação legítima de # quem sabe o que está fazendo); o que deixou de existir é ESCOLHER sozinho a # maior tag, que instalaria código sem release publicada. [ -n "$TARGET_TAG" ] || TARGET_TAG="$(ultima_release_estavel)" [ -n "$TARGET_TAG" ] || die "Não encontrei nenhuma versão publicada para instalar. (Tag existir não basta: o alvo é a última release estável publicada no GitHub. Se o servidor não conseguiu falar com a API, tente de novo mais tarde; para instalar uma versão específica, passe --to vX.Y.Z.)" git rev-parse --verify --quiet "${TARGET_TAG}^{commit}" >/dev/null \ || die "Não conheço a versão $TARGET_TAG aqui. Confira o nome (ex.: v1.1.0) ou tente de novo quando o servidor conseguir falar com o GitHub." CURRENT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)" # O código estar em dia NÃO significa que o app está: quem roda é a imagem. # Uma atualização interrompida depois do checkout (queda de rede, falta de # memória no meio do docker pull) deixa o repositório novo e a imagem velha — e # a partir dali TODO update.sh respondia "já está na versão mais recente", # prendendo o CRM na versão antiga sem nenhuma saída visível para o dono. # Também cobre imagem republicada sem commit novo (rebuild de segurança). # (Veio da `main`; a versão por tag cai exatamente na mesma armadilha, porque a # comparação de tags também fica satisfeita com a imagem velha no lugar.) image_desatualizada() { # O fallback vem de `IMG_APP` (_common.sh, sourceado no topo deste arquivo) e não de # um literal: num fork com namespace próprio, o literal apontava para a # imagem do UPSTREAM, e um `.env` sem APP_IMAGE comparava o digest local # contra um registry que não é o dele. local img="${APP_IMAGE:-${IMG_APP}:latest}" local_d remote_d local_d="$(docker image inspect "$img" --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' 2>/dev/null | sed 's/.*@//')" [ -z "$local_d" ] && return 0 # nem baixada ainda → atualizar remote_d="$(docker buildx imagetools inspect "$img" 2>/dev/null | awk '/^Digest:/{print $2; exit}')" [ -z "$remote_d" ] && return 1 # sem como consultar → não forçar [ "$local_d" != "$remote_d" ] } MESMA_TAG="" [ "$CURRENT_TAG" = "$TARGET_TAG" ] && MESMA_TAG=1 if [ -n "$MESMA_TAG" ] && [ -z "$FORCE" ] && ! image_desatualizada; then # ⛔ O AVISO TAMBÉM DESCE AQUI — esta saída é anterior ao `manutencao_desce` # do fluxo normal (mais abaixo) e ao `restaurar_servicos` do caminho de erro. # # MEDIDO numa VPS real: uma atualização morreu logo depois de `manutencao_sobe` # (tag nova já no disco, imagem antiga ainda rodando). O aviso ficou de pé com # o apelido de rede `app`, e o Caddy passou a entregar ELE — 503 em tudo: # site, crons e webhook do WAHA. O app estava saudável o tempo todo. # # A volta por cima não existia: como o `git checkout` da tag JÁ tinha # acontecido, toda execução seguinte caía nesta linha, dizia "nada a # atualizar" e saía — sem nunca tocar no aviso. O CRM ficou 6h30 fora do ar # e nem o botão da tela voltava, porque o agente do host também levava 503. # # `manutencao_desce` é `docker rm -f ... || true`: idempotente, custa nada # quando não há aviso nenhum de pé, que é o caso comum desta saída. if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$NOME_DA_MANUTENCAO"; then manutencao_desce c_ylw "⚠ Havia um aviso de manutenção preso de uma atualização anterior — removido." c_ylw " Enquanto ele estava de pé, o CRM respondia 503 para todo mundo." # Aviso preso = a execução anterior morreu no meio (banco e/ou imagem pela # metade); "nada a atualizar" sozinho deixaria o app na imagem antiga. c_ylw " A atualização anterior não terminou. Para concluí-la:" c_ylw " bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force" fi c_grn "✓ Você já está na versão mais recente ($TARGET_TAG). Nada a atualizar." exit 0 fi # Alvo que JÁ está contido no que roda aqui = andar pra trás, não pra frente. # Numa instalação que segue a `main`, `git describe --exact-match` é vazio: a # comparação de tags acima passa batido e, sem esta guarda, o script instalaria # alegremente uma versão MAIS VELHA que a instalada — desligando o que o dono # já tem (foi assim que este próprio botão se autodestruiria, voltando pra uma # imagem que não conhece o agente de atualização). Recusar é o padrão; voltar # no tempo continua possível, mas só quando alguém pede de propósito. # Quando o alvo é a MESMA tag já instalada, a guarda não se aplica: não há para # onde voltar no tempo — só a imagem é que ficou para trás. if [ -z "$FORCE" ] && [ -z "$MESMA_TAG" ]; then is_already_in_head "$TARGET_TAG" && CONTIDA=0 || CONTIDA=$? case "$CONTIDA" in 0) refuse "A versão $TARGET_TAG é ANTERIOR à que já está instalada neste servidor. Instalar ela seria voltar no tempo e desligar coisas que você já tem. Não mexi em nada: nem no banco, nem no app — está tudo como estava. Se você REALMENTE quer voltar para a $TARGET_TAG, rode: bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force" ;; 2) refuse "Não consegui ter CERTEZA de que a versão $TARGET_TAG é mais nova que a instalada aqui — a cópia do código neste servidor veio abreviada e eu não consegui completá-la (o servidor precisa conseguir falar com o GitHub para isso). Prefiro não mexer a arriscar te levar para uma versão anterior sem querer. Não mexi em nada. Tente de novo em alguns minutos; se insistir, confira a internet do servidor. Para instalar assim mesmo, por sua conta: bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force" ;; esac fi if [ -n "$MESMA_TAG" ] && [ -n "$FORCE" ]; then # Com --force na mesma tag ninguém conferiu a imagem: quem chega aqui pediu # para refazer (é a saída que a própria atualização ensina quando o banco não # termina limpo). Dizer "o app está rodando uma imagem antiga" seria inventar. if [ -n "$SKIP_BACKUP" ]; then c_ylw "Refazendo a versão $TARGET_TAG, como pedido (--force): banco de novo, e confere o app." else c_ylw "Refazendo a versão $TARGET_TAG, como pedido (--force): backup e banco de novo, e confere o app." fi elif [ -n "$MESMA_TAG" ]; then c_ylw "O código já está na $TARGET_TAG, mas o app está rodando uma imagem antiga. Vou atualizar a imagem." else c_ylw "Vou atualizar para a versão $TARGET_TAG com segurança." fi # ── 2. Backup de segurança ANTES de tocar no banco ─────────────────────────── if [ -z "$SKIP_BACKUP" ]; then step "Backup de segurança (antes de mexer no banco)" if bash "$KIT_DIR/backup.sh"; then c_grn "✓ backup feito — se algo der errado, dá pra restaurar (restore.sh)." else if [ -n "${DESKCOMM_AGENT_REPORT:-}" ] || [ ! -t 0 ]; then die "O backup preventivo falhou. Atualização automática interrompida para proteger os dados." fi c_ylw "⚠ o backup falhou. A atualização NÃO apaga dados (só reorganiza os contatos)," c_ylw " mas o ideal é ter backup." read -r -p "Deseja continuar MESMO SEM BACKUP? Digite 'CONTINUAR': " conf [ "$conf" = "CONTINUAR" ] || die "Atualização cancelada pelo operador para investigar a falha do backup." fi fi # Avisa o agente do host (se for ele quem está dirigindo) — é o que faz a tela # de atualização avançar passo a passo enquanto o app ainda está de pé. [ -n "${DESKCOMM_AGENT_REPORT:-}" ] && eval "${DESKCOMM_AGENT_REPORT_CMD}" backup # ── 3. Código novo ─────────────────────────────────────────────────────────── step "Baixando o código novo" if ! git checkout --quiet "$TARGET_TAG" 2>&1; then die "Não consegui trocar para a versão $TARGET_TAG (parece haver mudanças locais que divergem). Rode 'git status' pra ver, ou peça ajuda. NÃO mexi no banco — está tudo como estava." fi # As funções do kit são carregadas na linha 16, ANTES deste checkout — então, # sem esta releitura, o resto desta atualização roda com as funções da versão # ANTIGA, e todo conserto que viva numa função do kit só chega na atualização # SEGUINTE. Foi medido numa VPS de produção em 17/09/2026: depois de atualizar # para a versão que conserta a linha do cron (que deixava um segredo escrito no # crontab, e portanto no syslog), a linha antiga continuava lá — o conserto # existia no disco e não tinha rodado. Duas passadas para aplicar um conserto é # o mesmo que exigir passo manual de quem opera a VPS, e a doutrina de # packaging proíbe. # # `_common.sh` só define funções e constantes no topo (`set -euo pipefail`, # COMPOSE, cores, REFUSED_RC), então reler é idempotente: nada é reexecutado # com efeito. O que muda é de onde vêm as funções daqui para baixo. source "$KIT_DIR/_common.sh" # E o aviso de manutenção pelo MESMO motivo, na mesma linha do raciocínio acima: # ele também é carregado no topo, também é só definição de função, e o passo que # o USA (a pausa do banco) vem depois daqui. Sem esta linha o parágrafo acima # valeria para `_common.sh` e seria falso para o kit — um conserto na página de # manutenção chegaria uma atualização atrasada, que é exatamente o defeito que a # releitura existe para fechar. source "$KIT_DIR/manutencao.sh" # Single-server: o Supabase vai para a versão pinada no código novo ANTES do # banco (o passo 4 pausa peças dele, e um `up` depois as religaria). if [ "${SINGLE_SERVER:-0}" = "1" ]; then # Esta função também sincroniza o modo de cadastro com o GoTrue (#1653). A # chamada mora DENTRO dela, e não numa linha aqui, porque é o corpo dela que # o update.sh antigo executa na atualização que traz o conserto. atualizar_supabase_single_server || die "O Supabase desta VPS não subiu (erro acima). NÃO mexi no banco do CRM." fi [ -n "${DESKCOMM_AGENT_REPORT:-}" ] && eval "${DESKCOMM_AGENT_REPORT_CMD}" codigo # ── 4. Banco: schema + correções de dados (schema ANTES do app) ────────────── # O baseline é idempotente e auto-curativo. Re-aplicar numa base que JÁ existe # gera erros do tipo "já existe" / "multiple primary keys" — isso é ESPERADO e # inofensivo (são objetos que já estavam lá). Filtramos esse ruído e só # mostramos problemas de verdade. Erro de disputa com o app no ar (deadlock) # faz o arquivo ser aplicado de novo: ver `reaplicar_baseline` em _common.sh. # Re-aplicar o baseline é DDL, então vai por `url_do_schema` (_common.sh) e não # pela string do app: numa instalação em Supabase próprio, com a role menor no # `.env` como recomendamos, este passo passava a falhar em silêncio a cada # atualização — e é o update.sh que entrega migration nova ao clone (issue #192). # ── NINGUÉM FALA COM O BANCO ENQUANTO ELE MUDA ─────────────────────────────── # # Medido nesta instalação, no mesmo dia e com o mesmo arquivo: # tudo de pé ................................ 113 travamentos # CRM parado ................................ 60 travamentos # CRM + rest + realtime + studio parados .... 0 travamentos # # Travamento aqui não é lentidão: quando o `create policy` trava, o `drop` que # veio antes já valeu. A regra some, o banco nega a leitura em silêncio, e a # tela fica vazia — indistinguível de "não há nada aqui". # # Custa ~16s (medido: parar 10,3s, subir 6,0s) numa atualização cuja mediana # real é 308s e cuja variação natural entre duas rodadas foi de 785s. Fica # abaixo do ruído que já existe. # # O `trap` é o que impede um erro no meio de deixar a instalação pela metade: # qualquer saída — sucesso, erro ou interrupção — devolve as peças do Supabase. # EXIT nao basta: interrupcao (Ctrl+C, cron matando a rodada, reinicio da # maquina) nao passa por ele em todos os casos — e o desfecho seria a # instalacao com as pecas do banco paradas, que foi o que se mediu. trap restaurar_servicos EXIT INT TERM HUP step "Atualizando o banco de dados" # O que sobrou de errado no banco, para ser repetido no FIM da execução. # Vazio = o banco terminou limpo (ou não havia baseline para aplicar). BANCO_INCOMPLETO="" # As linhas que repetir NÃO cura: as que não são de disputa nem de conexão # (permissão, dado). Vazio com BANCO_INCOMPLETO cheio = só o banco ocupado. BANCO_RESTANTE="" # O que fazer, dito por causa, no passo 4 e de novo no fim. Rodar o update.sh sem # --force responderia "já está na versão mais recente" e não tocaria no banco. # O restore vem por ÚLTIMO: ele desfaz também o que o CRM gravou desde o backup. orientar_banco_incompleto() { # As duas metades SOMAM: uma lista pode ter disputa (que repetir cura) e erro de # permissão ou de dado (que não). Escolher uma só escondia a ação possível. if [ "$BANCO_RESTANTE" != "$BANCO_INCOMPLETO" ]; then c_ylw " Parte não aplicou porque o banco seguiu ocupado ou fora de alcance nas $BASELINE_PASSADAS passadas." c_ylw " Confira se o banco está no ar e repita a atualização, de preferência num horário de pouco" c_ylw " movimento (reaplica o banco; o site pode piscar por alguns segundos):" c_ylw " bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force" fi case "$BANCO_RESTANTE" in "") ;; *permission\ denied*|*must\ be\ owner*|*permissão\ negada*) c_ylw " Há erros de PERMISSÃO, e esses repetir não cura: a conexão do .env não é o dono do banco." c_ylw " Num Supabase próprio, declare SUPABASE_DB_ADMIN_URL no .env — é ela que roda o schema — e" c_ylw " repita a atualização:" c_ylw " bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force" ;; *) c_ylw " O resto dos erros acima repetir não cura: guarde a mensagem e peça ajuda." ;; esac c_ylw " Só em último caso, volte ao backup feito antes desta atualização (restore.sh)." } if [ -f supabase/baseline.sql ]; then # O aviso PRIMEIRO: entre pausar e anunciar, quem estivesse com a tela aberta # veria o erro do navegador, que e o desfecho que esta onda existe para tirar. manutencao_sobe pausar_o_que_fala_com_o_banco # Extensões que o schema exige (idempotente; iguais ao install.sh). pg_container postgres:17-alpine psql "$(url_do_schema)" -c \ "create extension if not exists vector with schema public; create extension if not exists citext with schema public; create extension if not exists pg_trgm with schema public;" \ >/dev/null 2>&1 || true # ── O LOG DO BANCO FICA GUARDADO ────────────────────────────────────────── # # Ele era descartado: a saída do psql servia só para o filtro de erros e # morria com a função. O que o agente guarda em `system_update_runs.log_tail` # é a CAUDA da atualização — Docker e reinício —, e o banco acontece antes. # # Medido em 2026-09-12, numa instalação real: duas regras de isolamento # sumiram durante uma atualização, o funil ficou vazio para todo mundo, e não # houve como saber por quê — a evidência tinha sido jogada fora. A única coisa # que restou foi a hipótese. # # O segundo argumento de `reaplicar_baseline` já existe para isto e recebe # TODAS as passadas, cada uma com cabeçalho — melhor que a saída da última. if reaplicar_baseline "$PROJECT_DIR/supabase/baseline.sql" "$PROJECT_DIR/.deskcomm-banco.log"; then if [ "$BASELINE_PASSADAS" -gt 1 ]; then c_grn "✓ banco atualizado na passada $BASELINE_PASSADAS — as anteriores não aplicaram tudo (banco ocupado ou conexão instável; o que faltou está listado acima)." else c_grn "✓ banco atualizado (e conversas reorganizadas, se havia bagunça)." fi else BANCO_INCOMPLETO="$BASELINE_INESPERADO" BANCO_RESTANTE="$(printf '%s\n' "$BANCO_INCOMPLETO" | grep -viE "$BASELINE_ERROS_DE_DISPUTA" || true)" c_ylw "⚠ Apareceram avisos no banco que NÃO são os esperados:" # Sem `| head`: com pipefail, o head que fecha cedo mata o printf com SIGPIPE # numa lista grande — e o set -e derrubava o script aqui, antes do aviso de # PERMISSÃO, que foi escrito justamente para ela. listar_erros_do_banco "$BANCO_INCOMPLETO" 20 c_ylw " O app pode ainda funcionar." orientar_banco_incompleto fi # ── E AS REGRAS DE ISOLAMENTO SÃO CONFERIDAS ────────────────────────────── # # ## Por que isto existe # # O baseline aplica cada regra como APAGAR e depois CRIAR — é o único jeito # portável, porque o Postgres não tem `create or replace policy`. E esta # atualização roda SEM parar em erro, de propósito, para um clone bagunçado # conseguir se curar. # # As duas coisas juntas têm um desfecho ruim: se o "criar" falha, o "apagar" # já valeu. A regra some, a atualização segue e reporta SUCESSO. Com a regra # de leitura ausente e a segurança por linha ligada, o Postgres nega tudo — # sem erro, sem aviso. A tela mostra uma lista vazia, que é indistinguível de # "não há nada aqui". # # Medido: o dono de uma instalação descobriu horas depois, pelo funil vazio, e # não pela atualização que tinha acabado de dizer "concluída com sucesso". # # ## A régua, e por que não é "toda regra que o arquivo cria" # # O baseline CRIA e depois APAGA a mesma regra de propósito em vários pontos — # é assim que uma regra antiga vira três novas (`conversations_agent_write` # virou insert/update/delete). Contar toda criação daria falso positivo em # cima de decisão deliberada, e falso positivo derruba a confiança no aviso # inteiro. Vale a ÚLTIMA operação de cada regra no arquivo: quem termina # criada é esperada; quem termina apagada, não. # # ── E A COMPARAÇÃO RODA EM ORDEM DE BYTES, SEMPRE ───────────────────────── # # ⛔ `sort` e `comm` precisam concordar na ordenação. No GNU coreutils os dois # seguem o mesmo locale e concordam (medido: Ubuntu 20.04–25.10, Debian 12, # AlmaLinux 8/9 dão 0 em C, C.UTF-8, en_US e pt_BR). No Ubuntu 26.04, que troca # o coreutils pelo uutils (Rust, 0.8.0), o `sort` ordena pelo locale e o `comm` # compara BYTES: sob en_US/pt_BR.UTF-8, `orgs_select` cai entre `org_guardrail_*` # e `org_voice_calls_*`, o `comm` perde o passo — "comm: file 2 is not in # sorted order" na stderr — e o que devolve depois é lixo. # # MEDIDO numa instalação real, 2026-09-28: com as 114 regras TODAS no banco, a # comparação acusou 2 faltando (`org_voice_calls_admin_write` e # `org_voice_calls_select`). O alarme falso faz o script tentar recriar as # duas, o banco responde "already exists", a conferência seguinte tropeça no # mesmo erro de ordenação — e a atualização PARA, deixando o aviso de # manutenção de pé. O CRM passou 8 horas em 503 com o banco íntegro, e a tela # mandava o dono procurar regra que nunca faltou. # # A cura é forçar o locale da comparação para `C`, que é ordem de bytes: aí o # `sort` produz exatamente o que o `comm` espera, em qualquer ambiente. # # ⚠️ `LC_COLLATE=C` NÃO basta, e a diferença custa uma sessão de depuração: # o POSIX dá precedência a `LC_ALL` sobre `LC_COLLATE`, então basta alguém # exportar `LC_ALL=…UTF-8` — systemd, um `docker exec`, o terminal de quem # roda o update à mão — para o pin virar enfeite e o defeito voltar inteiro. # MEDIDO, com as 114 regras reais: `LC_ALL=C` devolve 0 em qualquer condição; # `LC_COLLATE=C` devolve 0 com `LC_ALL` vazio e 2 com `LC_ALL` preenchido. # `LC_ALL=C` vale só para os comandos abaixo — não alcança as mensagens. esperadas="$(awk ' match($0, /drop policy if exists "?[a-zA-Z0-9_]+"? on public\.[a-zA-Z0-9_]+/) { linha = substr($0, RSTART, RLENGTH); acao = "drop" } match($0, /create policy "?[a-zA-Z0-9_]+"? on public\.[a-zA-Z0-9_]+/) { linha = substr($0, RSTART, RLENGTH); acao = "create" } acao != "" { gsub(/.*policy (if exists )?"?/, "", linha); gsub(/"? on public\./, "|", linha) estado[linha] = acao; acao = "" } END { for (k in estado) if (estado[k] == "create") print k } ' supabase/baseline.sql | LC_ALL=C sort -u)" # ── E SÓ SE COBRE QUEM TEM A RELAÇÃO NO BANCO ────────────────────────────── # # MEDIDO na issue #1897: as 8 regras de honorários moram DENTRO do corpo de # public.fn_honorarios_provisionar() (supabase/baseline.sql:36801, primeira # policy em :36896), e essa função só executa quando um administrador chama # fn_modulo_instalar('honorarios', …) — criar a função não cria tabela nenhuma, # como a própria migration 0480 / ADR-0002 avisa. Num VPS SEM o módulo, # honorarios_contratos e honorarios_parcelas não existem: o awk de cima enxerga # o `create policy` no TEXTO do arquivo, a recriação responde # `relation does not exist`, a segunda conferência acusa as MESMAS 8 e o script # sai em 1 com o CRM parado — era a atualização inteira de toda instalação sem # o módulo de honorários (a tela mostrava as 8 e mais nada). # # A régua passa a cobrir só policy cuja RELAÇÃO já existe em `public`. É mais # genérico do que caçar `$f$`/`$$` no texto: cobre os próximos módulos da # ADR-0002, venham eles por corpo de função, por migration ou por qualquer # outra forma de escrever o baseline. E NÃO afrouxa nada: policy de tabela que # EXISTE continua sendo cobrada, que é o caso para o qual o aviso existe — a # regra que some do banco com a tabela de pé continua derrubando a atualização. # # ⚠️ Se a consulta vier VAZIA (banco fora do ar, URL trocada), NÃO se filtra. # Sem a lista de relações, filtrar derrubaria `esperadas` inteira e o ✓ sairia # com "0 declaradas" — o aviso viraria mudo exatamente quando ninguém consegue # ler o banco. Vale o comportamento antigo, que é barulhento: tudo é cobrado e # a conferência para. Surdo nunca. tabelas="$(pg_container -i postgres:17-alpine psql "$(url_do_schema)" -t -A -c \ "select c.relname from pg_class c join pg_namespace n on n.oid=c.relnamespace where n.nspname='public';" 2>/dev/null | LC_ALL=C sort -u)" if [ -n "$tabelas" ]; then # `esperadas` é `regra|tabela`; o filtro olha só a tabela. A saída do awk # segue a ordem do SEGUNDO arquivo (o `esperadas` já ordenado), e o # segundo `sort` reforça o MESMO pino de antes (LC_ALL=C): o `comm` logo # abaixo lê em `LC_ALL=C` e não perdoa entrada fora de ordem. esperadas="$(awk 'NR == FNR { existe[$0] = 1; next } { split($0, par, "[|]"); if (existe[par[2]]) print }' \ <(printf '%s\n' "$tabelas") <(printf '%s\n' "$esperadas") | LC_ALL=C sort -u)" fi existentes="$(pg_container -i postgres:17-alpine psql "$(url_do_schema)" -t -A -F'|' -c \ "select p.polname, c.relname from pg_policy p join pg_class c on c.oid=p.polrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public';" 2>/dev/null | LC_ALL=C sort -u)" faltando="$(LC_ALL=C comm -23 <(printf '%s\n' "$esperadas") <(printf '%s\n' "$existentes") || true)" if [ -n "$faltando" ]; then # ── RECRIAR AS QUE FALTAM, NUNCA REAPLICAR O ARQUIVO ───────────────────── # # ⚠️ Isto corrige o que este script fazia antes: reaplicar o baseline inteiro # e conferir de novo. Aquilo era o que eu tinha feito no servidor, e a # medição mostrou que NÃO FECHA — reaplicar não converge. A segunda passada # devolveu `conversations_select` e levou embora `conversations_agent_insert`; # a terceira trocou o conjunto outra vez. Cada passada sorteia, porque cada # passada é a mesma corrida de APAGAR e CRIAR 92 vezes. # # Recriar só o que falta é um punhado de comandos rápidos, com muito menos # superfície para travar. E roda com os serviços ainda PARADOS, que é a # única janela sem disputa. # # E não é uma segunda cópia das 92 declarações: o comando sai do PRÓPRIO # `baseline.sql`, recortado dele. Nada aqui sabe o que uma regra diz. c_ylw "⚠ Faltaram regras de isolamento. Recriando exatamente as que faltam…" faltam_arq="$PROJECT_DIR/.deskcomm-regras-faltando.txt" printf '%s\n' "$faltando" > "$faltam_arq" # A régua junta o comando INTEIRO — uma regra real ocupa várias linhas, e # recortar só a primeira produziria SQL sem predicado e sem `;`, que falha # deixando a impressão de que tentou. E vale a ÚLTIMA operação de cada # regra: quem o arquivo cria e depois apaga de propósito não é recriada. # /!\ O arquivo do que falta entra como PRIMEIRO ARQUIVO do awk, e nao por # `-v`. MEDIDO: `awk -v var=valor` processa sequencias de escape no valor, # entao um caminho do Windows (C:\Users\...) perde as barras e o awk le um # arquivo que nao existe — devolvendo vazio, EM SILENCIO, como se nada # faltasse. Numa VPS Linux nao doeria; o teste pegou antes de virar aposta. recria="$(awk ' NR == FNR { sub(/[ \t\r]+$/, "", $0); if ($0 != "") quero[$0] = 1; next } /create policy|drop policy if exists/ { buf = ""; coletando = 1 } coletando { buf = buf $0 "\n" } coletando && /;[ \t]*$/ { coletando = 0 if (match(buf, /drop policy if exists "?[a-zA-Z0-9_]+"? on public\.[a-zA-Z0-9_]+/)) { k = substr(buf, RSTART, RLENGTH); acao = "drop" } else if (match(buf, /create policy "?[a-zA-Z0-9_]+"? on public\.[a-zA-Z0-9_]+/)) { k = substr(buf, RSTART, RLENGTH); acao = "create" } else next gsub(/.*policy (if exists )?"?/, "", k); gsub(/"? on public\./, "|", k) estado[k] = acao; if (acao == "create") texto[k] = buf } END { for (k in quero) if (estado[k] == "create") printf "%s", texto[k] } ' "$faltam_arq" supabase/baseline.sql)" if [ -n "$recria" ]; then printf '%s\n' "$recria" | pg_container -i postgres:17-alpine \ psql "$(url_do_schema)" >> "$PROJECT_DIR/.deskcomm-banco.log" 2>&1 || true fi rm -f "$faltam_arq" existentes="$(pg_container -i postgres:17-alpine psql "$(url_do_schema)" -t -A -F'|' -c \ "select p.polname, c.relname from pg_policy p join pg_class c on c.oid=p.polrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public';" 2>/dev/null | LC_ALL=C sort -u)" # Mesma ordenação da primeira conferência, e pelo mesmo motivo: `LC_ALL=C` # é o que faz `sort` e `comm` concordarem. Ver o bloco acima. faltando="$(LC_ALL=C comm -23 <(printf '%s\n' "$esperadas") <(printf '%s\n' "$existentes") || true)" fi if [ -n "$faltando" ]; then c_red "⛔ REGRAS DE ISOLAMENTO AUSENTES — NÃO use o sistema até resolver." c_red " Sem elas o banco NEGA a leitura em silêncio: telas aparecem VAZIAS," c_red " sem erro nenhum, e isso é indistinguível de 'não há dados'." printf '%s\n' "$faltando" | sed 's/|/ na tabela /; s/^/ • /' | head -20 c_ylw " O log do banco está em .deskcomm-banco.log — mande-o para o suporte." c_ylw " Para voltar ao estado anterior: bash restore.sh" # ⛔ E A ATUALIZAÇÃO PARA AQUI. # # Antes ela seguia: imprimia este bloco vermelho e ia para o passo 5, que # sobe o app com a imagem nova. O CRM voltava ao ar sem regra de isolamento, # mostrando tela vazia para todo mundo — e o vermelho já tinha rolado para # fora da tela. Foi assim que o dono da instalação descobriu pelo funil, # horas depois, e não pela atualização. # # O `trap` (logo acima do passo do banco) devolve as peças do Supabase e # deixa o CRM parado de propósito. Um CRM fora do ar é um problema visível # que alguém resolve; um CRM no ar sem isolamento, não. REGRAS_FALTANDO="$faltando" exit 1 else c_grn "✓ regras de isolamento conferidas ($(printf '%s\n' "$esperadas" | grep -c . ) declaradas, todas no lugar)." # ── O BANCO RELIGA AQUI, e nao no fim do script ────────────────────────── # # MEDIDO na instalacao real em 2026-09-13: as pecas pararam as 03:10:18 e o # script so terminou as 03:13:09. QUASE TRES MINUTOS sem o Supabase — e nao # por falha: por DESENHO. A volta so acontecia no gatilho de saida, depois # de baixar imagem, recriar conteiner e esperar o healthcheck do app. # # Nada disso precisa do Supabase parado. O que precisava era o DDL, e ele # acabou na linha de cima — junto com a conferencia das regras, que e o # unico motivo de esperar ate aqui em vez de religar antes. # # Fica no ramo do SUCESSO de proposito: com regra faltando o script sai no # `exit 1` acima, e a volta das pecas vira responsabilidade do gatilho de # saida — que religa o banco e deixa o CRM parado, como deve. religar_o_supabase fi else c_ylw "⚠ supabase/baseline.sql não encontrado — pulei a parte do banco." fi # Retentativa não cura estes: a migration NÃO chegou, e seguir daqui trocava o # app por cima de um banco pela metade com status 0 — o "deu certo" do cron. # DEPOIS da conferência das regras de isolamento, nunca antes: ela recria as que # faltam e, se não conseguir, mantém o CRM parado. Sair antes dela deixaria o # trap subir o app sem regra — tela vazia para todo mundo. [ -z "$BANCO_RESTANTE" ] || die "O banco NÃO terminou limpo e os erros acima repetir não cura: a migration NÃO chegou. A atualização PARA aqui." [ -n "${DESKCOMM_AGENT_REPORT:-}" ] && eval "${DESKCOMM_AGENT_REPORT_CMD}" banco # ── 4.5 E-mails de acesso, para quem já estava instalado ──────────────────── # Só COM o token no ambiente, e por isso duas coisas: # # - é assim que um clone ANTIGO recebe os e-mails com a marca dele. O # `install.sh` dele nunca chamou este passo (ele não existia), e nenhuma # atualização toca em config de auth por conta própria; # - sem o token, o script imprimiria o passo manual — útil UMA vez, na # instalação, e ruído em toda atualização a partir daí. Atualização que # resmunga toda vez ensina a ignorar a saída dela. # # E sem o token, UMA vez na vida: quem instalou antes de a entrevista pedir o # token tem o Site URL do projeto em `localhost:3000` — o link de "esqueci minha # senha" leva a uma máquina que não existe fora do laptop de quem desenvolve. # Esse parque não é alcançado por nada: o `install.sh` dele não perguntou o # token, e o bloco acima só roda com token. Sem esta linha, a população # REALMENTE quebrada hoje nunca fica sabendo. # # Uma vez, e nunca mais — o marcador em disco garante isso, que é o que separa # um recado de um resmungo mensal. E o texto CONFERE, não acusa: quem já # configurou à mão está certo, e ler "seus e-mails estão quebrados" numa # atualização que correu bem seria alarme falso na cara de quem fez tudo certo. AVISO_SITE_URL="" MARCA_AVISO_SITE_URL="$PROJECT_DIR/.deskcomm-site-url-avisado" if [ -n "${SUPABASE_ACCESS_TOKEN:-}" ]; then bash "$KIT_DIR/marca-emails.sh" --projeto "$PROJECT_DIR" || true : > "$MARCA_AVISO_SITE_URL" 2>/dev/null || true # o passo automático rodou elif [ ! -e "$MARCA_AVISO_SITE_URL" ]; then AVISO_SITE_URL=1 fi # ── 5. App novo ────────────────────────────────────────────────────────────── step "Baixando a versão nova do app e reiniciando" # Imagem da TAG publicada (não "latest" solto): garante que o código (checkout # acima) e a imagem do container sejam sempre da mesma versão. Gravada no .env, # não só exportada: o compose lê a imagem de lá, e um `up -d` rodado à mão # depois voltaria pro ":latest" do install — desfazendo a atualização. # # As TRÊS imagens são gravadas juntas, na mesma versão. O worker e o scheduler # passaram a ter imagem publicada porque, antes, eram `build:`-only no compose: # `dc pull` os PULAVA ("Skipped - No image to be pulled") e o `dc up -d` abaixo # recriava o contêiner sobre a imagem velha, sem `--build`. Resultado: o worker # — que é o runtime do agente de IA — ficava congelado no código do dia da # instalação e atravessava todas as atualizações. Esta é a linha que conserta # isso para o parque já instalado, sem que ninguém precise editar arquivo. # # `gravar_imagens` também resolve o pull_policy pela mutabilidade da tag: como # aqui o alvo é sempre uma tag de versão (imutável), sai `missing`. Isso além de # tudo desfaz o "missing" que um rollback anterior deixava para trás — antes ele # ficava no .env para sempre, e o `up -d` manual do dono nunca mais puxava nada. # Lido ANTES de `gravar_imagens` corrigir — senão a informação some. Este é o # estado que a execução ANTERIOR deixou, e o dono nunca soube: o `update.sh` # antigo grava só `APP_IMAGE`, e o worker fica seguindo um canal móvel. PIN_FALTANDO_ANTES="$(pin_incompleto .env)" VERSAO_ALVO="${TARGET_TAG#v}" export APP_IMAGE="${IMG_APP}:${VERSAO_ALVO}" export WORKER_IMAGE="${IMG_WORKER}:${VERSAO_ALVO}" export SCHEDULER_IMAGE="${IMG_SCHEDULER}:${VERSAO_ALVO}" export VOICE_AGENT_IMAGE="${IMG_VOICE_AGENT}:${VERSAO_ALVO}" gravar_imagens .env "$VERSAO_ALVO" # Os segredos da chamada de voz (spec 18), para quem instalou antes dela existir. # LACUNA apenas — chave presente, mesmo vazia, é decisão de quem opera. Isto NÃO # liga a feature: sem `voz` em COMPOSE_PROFILES o serviço nem é criado. O que # isto compra é o dia em que o dono QUISER ligar não começar por inventar dois # segredos num editor dentro da VPS, que é o passo manual que a doutrina de # packaging proíbe. VOZ_CRIADA="$(completar_segredos_da_voz .env)" || VOZ_CRIADA="" [ -n "$VOZ_CRIADA" ] && c_ylw " (preparei as credenciais da chamada de voz no .env — ela segue DESLIGADA)" # `dc pull` falha se alguma das três imagens ainda não existir no registro — o # que acontece numa instalação atualizando para a primeira versão publicada # depois desta mudança, ou se um run de publicação quebrou. Nesse caso o compose # ainda tem `build:` ao lado do `image:` do worker e do scheduler, então o # `up -d` os constrói localmente: pior que puxar, melhor que não atualizar. if ! dc pull; then # A mensagem distingue os dois casos porque a consequência é oposta, e uma # frase tranquilizadora sobre o caso errado é o pior desfecho possível: o # `worker` e o `scheduler` têm `build:` ao lado do `image:` e o `up -d` os # constrói; o `app` NÃO tem, então se for a imagem dele que falta, o `up -d` # falha logo abaixo e a guarda dele constrói a versão aqui — e dizer "sigo # assim mesmo" teria sido mentira. if dc pull app >/dev/null 2>&1; then c_ylw "⚠ Não consegui puxar todas as imagens da versão ${VERSAO_ALVO}." c_ylw " A do app veio; o que faltar é construído aqui (mais lento, mesmo resultado)." else c_ylw "⚠ Não consegui puxar a imagem do APP na versão ${VERSAO_ALVO}." c_ylw " Causas comuns: a versão ainda está publicando, ou o pacote está privado no GHCR." c_ylw " Vou tentar subir mesmo assim — se falhar, rode de novo em alguns minutos." fi fi # A rede do proxy externo é declarada como EXTERNA no compose: se ela sumiu # (um `docker network prune`, ou o `down -v` que o próprio kit ensina como # caminho de recomeço), o `up -d` abaixo morre em "network X declared as # external, but could not be found" — e este script roda sozinho pelo agent.sh, # então ninguém está lendo a tela para decifrar isso. Mesma função do install.sh. garantir_rede_do_proxy # O `up -d` falha por imagem ausente no disco e, com ele, a atualização inteira: # numa VPS de arquitetura diferente da das imagens publicadas o `pull` acima não # traz nada, e o `app` — ao contrário do worker e do scheduler — não tem `build:` # ao lado do `image:`, então o Compose não tem como construí-lo. Sem esta guarda # o script terminava como se tivesse dado certo e o dono ficava na versão velha # sem saber; pelo botão "Atualizar" do site, pior: o agente roda sozinho no cron # e não há ninguém lendo a tela para desconfiar. # # O gatilho é o CÓDIGO DE SAÍDA, nunca o texto do erro — arquitetura da VPS, tag # ainda publicando, pacote privado no registro e registro fora do ar caem no # mesmo caminho, sem depender de casar em inglês uma frase que o Docker muda. O # custo é o pior caso: um `up -d` que falhe por outro motivo gasta o build antes # de desistir. É o preço de não adivinhar. # ⛔ O AVISO DESCE AQUI, e nao no gatilho de saida. # # MEDIDO na atualizacao real para a v1.17.21: o gatilho roda depois de mais # quatro etapas — baixar imagem, recriar, conferir saude, conferir automacoes. E # o roteamento do aviso tem prioridade 500, ACIMA da regra do app. Resultado: o # CRM voltava ao ar e quem abrisse continuava vendo "estamos atualizando" por # minutos, com o sistema ja funcionando. Aviso que mente e pior que aviso nenhum: # a pessoa vai embora achando que o sistema esta fora. # # `restaurar_servicos` segue chamando o mesmo `manutencao_desce` — ele e # `docker rm -f ... || true`, idempotente de proposito, e la ele cobre o caminho # de ERRO, onde este ponto aqui nunca chega a ser alcancado. manutencao_desce CONSTRUIU_AQUI="" if ! dc up -d; then if construir_aqui_e_subir "$VERSAO_ALVO"; then CONSTRUIU_AQUI=1 else c_red "✖ A atualização não terminou: nem as imagens prontas desta versão nem a construção aqui funcionaram." c_ylw " O CRM segue no ar, na versão anterior. O erro está logo acima;" c_ylw " para reproduzir só a construção: docker compose $(dc_files) -f ${COMPOSE_BUILD} build" exit 1 fi fi # O Caddyfile entra no container por bind mount de UM ARQUIVO, e bind mount de # arquivo fica preso ao inode. O `git pull` não edita o arquivo: escreve outro e # renomeia, gerando inode novo — o container continua lendo o antigo, para # sempre. Medido nesta VPS: host inode 3283869, container 3271833, com o # conteúdo velho lá dentro. # # Sem este force-recreate, TODA mudança de proxy enviada numa atualização # (inclusive correção de segurança na borda) some em silêncio: o update diz # "concluída" e a configuração antiga segue valendo. # # Com proxy externo não há Caddy para recriar — e não basta o profile inativo # do override: nomear o serviço explicitamente (`up -d ... caddy`) ATIVA o # profile dele no Compose e sobe o contêiner assim mesmo, indo bater de frente # com o Traefik nas portas 80/443. O resultado era um "⚠ não consegui recriar o # proxy" em TODA atualização de quem usa proxy externo: alarme falso, num # momento em que o dono precisa confiar no que está lendo. case "${REVERSE_PROXY:-caddy}" in traefik|npm) c_grn "✓ proxy externo (${REVERSE_PROXY}): o Caddy não é usado aqui — nada a recarregar" ;; *) dc up -d --force-recreate --no-deps caddy >/dev/null 2>&1 \ && c_grn "✓ proxy recarregado com a configuração desta versão" \ || c_ylw "⚠ não consegui recriar o proxy — rode: docker compose $(dc_files) up -d --force-recreate caddy" ;; esac # ── 6. O app voltou no ar? ─────────────────────────────────────────────────── step "Conferindo se o app voltou no ar" ok="" wait_app_healthy 20 3 >/dev/null && ok=1 if [ -n "$ok" ]; then # O marcador que a guarda de arquitetura lê (#1778). Instalações antigas só # seriam reconhecidas pelo contêiner — que um `down` sem `-v` apaga. Gravar # aqui, com o app saudável, fecha esse caso a partir desta atualização. # Falhar em gravar não desfaz nada: a guarda segue caindo no sinal do # contêiner para arquiteturas que ainda não têm imagens publicadas. marcar_instalacao_feita "$TARGET_TAG" || true if [ -n "$BANCO_INCOMPLETO" ]; then c_ylw "⚠ App no ar e saudável, mas o banco NÃO terminou limpo — o que fazer está no fim desta saída." else c_grn "✓ Atualização concluída — app no ar e saudável." # Dito AQUI, no fim, porque é o que sobra na tela do site: o agent.sh manda o # rabo da saída, e o build local encheu as linhas de cima com a própria # construção. Sem esta frase o dono lê "concluída" e não faz ideia de que a # VPS dele passou 20 minutos construindo imagens. if [ -n "$CONSTRUIU_AQUI" ]; then c_ylw " (as três imagens desta versão foram construídas aqui nesta VPS: as" c_ylw " prontas não servem para a arquitetura dela. Toda atualização aqui" c_ylw " segue o mesmo caminho — é mais lento e não precisa de nada manual.)" fi fi # Dito no fim, e não no início, porque é aqui que o dono lê. Se a execução # anterior deixou o pin pela metade, ele nunca soube — a tela dizia "concluída" # e o worker seguia um canal móvel. Agora ele sabe que existiu e que acabou. if [ -n "$PIN_FALTANDO_ANTES" ]; then c_ylw " (de quebra: a versão de $PIN_FALTANDO_ANTES estava solta e foi fixada agora)" fi # Dito aqui pelo mesmo motivo do pin: é no fim que o dono lê. if [ -n "$AVISO_SITE_URL" ]; then DOM_AVISO="$(printf '%s' "${NEXT_PUBLIC_APP_URL:-https://SEU_DOMINIO}")" cat < "$MARCA_AVISO_SITE_URL" 2>/dev/null || true fi else c_ylw "⚠ Atualizei, mas o app não respondeu 'ok'. Veja os logs:" c_ylw " docker compose $(dc_files) logs --tail=50 app" # Código de saída != 0: é o que o agent.sh usa pra saber que precisa voltar # pra imagem anterior (guardada por ele ANTES do pull). Sem isso, não existe # rede de proteção — o app novo, quebrado, ficaria no ar sem ninguém saber. exit 1 fi # ── 7. Automações (cron do drain de eventos; o da tela já subiu no bloco 0) ── step "Conferindo as automações" ensure_encryption_key .env setup_event_log_drain_cron # ── Fim: o banco que não terminou limpo é a ÚLTIMA coisa na tela ───────────── # Na v1.27.3 de uma VPS real o aviso do passo 4 ficou soterrado por centenas de # linhas do docker pull, e as últimas linhas da tela eram ✓ verdes. É aqui, # depois de tudo, que o dono lê. if [ -n "$BANCO_INCOMPLETO" ]; then step "Atenção: o banco NÃO terminou limpo nesta atualização" c_ylw " Os avisos completos estão no passo \"Atualizando o banco de dados\", acima." orientar_banco_incompleto fi