373 lines
17 KiB
Markdown
373 lines
17 KiB
Markdown
# Imagem do ConectaSUS V2 — API + frontend
|
||
|
||

|
||
|
||
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`)
|