Files
Antonio Lopes dos Santos afec302c3e
All checks were successful
build-api-image / build (push) Successful in 51s
init commit
2026-08-04 16:52:02 -03:00

17 KiB
Raw Permalink Blame History

Imagem do ConectaSUS V2 — API + frontend

Arquitetura CI/CD do ConectaSUS V2

Imagem de produção com o código encapsulado: o vendor/, os caches de boot (packages.php, services.php, routes-v7.php) e o dist/ do frontend já vão prontos. Substitui o modelo de bind mount de /var/www + publish.sh dentro do container.

API e frontend ficam no mesmo container, como em produção: Apache serve a API na :80 e o SPA na :8080. Uma tag, um artefato, um rollback — as duas partes não têm como ficar dessincronizadas.

O que entra e o que não entra

Na imagem código da API, vendor/ (--no-dev), autoload otimizado, route cache, dist/ do frontend, vhosts do Apache, configs do supervisor
Fora da imagem .env, connections.json, storage/ (dados e logs)

config/database.php:36 faz file_get_contents(base_path('connections.json')) no boot — sem esse arquivo a aplicação não sobe. O entrypoint valida os dois arquivos e falha com mensagem clara em vez de deixar o Apache subir quebrado.

O npm existe só no stage de build. Para o runtime atravessa apenas o dist/: nem node_modules, nem fonte .vue, nem webpack. Produção deixa de compilar qualquer coisa — nem PHP, nem JS.

Estrutura

O contexto do build é a raiz do application/, porque o build precisa enxergar api/ e frontend/ ao mesmo tempo. Por isso Dockerfile e docker/ vivem na raiz, e não dentro de api/.

application/
├── Dockerfile              # 3 stages: frontend-build | build (composer) | runtime
├── .dockerignore
├── api/
├── frontend/
└── docker/
    ├── entrypoint.sh
    ├── apache/
    │   ├── ports.conf                   # Listen 80 + Listen 8080
    │   ├── 000-default.conf             # :80   -> /var/www/api/public
    │   └── frontend.conf                # :8080 -> /var/www/frontend/dist
    └── supervisor/conf.d/
        ├── apache.conf
        └── websockets.conf              # :6001

O frontend.conf traz FallbackResource /index.html: o vue-router está em mode: 'history' (frontend/src/router/index.js) e, sem esse fallback, F5 em rota interna devolve 404.

Build local

A partir da raiz do application/ (o contexto é .):

docker build -f Dockerfile \
  --build-arg APP_VERSION=2.59.3 \
  -t conectasus:2.59.3 \
  .

O stage do frontend leva ~7 min (webpack) e roda com NODE_OPTIONS=--max-old-space-size=4096. O Node 10 da imagem base limita o heap a ~1.5GB por padrão, e este projeto estoura esse teto com FATAL ERROR: JavaScript heap out of memory.

O COPY do dist/ é o último do stage de runtime, de propósito: assim uma alteração só de frontend não invalida as camadas pesadas da API (vendor/, caches de boot), e o push sobe só o delta.

Executando

docker run -d --name conectasus \
  -p 80:80 -p 8080:8080 -p 6001:6001 \
  -v /dados/producao/conectasus/api/.env:/var/www/api/.env:ro \
  -v /dados/producao/conectasus/api/connections.json:/var/www/api/connections.json:ro \
  -v /dados/producao/conectasus/config.js:/var/www/frontend/config.js:ro \
  -v /dados/producao/conectasus/storage:/var/www/api/storage \
  192.168.0.41:3001/rkm/conectasus-api:2.59.3

São três arquivos de configuração, todos obrigatórios — o entrypoint recusa subir sem qualquer um deles. O config.js é a config de runtime do frontend (modelo em config.js.example): é ele que faz a mesma imagem servir teste, homologação e produção sem rebuild.

O bind mount de arquivo único exige que o arquivo já exista no host — se o caminho não existir, o Docker cria um diretório com aquele nome e o entrypoint falha como se o arquivo estivesse faltando.

Variáveis de runtime opcionais:

Variável Padrão Efeito
CACHE_CONFIG false true gera bootstrap/cache/config.php no boot. Ganha performance, mas exige restart do container a cada mudança de .env/connections.json.

