fix(docker): inicializar permissoes do storage com seguranca
This commit is contained in:
1 parent
e142a44c01
commit
2be97ea969
8 files changed
+421
-3
No files matched your search
Executable
+185
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Permissões do armazenamento local (volume montado em /app/.data).
|
||||||
|
#
|
||||||
|
# Cada caso cria o próprio volume descartável com prefixo único e remove só os
|
||||||
|
# volumes que criou. Nunca toca nos volumes do compose
|
||||||
|
# (adaptador-planilhas-local-postgres, adaptador-planilhas-local-storage).
|
||||||
|
#
|
||||||
|
# ADAPTER_IMAGE=adaptador-planilhas:local bash .github/scripts/storage-permissions.sh
|
||||||
|
set -euo pipefail
|
||||||
|
# Git Bash no Windows converteria /app/... em caminho do Windows.
|
||||||
|
export MSYS_NO_PATHCONV=1
|
||||||
|
|
||||||
|
IMAGE="${ADAPTER_IMAGE:-adaptador-planilhas:local}"
|
||||||
|
PREFIX="adaptador-storage-test-$$-$RANDOM"
|
||||||
|
CREATED=()
|
||||||
|
CONTAINERS=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
for c in "${CONTAINERS[@]:-}"; do [ -n "$c" ] && docker rm -f "$c" >/dev/null 2>&1 || true; done
|
||||||
|
for v in "${CREATED[@]:-}"; do [ -n "$v" ] && docker volume rm "$v" >/dev/null 2>&1 || true; done
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Define VOL no shell atual: dentro de $(...) o registro em CREATED se perderia.
|
||||||
|
new_volume() {
|
||||||
|
VOL="$PREFIX-$1"
|
||||||
|
docker volume create "$VOL" >/dev/null
|
||||||
|
CREATED+=("$VOL")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mesmas restrições do serviço storage-init do docker-compose.yml.
|
||||||
|
prepare() {
|
||||||
|
docker run --rm --network none --user 0:0 \
|
||||||
|
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \
|
||||||
|
--cap-add DAC_READ_SEARCH --cap-add SETUID --cap-add SETGID \
|
||||||
|
--security-opt no-new-privileges:true \
|
||||||
|
-v "$1:/app/.data" "$IMAGE" prepare-storage "${@:2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
as_app() { local v="$1"; shift; docker run --rm --network none -v "$v:/app/.data" "$IMAGE" exec "$@"; }
|
||||||
|
as_root() { local v="$1"; shift; docker run --rm --network none --user 0:0 -v "$v:/app/.data" "$IMAGE" exec "$@"; }
|
||||||
|
|
||||||
|
# Estado do volume como root: dono, grupo, modo, tamanho e caminho, mais o hash dos arquivos.
|
||||||
|
snapshot() {
|
||||||
|
as_root "$1" sh -c 'cd /app/.data && find . -printf "%U:%G %m %s %p\n" | sort && find . -type f -exec sha256sum {} + | sort -k2'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_owner_mode() {
|
||||||
|
local v="$1" path="$2" want="$3"
|
||||||
|
local got
|
||||||
|
got=$(as_root "$v" stat -c '%u:%g %a' "$path")
|
||||||
|
[ "$got" = "$want" ] || { echo "ERRO: $path = '$got', esperado '$want'"; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() { echo "ERRO: $*"; exit 1; }
|
||||||
|
|
||||||
|
echo "== imagem: $IMAGE =="
|
||||||
|
docker image inspect "$IMAGE" >/dev/null
|
||||||
|
|
||||||
|
echo "== A. volume novo =="
|
||||||
|
new_volume a; VA="$VOL"
|
||||||
|
# A1: sem storage-init, o volume vazio herda dono e modo da imagem.
|
||||||
|
expect_owner_mode "$VA" /app/.data "1001:1001 750"
|
||||||
|
expect_owner_mode "$VA" /app/.data/storage "1001:1001 750"
|
||||||
|
as_app "$VA" sh -c ': > /app/.data/storage/ok && rm /app/.data/storage/ok' || fail "uid 1001 não escreve no volume novo"
|
||||||
|
# A2: com storage-init, o mesmo resultado e a prova de escrita.
|
||||||
|
prepare "$VA" | tee /tmp/storage-a.log
|
||||||
|
grep -q "STORAGE_WRITABLE" /tmp/storage-a.log || fail "storage-init não confirmou escrita"
|
||||||
|
echo "ok: volume novo 1001:1001 750, escrita pelo uid 1001"
|
||||||
|
|
||||||
|
echo "== B. volume existente, criado antes da correção (raiz root:root 755) =="
|
||||||
|
new_volume b; VB="$VOL"
|
||||||
|
as_root "$VB" sh -c '
|
||||||
|
chown 0:0 /app/.data /app/.data/storage && chmod 755 /app/.data /app/.data/storage
|
||||||
|
mkdir -p /app/.data/storage/ws-alpha/modelos
|
||||||
|
printf "conteudo do modelo alpha\n" > /app/.data/storage/ws-alpha/modelos/modelo.xlsx
|
||||||
|
chown -R 1001:1001 /app/.data/storage/ws-alpha
|
||||||
|
printf "arquivo legado de root\n" > /app/.data/storage/legado.bin
|
||||||
|
chmod 640 /app/.data/storage/legado.bin'
|
||||||
|
# Antes da preparação o uid 1001 não escreve na raiz: é o defeito original.
|
||||||
|
if as_app "$VB" sh -c ': > /app/.data/storage/x' 2>/dev/null; then fail "o cenário B deveria começar sem escrita"; fi
|
||||||
|
prepare "$VB" 2>&1 | tee /tmp/storage-b.log
|
||||||
|
grep -q "STORAGE_WRITABLE" /tmp/storage-b.log || fail "storage-init não confirmou escrita"
|
||||||
|
grep -q "AVISO: 1 item" /tmp/storage-b.log || fail "o arquivo de root deveria ser só reportado"
|
||||||
|
expect_owner_mode "$VB" /app/.data "1001:1001 750"
|
||||||
|
expect_owner_mode "$VB" /app/.data/storage "1001:1001 750"
|
||||||
|
# Conteúdo e dono dos arquivos existentes intactos; só os dois diretórios mudaram.
|
||||||
|
expect_owner_mode "$VB" /app/.data/storage/legado.bin "0:0 640"
|
||||||
|
expect_owner_mode "$VB" /app/.data/storage/ws-alpha/modelos/modelo.xlsx "1001:1001 644"
|
||||||
|
test "$(as_root "$VB" cat /app/.data/storage/ws-alpha/modelos/modelo.xlsx)" = "conteudo do modelo alpha" \
|
||||||
|
|| fail "conteúdo do modelo alterado"
|
||||||
|
test "$(as_root "$VB" cat /app/.data/storage/legado.bin)" = "arquivo legado de root" || fail "conteúdo legado alterado"
|
||||||
|
as_app "$VB" sh -c ': > /app/.data/storage/novo && rm /app/.data/storage/novo' || fail "uid 1001 não escreve após preparar"
|
||||||
|
echo "ok: volume existente preservado e liberado para o uid 1001"
|
||||||
|
|
||||||
|
echo "== C. idempotência =="
|
||||||
|
s1=$(snapshot "$VB")
|
||||||
|
prepare "$VB" > /tmp/storage-c1.log 2>&1
|
||||||
|
prepare "$VB" > /tmp/storage-c2.log 2>&1
|
||||||
|
s2=$(snapshot "$VB")
|
||||||
|
[ "$s1" = "$s2" ] || { diff <(echo "$s1") <(echo "$s2") || true; fail "a preparação repetida alterou o volume"; }
|
||||||
|
if grep -q -- "->" /tmp/storage-c2.log; then fail "a segunda execução não deveria alterar dono nem modo"; fi
|
||||||
|
echo "ok: duas execuções extras, nenhum byte, dono ou modo alterado"
|
||||||
|
|
||||||
|
echo "== C2. --fix-existing é limitado ao volume e preserva conteúdo =="
|
||||||
|
prepare "$VB" --fix-existing > /tmp/storage-c3.log 2>&1
|
||||||
|
expect_owner_mode "$VB" /app/.data/storage/legado.bin "1001:1001 640"
|
||||||
|
test "$(as_root "$VB" cat /app/.data/storage/legado.bin)" = "arquivo legado de root" || fail "conteúdo alterado"
|
||||||
|
if grep -q "AVISO" /tmp/storage-c3.log; then fail "ainda há item com outro dono"; fi
|
||||||
|
echo "ok: dono ajustado só quando pedido, conteúdo intacto"
|
||||||
|
|
||||||
|
echo "== D. web e worker compartilham o volume =="
|
||||||
|
new_volume d; VD="$VOL"
|
||||||
|
prepare "$VD" >/dev/null
|
||||||
|
# Um contêiner grava como a web grava, outro lê e apaga como o worker faz na limpeza.
|
||||||
|
as_app "$VD" sh -c 'mkdir -p /app/.data/storage/ws/jobs && printf saida > /app/.data/storage/ws/jobs/out.csv'
|
||||||
|
test "$(as_app "$VD" cat /app/.data/storage/ws/jobs/out.csv)" = "saida" || fail "outro contêiner não lê o arquivo"
|
||||||
|
as_app "$VD" rm /app/.data/storage/ws/jobs/out.csv || fail "outro contêiner não apaga o arquivo"
|
||||||
|
# O comando web real passa da verificação e responde liveness.
|
||||||
|
WEB="$PREFIX-web"
|
||||||
|
CONTAINERS+=("$WEB")
|
||||||
|
docker run -d --name "$WEB" --network none -e ADAPTER_ENV=development -v "$VD:/app/.data" "$IMAGE" web >/dev/null
|
||||||
|
ok=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if docker exec "$WEB" node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; then ok=1; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ -n "$ok" ] || { docker logs "$WEB"; fail "a web não subiu com o volume preparado"; }
|
||||||
|
docker logs "$WEB" 2>&1 | grep -q STORAGE_NOT_WRITABLE && fail "a web reportou storage sem escrita"
|
||||||
|
# O comando worker real passa da verificação de storage (falha depois, por falta de banco/Redis, o que não é deste teste).
|
||||||
|
WORKER="$PREFIX-worker"
|
||||||
|
CONTAINERS+=("$WORKER")
|
||||||
|
docker run -d --name "$WORKER" --network none -e ADAPTER_ENV=development -e ADAPTER_JOB_MODE=worker \
|
||||||
|
-v "$VD:/app/.data" "$IMAGE" worker >/dev/null
|
||||||
|
sleep 6
|
||||||
|
wlog=$(docker logs "$WORKER" 2>&1)
|
||||||
|
docker rm -f "$WORKER" >/dev/null
|
||||||
|
echo "$wlog" | grep -q STORAGE_NOT_WRITABLE && { echo "$wlog"; fail "o worker reportou storage sem escrita"; }
|
||||||
|
echo "$wlog" | grep -q '\[worker\]\|DATABASE\|Redis\|ECONNREFUSED\|pglite\|migration' \
|
||||||
|
|| { echo "$wlog"; fail "o worker não chegou ao código da aplicação"; }
|
||||||
|
echo "ok: escrita, leitura e remoção entre contêineres; web e worker passam da verificação"
|
||||||
|
|
||||||
|
echo "== E. usuário não privilegiado =="
|
||||||
|
test "$(docker exec "$WEB" id -u)" = "1001" || fail "a web não roda como 1001"
|
||||||
|
test "$(docker exec "$WEB" sh -c 'grep CapEff /proc/1/status | cut -f2')" = "0000000000000000" || fail "a web tem capacidades"
|
||||||
|
test "$(docker run --rm --network none "$IMAGE" exec id -u)" = "1001" || fail "o usuário padrão da imagem não é 1001"
|
||||||
|
# prepare-storage recusa rodar sem root, então não serve de atalho para escalar.
|
||||||
|
if docker run --rm --network none -v "$VD:/app/.data" "$IMAGE" prepare-storage >/dev/null 2>&1; then
|
||||||
|
fail "prepare-storage aceitou rodar como 1001"
|
||||||
|
fi
|
||||||
|
docker rm -f "$WEB" >/dev/null
|
||||||
|
test "$(docker image inspect "$IMAGE" --format "{{.Config.User}}")" = "adaptador" || fail "a imagem não declara USER adaptador"
|
||||||
|
echo "ok: web e worker como 1001 sem capacidades; preparação só como root"
|
||||||
|
|
||||||
|
echo "== F. falha segura com armazenamento sem escrita =="
|
||||||
|
new_volume f; VF="$VOL"
|
||||||
|
as_root "$VF" sh -c 'chown 0:0 /app/.data /app/.data/storage && chmod 755 /app/.data /app/.data/storage'
|
||||||
|
for proc in web worker; do
|
||||||
|
set +e
|
||||||
|
out=$(docker run --rm --network none -e ADAPTER_ENV=development -v "$VF:/app/.data" "$IMAGE" "$proc" 2>&1)
|
||||||
|
code=$?
|
||||||
|
set -e
|
||||||
|
[ "$code" -ne 0 ] || fail "$proc subiu com storage sem escrita"
|
||||||
|
echo "$out" | grep -q STORAGE_NOT_WRITABLE || { echo "$out"; fail "$proc não explicou a falha"; }
|
||||||
|
echo " $proc: saiu com código $code e STORAGE_NOT_WRITABLE"
|
||||||
|
done
|
||||||
|
# Com Supabase configurado o volume local não é usado e a verificação não bloqueia.
|
||||||
|
SUPA="$PREFIX-supabase"
|
||||||
|
CONTAINERS+=("$SUPA")
|
||||||
|
docker run -d --name "$SUPA" --network none -e ADAPTER_ENV=development \
|
||||||
|
-e NEXT_PUBLIC_SUPABASE_URL=https://exemplo.invalid -e SUPABASE_SERVICE_ROLE_KEY=x -e SUPABASE_STORAGE_BUCKET=x \
|
||||||
|
-v "$VF:/app/.data" "$IMAGE" web >/dev/null
|
||||||
|
ok=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if docker exec "$SUPA" node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; then ok=1; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
slog=$(docker logs "$SUPA" 2>&1)
|
||||||
|
docker rm -f "$SUPA" >/dev/null
|
||||||
|
[ -n "$ok" ] || { echo "$slog"; fail "a web não subiu com bucket configurado"; }
|
||||||
|
echo "$slog" | grep -q STORAGE_NOT_WRITABLE && { echo "$slog"; fail "a verificação bloqueou com bucket configurado"; }
|
||||||
|
echo "ok: falha explícita sem storage; sem bloqueio quando o bucket é o Supabase"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "PERMISSÕES DO ARMAZENAMENTO: OK (A, B, C, D, E, F)"
|
||||||
@@ -254,6 +254,9 @@ jobs:
|
|||||||
http://127.0.0.1:3000/api/dev/session)" = "404"
|
http://127.0.0.1:3000/api/dev/session)" = "404"
|
||||||
docker rm -f ci-web
|
docker rm -f ci-web
|
||||||
|
|
||||||
|
- name: Permissões do armazenamento local (volume novo, existente, idempotência, falha segura)
|
||||||
|
run: bash .github/scripts/storage-permissions.sh
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Topologia completa: web + worker + Postgres + Redis
|
# Topologia completa: web + worker + Postgres + Redis
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ COPY --chown=adaptador:adaptador src ./src
|
|||||||
COPY --chown=adaptador:adaptador tsconfig.json ./
|
COPY --chown=adaptador:adaptador tsconfig.json ./
|
||||||
COPY --chown=adaptador:adaptador docker ./docker
|
COPY --chown=adaptador:adaptador docker ./docker
|
||||||
|
|
||||||
|
# 4) Ponto de montagem do armazenamento local já com o dono certo. Um volume
|
||||||
|
# nomeado VAZIO herda dono e modo deste diretório na primeira montagem. Sem
|
||||||
|
# ele, o Docker cria /app/.data como root:root 755 e o uid 1001 não escreve
|
||||||
|
# (STORAGE_NOT_WRITABLE). Volumes que já existem não herdam nada: para eles
|
||||||
|
# há o serviço storage-init do compose.
|
||||||
|
RUN mkdir -p /app/.data/storage \
|
||||||
|
&& chown adaptador:adaptador /app/.data /app/.data/storage \
|
||||||
|
&& chmod 750 /app/.data /app/.data/storage
|
||||||
|
|
||||||
USER adaptador
|
USER adaptador
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ docker compose exec web node node_modules/tsx/dist/cli.mjs scripts/migrate.ts
|
|||||||
|
|
||||||
Portas só em `127.0.0.1`: web `3000`, Postgres `55432`, Redis `56379`.
|
Portas só em `127.0.0.1`: web `3000`, Postgres `55432`, Redis `56379`.
|
||||||
|
|
||||||
|
O serviço `storage-init` prepara o volume do armazenamento e termina antes de web e worker subirem; os dois continuam como uid 1001. Ver [23-storage-permissions.md](docs/spreadsheet-adapter/23-storage-permissions.md).
|
||||||
|
|
||||||
## Homologação
|
## Homologação
|
||||||
|
|
||||||
O procedimento completo — DNS, Nginx, Supabase, banco, bucket, containers, worker, observabilidade e rollback — está em **[docs/spreadsheet-adapter/20-homologacao-runbook.md](docs/spreadsheet-adapter/20-homologacao-runbook.md)**. O estado de validação está em [21-checklist-validacao.md](docs/spreadsheet-adapter/21-checklist-validacao.md).
|
O procedimento completo — DNS, Nginx, Supabase, banco, bucket, containers, worker, observabilidade e rollback — está em **[docs/spreadsheet-adapter/20-homologacao-runbook.md](docs/spreadsheet-adapter/20-homologacao-runbook.md)**. O estado de validação está em [21-checklist-validacao.md](docs/spreadsheet-adapter/21-checklist-validacao.md).
|
||||||
|
|||||||
@@ -41,6 +41,26 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
networks: [adaptador]
|
networks: [adaptador]
|
||||||
|
|
||||||
|
# Prepara o volume do armazenamento antes de web e worker e termina. É o único
|
||||||
|
# passo como root: sem rede, só com as capacidades de ajustar dono e trocar de
|
||||||
|
# usuário para provar a escrita como uid 1001. Idempotente e sem recursão.
|
||||||
|
storage-init:
|
||||||
|
image: ${ADAPTER_IMAGE:-adaptador-planilhas:local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: runner
|
||||||
|
command: ["prepare-storage"]
|
||||||
|
user: "0:0"
|
||||||
|
network_mode: none
|
||||||
|
cap_drop: [ALL]
|
||||||
|
cap_add: [CHOWN, FOWNER, DAC_OVERRIDE, DAC_READ_SEARCH, SETUID, SETGID]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- app-storage:/app/.data
|
||||||
|
|
||||||
web:
|
web:
|
||||||
# Com ADAPTER_IMAGE definido, usa a imagem já construída (o CI faz isso).
|
# Com ADAPTER_IMAGE definido, usa a imagem já construída (o CI faz isso).
|
||||||
# Sem ela, `up --build` constrói e marca com este nome.
|
# Sem ela, `up --build` constrói e marca com este nome.
|
||||||
@@ -67,6 +87,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- app-storage:/app/.data
|
- app-storage:/app/.data
|
||||||
depends_on:
|
depends_on:
|
||||||
|
storage-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
@@ -92,6 +114,8 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
depends_on:
|
depends_on:
|
||||||
|
storage-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
web:
|
web:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
+80
-2
@@ -9,6 +9,81 @@ set -eu
|
|||||||
|
|
||||||
TSX="--import=tsx"
|
TSX="--import=tsx"
|
||||||
|
|
||||||
|
# Armazenamento local (development): o volume `app-storage` montado em /app/.data.
|
||||||
|
# Homologação e produção usam o bucket do Supabase e não montam este volume.
|
||||||
|
DATA_DIR=/app/.data
|
||||||
|
STORAGE_DIR="$DATA_DIR/storage"
|
||||||
|
|
||||||
|
# Mesma regra de src/infra/storage/index.ts: sem as três variáveis do Supabase,
|
||||||
|
# o processo grava no disco local.
|
||||||
|
local_storage_in_use() {
|
||||||
|
[ -z "${NEXT_PUBLIC_SUPABASE_URL:-}" ] || [ -z "${SUPABASE_SERVICE_ROLE_KEY:-}" ] || [ -z "${SUPABASE_STORAGE_BUCKET:-}" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Web e worker recusam subir com armazenamento local que não aceita escrita.
|
||||||
|
# Sem isto o processo fica no ar e o primeiro upload responde "Erro interno".
|
||||||
|
assert_storage_writable() {
|
||||||
|
local_storage_in_use || return 0
|
||||||
|
probe="$STORAGE_DIR/.write-probe-$$"
|
||||||
|
if mkdir -p "$STORAGE_DIR" 2>/dev/null && touch "$probe" 2>/dev/null; then
|
||||||
|
rm -f "$probe"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "STORAGE_NOT_WRITABLE: $STORAGE_DIR não aceita escrita pelo uid $(id -u)." >&2
|
||||||
|
echo "Rode o serviço storage-init do compose (docs/spreadsheet-adapter/23-storage-permissions.md)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Roda como root, uma vez, antes de web e worker (serviço storage-init).
|
||||||
|
# Ajusta só os dois diretórios do armazenamento, sem recursão: arquivos que já
|
||||||
|
# existem não são tocados, a não ser com --fix-existing, que é passo manual.
|
||||||
|
prepare_storage() {
|
||||||
|
if [ "$(id -u)" != "0" ]; then
|
||||||
|
echo "prepare-storage precisa rodar como root (user: \"0:0\" no serviço storage-init)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
app_uid=$(id -u adaptador)
|
||||||
|
app_gid=$(id -g adaptador)
|
||||||
|
|
||||||
|
for dir in "$DATA_DIR" "$STORAGE_DIR"; do
|
||||||
|
if [ -L "$dir" ]; then
|
||||||
|
echo "[storage-init] recusado: $dir é link simbólico." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$dir"
|
||||||
|
owner=$(stat -c '%u:%g' "$dir")
|
||||||
|
if [ "$owner" != "$app_uid:$app_gid" ]; then
|
||||||
|
chown "$app_uid:$app_gid" "$dir"
|
||||||
|
echo "[storage-init] $dir: dono $owner -> $app_uid:$app_gid"
|
||||||
|
fi
|
||||||
|
mode=$(stat -c '%a' "$dir")
|
||||||
|
if [ "$mode" != "750" ]; then
|
||||||
|
chmod 750 "$dir"
|
||||||
|
echo "[storage-init] $dir: modo $mode -> 750"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--fix-existing" ]; then
|
||||||
|
# -xdev não atravessa outro sistema de arquivos; -h não segue link simbólico.
|
||||||
|
find "$DATA_DIR" -xdev ! -user "$app_uid" -exec chown -h "$app_uid:$app_gid" {} +
|
||||||
|
echo "[storage-init] dono de itens existentes ajustado para $app_uid:$app_gid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
foreign=$(find "$DATA_DIR" -xdev ! -user "$app_uid" | wc -l)
|
||||||
|
if [ "$foreign" -gt 0 ]; then
|
||||||
|
echo "[storage-init] AVISO: $foreign item(ns) em $DATA_DIR com outro dono; nada foi alterado neles." >&2
|
||||||
|
echo "[storage-init] Para ajustar, rode com --fix-existing (procedimento de recuperação no doc 23)." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prova com o usuário da aplicação, não com root.
|
||||||
|
if ! setpriv --reuid="$app_uid" --regid="$app_gid" --clear-groups \
|
||||||
|
sh -c 'p="$1/.write-probe-$$"; touch "$p" && rm -f "$p"' _ "$STORAGE_DIR"; then
|
||||||
|
echo "STORAGE_NOT_WRITABLE: o uid $app_uid não escreve em $STORAGE_DIR mesmo após a preparação." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[storage-init] STORAGE_WRITABLE: $STORAGE_DIR, dono $app_uid:$app_gid, modo 750"
|
||||||
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
uso: entrypoint.sh <comando> [args]
|
uso: entrypoint.sh <comando> [args]
|
||||||
@@ -23,6 +98,8 @@ uso: entrypoint.sh <comando> [args]
|
|||||||
check-bucket
|
check-bucket
|
||||||
smoke
|
smoke
|
||||||
worker-health confere o heartbeat do worker (healthcheck do contêiner)
|
worker-health confere o heartbeat do worker (healthcheck do contêiner)
|
||||||
|
prepare-storage [--fix-existing]
|
||||||
|
prepara /app/.data como root (serviço storage-init)
|
||||||
exec <cmd...> qualquer outro comando
|
exec <cmd...> qualquer outro comando
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -31,8 +108,9 @@ cmd="${1:-web}"
|
|||||||
[ $# -gt 0 ] && shift || true
|
[ $# -gt 0 ] && shift || true
|
||||||
|
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
web) exec node server.js ;;
|
web) assert_storage_writable; exec node server.js ;;
|
||||||
worker) exec node $TSX scripts/worker.ts ;;
|
worker) assert_storage_writable; exec node $TSX scripts/worker.ts ;;
|
||||||
|
prepare-storage) prepare_storage "$@" ;;
|
||||||
migrate) exec node $TSX scripts/migrate.ts "$@" ;;
|
migrate) exec node $TSX scripts/migrate.ts "$@" ;;
|
||||||
seed-staging) exec node $TSX scripts/seed-staging.ts "$@" ;;
|
seed-staging) exec node $TSX scripts/seed-staging.ts "$@" ;;
|
||||||
flag) exec node $TSX scripts/set-flag.ts "$@" ;;
|
flag) exec node $TSX scripts/set-flag.ts "$@" ;;
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ Regra de preenchimento: **PASS** só com evidência executada. Sem infraestrutur
|
|||||||
| D9d | Worker relê o contexto do dono | PASS | flag do módulo desligada entre o enfileiramento e o tick: o job não é processado |
|
| D9d | Worker relê o contexto do dono | PASS | flag do módulo desligada entre o enfileiramento e o tick: o job não é processado |
|
||||||
| D10 | Worker recusa iniciar com migration pendente | PASS por construção | `pendingMigrations` no start; a execução real é PENDENTE |
|
| D10 | Worker recusa iniciar com migration pendente | PASS por construção | `pendingMigrations` no start; a execução real é PENDENTE |
|
||||||
| D11 | Desligamento gracioso do worker | PASS no loop / PENDENTE no sinal | `shutdown()` para de aceitar trabalho, espera o tick corrente sair, e o job que não começou fica `queued` com o lock reservado para ele; concluído, o lock é liberado. A **entrega do SIGTERM** ao processo é PENDENTE: no Windows não há sinal real, e o job `image` do CI confere `SIGTERM recebido`, `[worker] encerrado` e exit 0 em menos de 25 s |
|
| D11 | Desligamento gracioso do worker | PASS no loop / PENDENTE no sinal | `shutdown()` para de aceitar trabalho, espera o tick corrente sair, e o job que não começou fica `queued` com o lock reservado para ele; concluído, o lock é liberado. A **entrega do SIGTERM** ao processo é PENDENTE: no Windows não há sinal real, e o job `image` do CI confere `SIGTERM recebido`, `[worker] encerrado` e exit 0 em menos de 25 s |
|
||||||
|
| D19 | Storage local gravável pelo uid 1001 em volume novo e existente (local) | PASS | 16 out 2026, Docker Desktop/WSL2: `.github/scripts/storage-permissions.sh` casos A–F em volumes descartáveis. Corrige `STORAGE_NOT_WRITABLE` no upload. Ver [23-storage-permissions.md](23-storage-permissions.md). Não se aplica a homologação (bucket Supabase, D7) |
|
||||||
|
| D20 | Web e worker recusam subir com storage local sem escrita | PASS | mesmo script, caso F: saída com código 1 e `STORAGE_NOT_WRITABLE` |
|
||||||
| D17 | Heartbeat do worker e healthcheck | PASS | worker no layout de runtime gravou o heartbeat; `docker/worker-health.mjs` aprovou com 1 s e reprova quando o arquivo falta |
|
| D17 | Heartbeat do worker e healthcheck | PASS | worker no layout de runtime gravou o heartbeat; `docker/worker-health.mjs` aprovou com 1 s e reprova quando o arquivo falta |
|
||||||
| D12 | Download XLSX, CSV e PDF ponta a ponta | PASS no local / PENDENTE no ambiente | `tests/integration/journey.test.ts` cobre o fluxo; falta a jornada em homologação |
|
| D12 | Download XLSX, CSV e PDF ponta a ponta | PASS no local / PENDENTE no ambiente | `tests/integration/journey.test.ts` cobre o fluxo; falta a jornada em homologação |
|
||||||
| D13 | Modelo original não é alterado | PASS | `tests/unit/xlsx-fixtures.test.ts` compara bytes |
|
| D13 | Modelo original não é alterado | PASS | `tests/unit/xlsx-fixtures.test.ts` compara bytes |
|
||||||
@@ -121,7 +123,7 @@ Regra de preenchimento: **PASS** só com evidência executada. Sem infraestrutur
|
|||||||
|
|
||||||
| | Quantidade |
|
| | Quantidade |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| PASS com evidência executada | 54 |
|
| PASS com evidência executada | 56 |
|
||||||
| PENDENTE por falta de infraestrutura ou credencial | 20 |
|
| PENDENTE por falta de infraestrutura ou credencial | 20 |
|
||||||
| FAIL | 0 |
|
| FAIL | 0 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Permissões do armazenamento local em Docker
|
||||||
|
|
||||||
|
Escopo: `docker-compose.yml` (desenvolvimento local). Homologação e produção usam o bucket privado do Supabase e não montam volume em `/app/.data` — `docker-compose.staging.yml` não foi alterado.
|
||||||
|
|
||||||
|
## Problema
|
||||||
|
|
||||||
|
Cadastro de `modelo_imoveis_teste.xlsx` no compose local respondeu `Não foi possível continuar — Erro interno.`
|
||||||
|
|
||||||
|
```
|
||||||
|
uid=1001(adaptador) gid=1001(adaptador)
|
||||||
|
drwxr-xr-x 2 root root /app/.data
|
||||||
|
STORAGE_NOT_WRITABLE
|
||||||
|
```
|
||||||
|
|
||||||
|
Contornado à mão no volume local com `chown 1001:1001` e `chmod 750` em `/app/.data`, depois `STORAGE_WRITABLE`. Esta correção elimina o passo manual.
|
||||||
|
|
||||||
|
## Causa raiz
|
||||||
|
|
||||||
|
1. A imagem não continha `/app/.data`. `LocalPrivateStorage` grava em `/app/.data/storage` e só cria o diretório em runtime.
|
||||||
|
2. Quando um volume nomeado é montado num caminho que não existe na imagem, o Docker cria o ponto de montagem como `root:root 755`. O volume vazio não tem de onde herdar dono.
|
||||||
|
3. Web e worker rodam como `adaptador` (1001) sem capacidades. Não escrevem num diretório de root.
|
||||||
|
4. O health check do storage local só fazia `mkdir -p`, que passa quando o diretório já existe. O processo subia saudável e o erro só aparecia no primeiro upload, como 500 genérico.
|
||||||
|
|
||||||
|
Reproduzido num volume descartável com a imagem anterior: `drwxr-xr-x 0 0 /app/.data` e `touch: Permission denied`.
|
||||||
|
|
||||||
|
## Correção
|
||||||
|
|
||||||
|
Duas camadas, porque `RUN chown` no Dockerfile sozinho não alcança volume que já existe.
|
||||||
|
|
||||||
|
| Camada | Arquivo | O que faz | Alcança |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Imagem | `Dockerfile` | Cria `/app/.data/storage` com dono `adaptador` e modo `750`. Um volume nomeado **vazio** herda dono e modo na primeira montagem | Volumes novos |
|
||||||
|
| Inicialização | `docker-compose.yml` (`storage-init`) + `docker/entrypoint.sh prepare-storage` | Serviço de uma execução, antes de web e worker, que ajusta **só** `/app/.data` e `/app/.data/storage` e prova a escrita como uid 1001 | Volumes existentes, inclusive os criados antes desta correção |
|
||||||
|
| Falha segura | `docker/entrypoint.sh` (`web`, `worker`) | Antes de iniciar, grava e apaga um arquivo de prova em `/app/.data/storage`. Sem escrita, sai com código 1 e `STORAGE_NOT_WRITABLE` | Qualquer ambiente que use o storage local |
|
||||||
|
|
||||||
|
Alternativa A (preparar dentro do entrypoint de web e worker) foi descartada: exigiria iniciar os processos como root e rebaixar o usuário depois. A alternativa B mantém web e worker exatamente como antes — `USER adaptador`, sem capacidades — e concentra o único passo privilegiado num contêiner que termina.
|
||||||
|
|
||||||
|
## Fluxo de inicialização
|
||||||
|
|
||||||
|
```
|
||||||
|
postgres (healthy) ─┐
|
||||||
|
redis (healthy) ────┤
|
||||||
|
storage-init ───────┼─ completed_successfully ─► web ─► worker
|
||||||
|
(root, sem rede, │
|
||||||
|
termina) │
|
||||||
|
```
|
||||||
|
|
||||||
|
`storage-init`:
|
||||||
|
|
||||||
|
- `user: "0:0"`, `network_mode: none`, `cap_drop: [ALL]` e só `CHOWN`, `FOWNER`, `DAC_OVERRIDE`, `DAC_READ_SEARCH`, `SETUID`, `SETGID`; `no-new-privileges`.
|
||||||
|
- Recusa link simbólico em `/app/.data` ou `/app/.data/storage`.
|
||||||
|
- Muda dono/modo somente quando diferem de `1001:1001 750`, e registra cada mudança.
|
||||||
|
- **Não é recursivo.** Itens com outro dono dentro do volume são contados e reportados, não alterados.
|
||||||
|
- Prova a escrita com `setpriv --reuid 1001` e falha com `STORAGE_NOT_WRITABLE` se não conseguir. Falha do `storage-init` impede web e worker de subir.
|
||||||
|
- UID/GID vêm de `id -u adaptador` na própria imagem, a mesma fonte do `USER` do Dockerfile.
|
||||||
|
|
||||||
|
## Permissões esperadas
|
||||||
|
|
||||||
|
| Caminho | Dono | Modo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/app/.data` | `1001:1001` | `750` |
|
||||||
|
| `/app/.data/storage` | `1001:1001` | `750` |
|
||||||
|
| Subpastas e arquivos criados pela aplicação | `1001:1001` | padrão do processo (`755`/`644`), protegidos pelo `750` acima |
|
||||||
|
|
||||||
|
| Processo | Usuário | Capacidades efetivas |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| web | `adaptador` (1001) | nenhuma |
|
||||||
|
| worker | `adaptador` (1001) | nenhuma |
|
||||||
|
| storage-init | root, só durante a preparação | as seis listadas acima |
|
||||||
|
|
||||||
|
Web e worker montam o mesmo volume `adaptador-planilhas-local-storage` em `/app/.data`. O volume não é servido por HTTP: o download continua passando pela rota autenticada.
|
||||||
|
|
||||||
|
## Volume novo × existente
|
||||||
|
|
||||||
|
- **Novo:** herda `1001:1001 750` da imagem; `storage-init` só confirma e não altera nada.
|
||||||
|
- **Existente com raiz de root:** `storage-init` ajusta os dois diretórios. Conteúdo, dono e modo dos arquivos existentes ficam como estavam.
|
||||||
|
- **Existente já corrigido à mão:** nenhuma mudança de dono; o modo de `/app/.data/storage` passa de `755` para `750` na primeira execução.
|
||||||
|
|
||||||
|
## Validação
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --parallel 1 build web
|
||||||
|
ADAPTER_IMAGE=adaptador-planilhas:local bash .github/scripts/storage-permissions.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
O script usa apenas volumes e contêineres descartáveis com prefixo `adaptador-storage-test-` e remove só o que criou. Casos:
|
||||||
|
|
||||||
|
| Caso | Verifica |
|
||||||
|
| --- | --- |
|
||||||
|
| A | Volume novo: `1001:1001 750` sem `storage-init`, escrita pelo uid 1001, `STORAGE_WRITABLE` |
|
||||||
|
| B | Volume com raiz `root:root 755` e arquivos: diretórios corrigidos, conteúdo e dono dos arquivos intactos, item de root apenas reportado |
|
||||||
|
| C | Duas execuções extras: snapshot de dono, modo, tamanho e SHA-256 idêntico; nenhuma mudança registrada |
|
||||||
|
| C2 | `--fix-existing` ajusta o dono só quando pedido e preserva o conteúdo |
|
||||||
|
| D | Um contêiner grava, outro lê e apaga; `web` real sobe e responde liveness; `worker` real passa da verificação |
|
||||||
|
| E | web como 1001 com `CapEff` zero; imagem com `USER adaptador`; `prepare-storage` recusa rodar sem root |
|
||||||
|
| F | Volume sem escrita: `web` e `worker` saem com código 1 e `STORAGE_NOT_WRITABLE`; com Supabase configurado a verificação não bloqueia |
|
||||||
|
|
||||||
|
O job `image` do CI executa o mesmo script.
|
||||||
|
|
||||||
|
## Recuperação
|
||||||
|
|
||||||
|
Se `storage-init` reportar `AVISO: N item(ns) ... com outro dono`, os arquivos continuam íntegros, mas a aplicação pode não conseguir sobrescrevê-los ou apagá-los. Ajuste controlado, limitado ao volume, sem seguir link simbólico e sem atravessar outro sistema de arquivos — **execute só com autorização**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm storage-init prepare-storage --fix-existing
|
||||||
|
```
|
||||||
|
|
||||||
|
Se `storage-init` falhar com `STORAGE_NOT_WRITABLE`: confira `docker compose logs storage-init`. Causas prováveis são volume montado somente leitura ou driver de volume que não aceita `chown`. Não use `chmod 777` nem rode web/worker como root.
|
||||||
|
|
||||||
|
## Limitações
|
||||||
|
|
||||||
|
- Bind mount (`./pasta:/app/.data`) não herda dono da imagem. No Docker Desktop/WSL2 sobre o disco do Windows o `chown` pode não ter efeito; a falha segura impede o processo de subir em silêncio. O compose usa volume nomeado.
|
||||||
|
- `--fix-existing` é recursivo dentro do volume e por isso é manual.
|
||||||
|
- O ajuste do volume local real `adaptador-planilhas-local-storage` acontece quando os contêineres forem recriados com o compose novo. Esta validação não recriou os contêineres em execução.
|
||||||
|
- Não valida homologação: o storage de homologação é o bucket privado do Supabase (item D7 do checklist continua PENDENTE).
|
||||||
Reference in new issue
Block a user