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

373 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Imagem do ConectaSUS V2 — API + frontend
![Arquitetura CI/CD do ConectaSUS V2](ci-cd-architecture.svg)
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 é `.`):
```bash
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
```bash
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`](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:
```bash
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:
```bash
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()`](../api/app/Console/Commands/PublishCommand.php)
usa `DB::connection('schema')`. Com `migrate --database=` direto isso não se aplica.
### Workflow manual de migrations
O workflow [`.gitea/workflows/migrate.yml`](../.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:
```bash
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`](../.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`:
```bash
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:
```text
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:
```text
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`:
```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`)