Migrations

O WORKDIR da imagem e /var/www, nao /var/www/api. Comandos artisan precisam de -w /var/www/api (ou --workdir), senao falham com Could not open input file: artisan.

Não são executadas nem no build nem no start do container. A aplicação de migrations por município é uma esteira separada.

O entrypoint só roda no start, então docker exec passa por fora dele. Tudo o que o comando precisa já está na imagem: database/migrations, database/seeders, database/utils e o doctrine/dbal (usado nos ->change()). O nome passado em --database vem do connections.json montado em runtime.

Em um container já rodando:

docker exec -w /var/www/api -it conectasus php artisan migrate --database=municipio --force

Em um container descartável, sem depender de nenhum já no ar — o formato que a esteira de migration deve usar, já que só precisa da tag da imagem e dos mounts de config:

docker run --rm \
  -v /dados/producao/conectasus/api/.env:/var/www/api/.env:ro \
  -v /dados/producao/conectasus/api/connections.json:/var/www/api/connections.json:ro \
  -v /dados/producao/conectasus/storage:/var/www/api/storage \
  --workdir /var/www/api \
  --entrypoint php \
  192.168.0.41:3001/rkm/conectasus-api:2.59.3 \
  artisan migrate --database=municipio --force

Pontos de atenção:

--force Obrigatório. Com APP_ENV=production o migrate abre prompt de confirmação e aborta em execução não interativa.
--database=<nome> Precisa bater com uma chave do connections.json. Nome inexistente falha com Database connection [<nome>] not configured.
Código executado É o da tag da imagem, não o do git checkout do host — a esteira de migration deve usar a mesma tag que está em produção.
Persistência Migration escreve no banco, não no filesystem do container. Nada se perde em restart ou troca de tag.

Se em algum momento for usado o php artisan publish (que percorre todos os clientes de uma vez), aí o connections.json precisa ter também a entrada schema — PublishCommand::databaseExists() usa DB::connection('schema'). Com migrate --database= direto isso não se aplica.

Workflow manual de migrations

O workflow .gitea/workflows/migrate.yml aplica migrations em uma imagem já publicada, sem executar deploy nem construir uma imagem. Ele exige quatro dados no botão Run workflow:

Campo Exemplo Regra
ambiente teste Escolha explícita entre teste, homolog e prod.
versao 2.59.3 Deve ser a tag da imagem que contém a migration.
municipios anhembi,aparecida Lista explícita de chaves do connections.json; não existe opção todos.
confirmacao MIGRAR Sem o texto literal, o workflow recusa executar.

Para cada município, o workflow cria um container descartável com a imagem escolhida, os mounts .env, connections.json e storage/ do ambiente, e roda php artisan migrate --database=<municipio> --force. O container temporário não é a aplicação em produção e é removido ao fim; a alteração permanece no banco de dados.

Antes de executar a primeira migration, o script verifica que todos os nomes informados existem no connections.json, sem imprimir o conteúdo do arquivo. Depois disso os municípios são processados em sequência. Uma falha interrompe a lista; os bancos anteriores podem já ter sido migrados e devem ser verificados antes de uma nova tentativa.

Rollback

Voltar o código é trocar a tag da imagem — segundos, sem rebuild:

docker stop conectasus-api && docker rm conectasus-api
docker run -d --name conectasus-api ... 192.168.0.41:3001/rkm/conectasus-api:2.59.2

O que não volta é o schema do banco, já migrado para a versão nova. Se isso quebra ou não depende do tipo de migration aplicada:

Tipo de migration no up() Rollback da imagem
Aditiva (coluna nova, tabela nova, índice) Seguro. O código antigo ignora o que não conhece.
Destrutiva (dropColumn, dropIfExists, renameColumn) Quebra. O código antigo referencia o que deixou de existir.

Historicamente o segundo caso é raro: 3 de 255 migrations desde out/2025. Por isso a esteira roda o passo Avaliar reversibilidade das migrations, que compara o release com a tag anterior e marca no job summary se o rollback é seguro. O passo é informativo e não bloqueia o build.

