diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 45d2255..76c75ff 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,22 +1,73 @@ -# Deploy na single server: deploy.sh volá hostovský Docker přes /var/run/docker.sock (bez DinD). +# CI: immutability + Flyway validate (JDBC na staging / sdílenou DB). Deploy na main až po úspěchu. +# Job bez container: — hostovský docker + git (stejně jako deploy). +# Gitea secrets: EMS_CI_FLYWAY_URL (jdbc:postgresql://…/ems). Volitelně EMS_CI_FLYWAY_USER, EMS_CI_FLYWAY_PASSWORD. +# Runner: container.valid_volumes pro /var/run/docker.sock (viz docs/deployment-self-hosted.md). # -# Job běží v kontejneru — /opt/ems-deploy a sock musí být přimountované (viz container.volumes). -# V /opt/gitea-stack/runner/config.yaml nastav container.valid_volumes na stejné cesty. -# Sladit `runs-on` s labely registrace runneru (výchozí: self-hosted). -# -# Spuštění: push na větev main (včetně merge PR do main — merge v Gitea/Git je stále push na main). -# Nepřidávat paralelně pull_request:closed — při merge by běžel deploy dvakrát (push + PR). +# Spuštění deploye: push na main. Nepřidávat paralelně pull_request:closed — při merge by běžel deploy dvakrát. -name: deploy +name: CI and deploy on: push: branches: - main + - feature/** + pull_request: workflow_dispatch: jobs: + migration-check: + runs-on: self-hosted + steps: + - name: Checkout + env: + TOKEN: ${{ github.token }} + run: | + set -eu + su="${{ github.server_url }}" + case "$su" in + https://*) clone_url="https://oauth2:${TOKEN}@${su#https://}" ;; + http://*) clone_url="http://oauth2:${TOKEN}@${su#http://}" ;; + *) echo "unknown github.server_url: $su"; exit 1 ;; + esac + clone_url="${clone_url}/${{ github.repository }}.git" + git init + git remote add origin "$clone_url" + git fetch --depth=64 origin "${{ github.sha }}" + git checkout -qf FETCH_HEAD + git remote set-branches origin 'main' || true + git fetch --depth=64 origin main:refs/remotes/origin/main || true + + - name: Repo layout + run: | + test -f docker-compose.yml + test -f deploy/docker-compose.yml + test -x deploy/deploy.sh + test -x scripts/ci_check_migration_immutability.sh + test -x scripts/ci_flyway_validate_remote.sh + + - name: Migration immutability (vs PR base or main) + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -eu + BASE='origin/main' + if [ -n "${PR_BASE_SHA:-}" ]; then + BASE="$PR_BASE_SHA" + git fetch --no-tags --depth=256 origin "$BASE" || true + fi + ./scripts/ci_check_migration_immutability.sh "$BASE" + + - name: Flyway validate (remote DB) + env: + EMS_CI_FLYWAY_URL: ${{ secrets.EMS_CI_FLYWAY_URL }} + EMS_CI_FLYWAY_USER: ${{ secrets.EMS_CI_FLYWAY_USER }} + EMS_CI_FLYWAY_PASSWORD: ${{ secrets.EMS_CI_FLYWAY_PASSWORD }} + run: ./scripts/ci_flyway_validate_remote.sh + deploy: + needs: migration-check + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') runs-on: self-hosted steps: - name: Show execution context @@ -27,9 +78,8 @@ jobs: ls -ld /opt/ems-deploy - name: Run deploy script - run: | - bash /opt/ems-deploy/deploy.sh - + run: bash /opt/ems-deploy/deploy.sh + # Alternativa: runner v Dockeru bez přístupu k hostu — odkomentovat a upravit SERVER + secrets. # deploy-ssh: # runs-on: ubuntu-latest diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml deleted file mode 100644 index 1d5eea1..0000000 --- a/.gitea/workflows/test.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: test - -on: - push: - branches: - - main - - feature/** - pull_request: - -jobs: - smoke-test: - # Stejný label jako deploy.yml — výchozí act_runner má typicky jen `self-hosted`. - runs-on: self-hosted - # Výchozí job image často nemá Node → `actions/checkout@v4` padá na „Cannot find: node“. - # alpine/git je malý a stačí na shallow clone přes token (Gitea = GitHub-kompatibilní kontext). - container: - image: alpine/git:latest - steps: - - name: Checkout - env: - TOKEN: ${{ github.token }} - run: | - set -eu - su="${{ github.server_url }}" - case "$su" in - https://*) clone_url="https://oauth2:${TOKEN}@${su#https://}" ;; - http://*) clone_url="http://oauth2:${TOKEN}@${su#http://}" ;; - *) echo "unknown github.server_url: $su"; exit 1 ;; - esac - clone_url="${clone_url}/${{ github.repository }}.git" - git init - git remote add origin "$clone_url" - git fetch --depth=1 origin "${{ github.sha }}" - git checkout -qf FETCH_HEAD - - - name: Repo layout - run: | - test -f docker-compose.yml - test -f deploy/docker-compose.yml - test -x deploy/deploy.sh - - - name: Runner info - run: | - uname -a - pwd - ls -la diff --git a/CLAUDE.md b/CLAUDE.md index 78d0a28..bf74a9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,3 +192,4 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan - Výkon **W**, energie **Wh**, ceny **Kč/kWh**; čas v DB **`TIMESTAMPTZ` (UTC)**. - NIKDY neupravuj existující V__ migrační soubory po jejich aplikaci na DB. - Pokud je potřeba opravit chybu ve verzované migraci, vytvoř novou V{N+1} migraci. +- Deploy: `flyway validate` před `migrate` ([`deploy/deploy.sh`](deploy/deploy.sh)). Lokálně `./scripts/flyway_validate_local.sh`; CI viz [`docs/deployment-self-hosted.md`](docs/deployment-self-hosted.md) a `scripts/ci_check_migration_immutability.sh`. diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 537f86f..2101dd0 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -50,9 +50,12 @@ install -m 0644 "$COMPOSE_SRC" "$COMPOSE_DST" log "docker compose config (validate)" docker compose -f "$COMPOSE_DST" --env-file "$ENV_FILE" config >/dev/null -# Vždy spustit migrace z aktuálního ./app/db (mount ve flyway službě). Čisté `up -d` často -# znovu nespustí jednorázový kontejner flyway, takže změny jen v R__/*.sql by se neaplikovaly. -# Při chybě je v logu jobu celý Flyway výstup (konkrétní SQL / řádek). +# Flyway: nejdřív validate (soubory vs flyway_schema_history na DB), pak migrate. +# Čisté `up -d` často znovu nespustí jednorázový kontejner flyway — změny jen v R__/*.sql přes migrate. +# Při chybě je v logu celý Flyway výstup (konkrétní SQL / řádek). +log "Flyway validate" +docker compose -f "$COMPOSE_DST" --env-file "$ENV_FILE" run --rm flyway validate + log "Flyway migrate (docker compose run --rm flyway)" docker compose -f "$COMPOSE_DST" --env-file "$ENV_FILE" run --rm flyway migrate diff --git a/docs/deployment-self-hosted.md b/docs/deployment-self-hosted.md index a66f127..b6cf82e 100644 --- a/docs/deployment-self-hosted.md +++ b/docs/deployment-self-hosted.md @@ -43,10 +43,12 @@ flowchart LR dev[Dev laptop] -->|git push| gitea[Gitea main] gitea --> act[Gitea Actions] act --> runner[act_runner + Docker] - runner --> job[Job container Alpine + sock + /opt mount] + runner --> ci[migration-check validate] + ci -->|needs ok| job[deploy job + /opt mount] job --> sh[deploy.sh] sh --> git[git fetch / reset main] sh --> sync[install compose.yml] + sh --> fly[flyway validate then migrate] sh --> dc["docker compose build && up -d"] dc --> hostd[Host Docker Engine] hostd --> ems[EMS stack] @@ -55,7 +57,7 @@ flowchart LR 1. Vývoj lokálně, merge do `main`, push na `git.vojacek.eu:2222`. 2. Workflow `.gitea/workflows/deploy.yml` (label `self-hosted`) spustí job. 3. Job kontejner má `docker.sock` a rw mount `/opt/ems-deploy`; nainstaluje `git`, `bash`, `docker-cli`, `docker-cli-compose` (Alpine). -4. `/opt/ems-deploy/deploy.sh`: `flock`, `git` v `app/`, sync `docker-compose.yml`, `docker compose config`, `build`, `up -d`, `image prune -f`. +4. `/opt/ems-deploy/deploy.sh`: `flock`, `git` v `app/`, sync `docker-compose.yml`, `docker compose config`, **`flyway validate`** (soubory vs `flyway_schema_history` na DB), **`flyway migrate`**, `build`, `up -d`, `image prune -f`. Build probíhá na **hostovském** Dockeru (stejný daemon jako Gitea stack), bez DinD. @@ -123,14 +125,26 @@ Produkční compose mapuje frontend na **`127.0.0.1:8080`**, backend na **`127.0 --- -## 7. CI: test vs deploy +## 7. CI a deploy (jeden workflow) -| Workflow | Účel | Poznámka | -|----------|------|----------| -| `.gitea/workflows/test.yml` | Smoke (soubory, layout) | `runs-on: self-hosted` + job `container: alpine/git` a checkout přes `git fetch` (ne `actions/checkout@v4` — ta potřebuje **Node** v job kontejneru, výchozí image act_runneru ho nemá → „Cannot find: node in PATH“). | -| `.gitea/workflows/deploy.yml` | Deploy po pushi na `main` | Job kontejner + mounty + viz sekce 5. | +| Soubor | Účel | +|--------|------| +| `.gitea/workflows/deploy.yml` | **`migration-check`** na `pull_request` a `push` (`main`, `feature/**`): checkout přes `git fetch` (bez Node), skript **neměnnosti** `scripts/ci_check_migration_immutability.sh` (proti `origin/main` nebo base SHA u PR), **`flyway validate`** proti DB z JDBC (viz secrets). **`deploy`** jen při `push` na `main`, **`needs: migration-check`** — pak `/opt/ems-deploy/deploy.sh`. | -`workflow_dispatch` na deploy umožňuje ruční opakování bez prázdného commitu. +**Gitea secrets (repo):** + +| Secret | Účel | +|--------|------| +| `EMS_CI_FLYWAY_URL` | JDBC na staging / sdílenou DB s aktuální `flyway_schema_history` (např. `jdbc:postgresql://host:5432/ems`). Pokud **není** nastaveno, krok remote validate se **přeskočí** (varování v logu); immutability check pořád běží. | +| `EMS_CI_FLYWAY_USER` / `EMS_CI_FLYWAY_PASSWORD` | Volitelně, pokud nejsou v URL. | + +Job **nemá** `container:` — potřebuje hostovský `docker` + `git` (stejně jako deploy). Runner: `valid_volumes` pro `/var/run/docker.sock` (sekce 5). + +**Lokálně před commitem:** z kořene repa s rozjetým Postgres z `docker-compose.yml` a aspoň jednou proběhlým migrate spusť `./scripts/flyway_validate_local.sh` (`flyway validate` vůči lokální DB). + +**Flyway `validate` vs `migrate`:** `validate` nekontroluje „dry-run“ celého SQL nové pending migrace; ověří hlavně shodu **již zapsaných** řádků ve `flyway_schema_history` se soubory v repu (checksumy atd.) — proto musí běžet proti **běžící** DB. `migrate` teprve aplikuje pending skripty. + +`workflow_dispatch` na větvi `main` spustí `migration-check` a potom i **`deploy`** (stejná podmínka jako u push na `main`). --- diff --git a/scripts/ci_check_migration_immutability.sh b/scripts/ci_check_migration_immutability.sh new file mode 100755 index 0000000..b9f104e --- /dev/null +++ b/scripts/ci_check_migration_immutability.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Fail if any versioned migration Vxxx already present on the base branch is modified +# in the current HEAD (Flyway checksum mismatch risk). See CLAUDE.md (never rewrite applied V__). +# +# Usage: ci_check_migration_immutability.sh [BASE] +# BASE — ref or full SHA (default: origin/main). For PRs pass base commit SHA. +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "$ROOT" ]]; then + echo "ERROR: not inside a git repository" + exit 1 +fi +cd "$ROOT" + +BASE="${1:-origin/main}" + +if [[ "$BASE" =~ ^[0-9a-f]{7,40}$ ]]; then + if ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then + git fetch --no-tags --depth=256 origin "$BASE" + fi +fi + +if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null; then + echo "ERROR: cannot resolve base revision: $BASE (fetch origin main?)" + exit 1 +fi + +max_ver_on_base() { + git ls-tree -r --name-only "$BASE" -- db/migration 2>/dev/null \ + | grep -E '^db/migration/V[0-9]+__' \ + | sed -n 's/^db\/migration\/V\([0-9][0-9]*\)__.*/\1/p' \ + | sort -n \ + | tail -1 +} + +MAX="$(max_ver_on_base || true)" +MAX="${MAX:-0}" + +mapfile -t CHANGED < <(git diff --name-only "${BASE}...HEAD" -- 'db/migration/V*.sql' 2>/dev/null || true) + +if [[ ${#CHANGED[@]} -eq 0 ]]; then + echo "ci_check_migration_immutability: no versioned migration changes vs $BASE (ok)" + exit 0 +fi + +err=0 +for f in "${CHANGED[@]}"; do + [[ "$f" =~ ^db/migration/V([0-9]+)__ ]] || continue + ver="${BASH_REMATCH[1]}" + ver=$((10#$ver)) + if (( ver <= MAX )); then + printf 'ERROR: do not modify historical migration %s (version %03d <= max on base %03d from %s). Add a new V* migration instead. See CLAUDE.md.\n' \ + "$f" "$ver" "$MAX" "$BASE" >&2 + err=1 + fi +done + +if (( err )); then + exit 1 +fi + +echo "ci_check_migration_immutability: ok (vs $BASE, max V on base=$MAX)" diff --git a/scripts/ci_flyway_validate_remote.sh b/scripts/ci_flyway_validate_remote.sh new file mode 100755 index 0000000..bb9949f --- /dev/null +++ b/scripts/ci_flyway_validate_remote.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Flyway validate using migration files from repo root; JDBC from env (staging / CI DB). +# Env: EMS_CI_FLYWAY_URL — if unset, skips (warn). Optional: EMS_CI_FLYWAY_USER, EMS_CI_FLYWAY_PASSWORD, FLYWAY_IMAGE +set -euo pipefail + +if [[ -z "${EMS_CI_FLYWAY_URL:-}" ]]; then + echo "WARN: EMS_CI_FLYWAY_URL not set — skipping remote Flyway validate (set Gitea secret for CI)." + exit 0 +fi + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$ROOT" + +IMG="${FLYWAY_IMAGE:-flyway/flyway:12}" + +args=( + run --rm + -v "$ROOT/db/migration:/flyway/sql/migration" + -v "$ROOT/db/routines:/flyway/sql/routines" + -v "$ROOT/db/views:/flyway/sql/views" + -e "FLYWAY_URL=${EMS_CI_FLYWAY_URL}" + -e "FLYWAY_SCHEMAS=ems" + -e "FLYWAY_LOCATIONS=filesystem:/flyway/sql/migration,filesystem:/flyway/sql/routines,filesystem:/flyway/sql/views" +) + +if [[ -n "${EMS_CI_FLYWAY_USER:-}" ]]; then + args+=(-e "FLYWAY_USER=${EMS_CI_FLYWAY_USER}") +fi +if [[ -n "${EMS_CI_FLYWAY_PASSWORD:-}" ]]; then + args+=(-e "FLYWAY_PASSWORD=${EMS_CI_FLYWAY_PASSWORD}") +fi + +args+=("$IMG" validate) + +echo "Running Flyway validate against remote DB (schema ems)…" +docker "${args[@]}" diff --git a/scripts/flyway_validate_local.sh b/scripts/flyway_validate_local.sh new file mode 100755 index 0000000..e3cc002 --- /dev/null +++ b/scripts/flyway_validate_local.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Flyway validate against local Postgres from repo-root docker-compose. +# Requires existing flyway_schema_history (run migrate at least once). Does not run migrate. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +if [[ ! -f .env ]]; then + echo "ERROR: missing .env (copy from .env.example)" + exit 1 +fi +docker compose --env-file .env run --rm flyway validate