Quando o release for marcado como destrutivo, o rollback deixa de ser opção barata. As saídas são, em ordem de preferência:

  1. Backup do banco antes de migrar — permite voltar imagem e schema juntos.
  2. Hotfix para frente — corrigir na versão nova em vez de voltar.
  3. migrate:rollback --database=<municipio> --step=N — último recurso. 97,5% das migrations têm down() implementado, mas rollback de schema com dados em produção tem risco próprio.

Uma alternativa que elimina o problema na origem é adiar a parte destrutiva: o release N para de usar a coluna, o release N+1 a remove. Aí toda migration é aditiva dentro da janela em que o rollback ainda pode ser necessário.

Esteira (Gitea Actions)

Workflow: .gitea/workflows/build-api-image.yml (precisa ficar na raiz do repositório — é onde o Gitea procura).

Gatilhos:

  • push em develop ou feat_CI_CD_TEST — constrói, publica e faz deploy no ambiente de teste; a tag é o nome da branch em minúsculas e latest não muda;
  • push em release-* — constrói, publica e faz deploy em homologação;
  • push em master — não constrói nem implanta; o merge só prepara o commit que será marcado como release;
  • push de tag 2.* (ex.: 2.59.3) — sempre constrói a imagem a partir do commit marcado, publica :2.59.3 (e latest, se PUSH_LATEST=true) e para. Deploy e migrations de produção são ações manuais separadas.

Sem prefixo v: o filtro é 2.*, então 2.59.3 dispara e v2.59.3 não — e a ausência de execução é silenciosa, sem erro em lugar nenhum.

O Gitea publica a tag da imagem em minúsculas. Um build da branch feat_CI_CD_TEST vira :feat_ci_cd_test no registry.

Release de produção

O responsável cria a tag no commit já aceito em master:

git tag 2.60.0
git push origin 2.60.0

A tag constrói e publica a imagem, mas não toca em nenhum ambiente. Antes da liberação, valide o artefato oficial no ambiente de teste pelo workflow manual:

deploy  → ambiente=teste, versao=2.60.0
migrate → ambiente=teste, versao=2.60.0, municipios=<lista>, confirmacao=MIGRAR

No horário de liberação, o operador repete a operação de deploy com ambiente=prod e a mesma versão 2.60.0; caso haja migrations, aciona depois o workflow migrate com a mesma versão e os municípios de produção escolhidos.

Enquanto produção estiver em outro servidor, o caminho recomendado é o job manual conectar por SSH ao host de produção e executar o script de deploy lá. Isso ainda precisa dos dados reais do servidor (host, usuário de deploy, chave, fingerprint SSH, caminho do script e do diretório de configuração); eles não devem ser inventados ou gravados no repositório.

Variables — Settings › Actions › Variables

Nenhum host está fixo no YAML; tudo sai daqui (com fallback embutido).

Variable Exemplo Default
REGISTRY_HOST 192.168.0.41:3001 192.168.0.41:3001
REGISTRY_USER ci-bot quem disparou a tag
API_IMAGE_NAME rkm/conectasus-api rkm/conectasus-api
API_BASE_IMAGE tirkm/v2-application:6 tirkm/v2-application:6
CONTEXT_DIR . .
DOCKERFILE Dockerfile Dockerfile
API_DIR api api
PUSH_LATEST true / false true
AUTO_MIGRATE_TEST true / false false — executa migrations automaticamente só quando o ambiente da branch é teste.
MIGRATION_CONNECTIONS_TEST anhembi,aparecida sem padrão — lista explícita de conexões municipais para a migration automática de teste.
TEST_SSH_HOST 192.168.0.44 opcional; esse é o padrão quando o transporte é SSH.
TEST_SSH_PORT 22 22
SSH_USER_TEST ci-deploy obrigatório quando o transporte é SSH.

CONTEXT_DIR é o contexto do build (a raiz, que enxerga api/ e frontend/). API_DIR é o subdiretório da API, usado só nas checagens que leem arquivos do repositório — composer.json e database/migrations. Não confundir os dois: foi por herdarem o mesmo valor que essas checagens quebraram quando o contexto subiu.

Secrets — Settings › Actions › Secrets

Secret Obrigatório Para quê
REGISTRY_TOKEN sim PAT do Gitea com escopo write:package (e read:package)
DOCKERHUB_USER / DOCKERHUB_TOKEN só se tirkm/ for privado pull da imagem base
SSH_KEY_TEST sim, para deploy de teste chave privada do usuário SSH de deploy no host de teste.
SSH_KNOWN_HOSTS_TEST ou SSH_KNOWN_HOST_TEST sim, para deploy de teste fingerprint SSH do host de teste, no formato de known_hosts.

O REGISTRY_TOKEN é obrigatório apesar do fallback no script: neste Gitea o secrets.GITEA_TOKEN do job resolve para string vazia, e o set -u não pega isso (a variável está definida, só que vazia). O resultado é um docker login com senha vazia e um unauthorized que parece problema de rede ou de TLS.

Cadastre o secret no repositório ou na organização. O cofre pessoal (avatar → Settings → Actions → Secrets) não é lido por repos da organização — o campo chega vazio e o sintoma é idêntico ao de secret inexistente.

Deploy de teste por SSH

Todo deploy de teste usa SSH. Configure:

TEST_SSH_HOST=192.168.0.44
TEST_SSH_PORT=22
SSH_USER_TEST=ci-deploy

O job abre SSH para o host .44, não para o container act_runner. Ele envia temporariamente deploy.sh e teste.env, faz login do Docker remoto no registry, executa o deploy com health check e remove os arquivos temporários. .env, connections.json, config.js e storage/ não atravessam SSH: eles continuam no CONFIG_DIR do host de teste.

Registre o host em SSH_KNOWN_HOSTS_TEST (ou SSH_KNOWN_HOST_TEST) com fingerprint conferido fora da esteira. Não use StrictHostKeyChecking=no. O usuário SSH deve ser dedicado ao deploy; como ele opera Docker, trate-o como acesso administrativo ao host.

Pré-requisitos

  1. act_runner registrado com acesso ao daemon Docker do host — registre em modo host, ou monte /var/run/docker.sock no runner. O registro é feito uma única vez e persiste em /data/.runner; o token de registro do compose só é lido no primeiro boot.

  2. Registry em HTTP: 192.168.0.41:3001 não tem TLS, então o Docker do runner precisa confiar nele. Em /etc/docker/daemon.json:

    { "insecure-registries": ["192.168.0.41:3001"] }
    

    Seguido de systemctl reload docker — insecure-registries é recarregável via SIGHUP, então não é preciso reiniciar o daemon nem derrubar containers. Vale para todo host que faça push ou pull dessa imagem, incluindo os servidores de deploy.

  3. Container registry habilitado no Gitea ([packages] ENABLED = true no app.ini — já responde em /v2/).

  4. Packages: Write no time da organização. Publicar em rkm/ exige essa unidade no time ao qual a conta do CI pertence — é permissão separada da de repositórios, e sem ela o docker login passa e só o push falha, com unauthorized: authentication required. Configura-se em organização rkm → Settings → Teams → <time> → Units.

    Para diagnosticar: o endpoint /v2/token não serve — ele devolve Scope: "all" mesmo sem permissão, porque a checagem real só acontece no upload do blob. O teste que decide é tentar o push num namespace pessoal (<usuario>/teste): se lá funcionar e em rkm/ não, é permissão de org, não escopo de PAT.

Etapas do job

  1. Checkout
  2. Metadados (versão, tags, data)
  3. Confere composer.json.version contra a tag (aviso, não bloqueia)
  4. Avalia reversibilidade das migrations contra a tag anterior (informativo)
  5. Login no Docker Hub (pulado se o secret não existir) e no registry do Gitea
  6. docker build — frontend (npm ci + npm run build) e API (composer install) em stages paralelos, com só o dist/ e o código atravessando para o runtime
  7. Smoke test: a app boota com config; .env/connections.json não estão na imagem; o entrypoint recusa subir sem config; o dist/ está publicado; o Apache passa no configtest e expõe :80 e :8080
  8. docker push de :<versão> e, se PUSH_LATEST=true e for build de tag, de :latest
  9. Resumo no job summary + limpeza (docker rmi, docker logout)