Ctrl K
ComunidadGuías…

DESPLEGAMENT.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Actualitzar una instal·lació existent

Passos per actualitzar un servidor que ja té l'aplicació en marxa a la versió
amb migracions d'esquema, contenidor sense root i configuració a la carpeta de
dades. Feu-ho fora de l'horari en què els centres hi treballen, i comproveu
abans que no hi ha ningú connectat. No mostreu dades dels centres, secrets ni
registres.

## Com funciona el codi

- `0001_base` congela l’esquema canònic inicial per a auth i dades.
  Les BD noves creen aquesta base i arriben a `0003_esquema_canonic`. Cap
  revisió no importa els models vius.
- `--comprova` valida en només lectura, abans de qualsevol còpia o escriptura,
  **tota** la llista. Una forma històrica coneguda només s’accepta com a punt
  de partida d’una actualització explícita. Una BD `vigent` ha de coincidir
  estrictament amb el canònic: ordre de columnes, tipus, nul·labilitat, defaults,
  PK, unicitat, índexs, col·lacions, FK, checks i objectes SQL. No es normalitza
  la comparació per amagar diferències. Una 0003 amb un ALTER històric es rebutja.
- Les estructures de versions anteriors que es reconeixen són a
  `backend/schema_migrations/historical_schema.json`. Només s’admeten les
  formes exactes i els conjunts de taules documentats, incloses mescles que
  l’antic `create_all` podia deixar en actualitzar. No s’executa codi antic.
  Una forma desconeguda atura la comprovació sense tocar cap BD ni crear còpies;
  el missatge indica restaurar una còpia compatible o demanar ajuda, sense
  imprimir camins, noms de taules alienes ni dades. Mai fer `alembic stamp`
  manual per saltar aquestes comprovacions.
- `0002_neteja_historiques` **retira de dades les taules `users`,
  `configuracio_assignatures` i `exam_schedules`**, si hi són, amb les seves
  files antigues sense ús. La còpia SQLite prèvia les conserva íntegres.
  Completa `idx_configuracio_clau`. La seva comprovació intermèdia encara
  admet les formes conegudes: la normalització es fa a la revisió següent.
- `0003_esquema_canonic` refà **només les taules diferents del canònic**:
  crea una taula auxiliar canònica, hi copia totes les files per nom de columna,
  retira l’original, la torna a crear amb el DDL congelat i hi recupera les files
  i els índexs. No usa RENAME, per evitar reescriure FK dels fills. Tot passa
  dins del mateix `BEGIN IMMEDIATE` que ja fa el migrador, **després de la còpia**.
  Davant una fallada es recuperen dades, DDL i versió dins d’aquella BD.
  La reversió posterior requereix restaurar la còpia, no fer downgrade.
- Llista de taules vigents que, segons la forma d’origen, es poden reconstruir:
  `abreviatures_grups`, `assignatures`, `assignatures_prioritat`, `aules`,
  `configuracio`, `exam_costos_professors`, `exam_restriccions`, `grups`,
  `grups_alliberats`, `nivells`, `substitucions`, `vigilancies` i **`users`
  només a auth**. També es retiren les auxiliars `_migra3_<taula>`, creades
  dins de la transacció. L’operador ha de conèixer aquesta llista abans d’aplicar.
- Es conserven totes les files i valors de les taules vigents, inclosos NULL,
  zeros, valors diferents del default, BLOB, dates, ids i rowid. En esquemes
  inicials es creen les taules que encara no existien; les columnes absents
  prenen NULL, excepte `users.versio_sessio`, que necessita 0. No s’omple ni
  recalcula cap `vigilancies.ordre` existent. Les dues columnes antigues
  d’abreviatures passen als noms canònics conservant els valors.
- Abans del BEGIN, només en la connexió del migrador, es desactiven les FK
  de SQLite per evitar esborrats en cascada durant la reconstrucció de pares.
  L’esquema final conserva les claus canòniques; l’aplicació continua activant
  les FK a cada connexió. Les proves amb pares i fills poblats comproven que
  no es perd cap fila i que `foreign_key_check` queda buit.
- Les BD existents **sense versió, a 0001 o a 0002** necessiten l’script
  explícit amb backend i reset aturats abans d’arrencar. La transacció és
  per BD, no per tota la llista: si falla després d’haver migrat alguna BD,
  mantenir els serveis aturats i restaurar la còpia completa.
- `--comprova` no crea fitxers inexistents. `--aplica` exigeix `--backup-dir`.
  La sortida i els noms de les còpies només identifiquen les BD per número i
  tipus. Repetir l’aplicació sobre BD vigents no toca ni un byte ni fa més còpies.
- Configuració mutable: `APP_CONFIG_PATH`, o per defecte `DATA_DIR/config.json`.
  Si encara no existeix, es llegeix `backend/config/config.json` com a transició;
  els desats sempre van al camí nou. `APP_INSTITUCIO` conserva la precedència.
  Els recursos estàtics versionats com `config/pdf.json` són de només lectura.
- El backend s’executa com `10001:10001`. El codi continua sent propietat de
  root i de només lectura per a l’aplicació; només els volums de dades i la
  carpeta de còpies que s’hi munti necessiten escriptura.

## Preparar la finestra de desplegament

L’operador ha d’identificar en la configuració real els dos projectes Compose,
els noms de projecte Compose, els serveis backend, els dos DATA_DIR, qualsevol AUTH_DB_PATH extern i el mecanisme
del reset nocturn (timer o cron). Aquests detalls no són al repositori. Anotar els
valors sense publicar-los; les variables següents són exemples de disposició,
no adreces reals:

```bash
set -euo pipefail
APP_ROOT=/ruta/absoluta/aplicacio
PROD_COMPOSE="$APP_ROOT/docker-compose.yml"
DEMO_COMPOSE="$APP_ROOT/docker-compose.demo.yml"
PROD_PROJECT=gestor
DEMO_PROJECT=gestor-demo
PROD_SERVICE=backend
DEMO_SERVICE=backend
PROD_DATA="$APP_ROOT/data"
DEMO_DATA="$APP_ROOT/data-demo"
PROD_AUTH="$PROD_DATA/auth.db"
DEMO_AUTH="$DEMO_DATA/auth.db"
DEMO_SNAPSHOT="$APP_ROOT/demo_reset/demo/gestor.db"
RUN_ID=$(date -u +%Y%m%dT%H%M%SZ)
BACKUP_DIR="/ruta/absoluta/copies/actualitzacio-$RUN_ID"
```

La carpeta de còpies ha de ser fora d’APP_ROOT i accessible només a l’operador.
Comprovar els muntatges Compose abans de parar. No assumir que producció i demo
comparteixen auth.db. Si la comparteixen, indicar-la només una vegada.

1. Registrar commit anterior i imatges anteriors. Conservar els fitxers Compose i
   d’entorn dins de la còpia privada; no imprimir-los ni incorporar-los a git.
2. Pausar el reset de demo i les altres tasques que escriguin BD. Si és systemd:
   `sudo systemctl stop NOM.timer NOM.service`; si és cron, desactivar només la
   línia del reset i guardar abans una còpia privada del crontab. Esperar que no
   quedi cap reset en curs. Anotar com reactivar exactament el mecanisme trobat.
3. Recuperar abans de reconstruir la imatge la configuració antiga de cada
   contenidor que en tingui (no n’imprimeix el contingut):

```bash
umask 077
mkdir -p "$BACKUP_DIR"
git -C "$APP_ROOT" rev-parse HEAD > "$BACKUP_DIR/commit-anterior.txt"
docker compose -p "$PROD_PROJECT" -f "$PROD_COMPOSE" images -q "$PROD_SERVICE" > "$BACKUP_DIR/imatge-prod.txt"
docker compose -p "$DEMO_PROJECT" -f "$DEMO_COMPOSE" images -q "$DEMO_SERVICE" > "$BACKUP_DIR/imatge-demo.txt"
test -s "$BACKUP_DIR/imatge-prod.txt"
test -s "$BACKUP_DIR/imatge-demo.txt"
docker image tag "$(cat "$BACKUP_DIR/imatge-prod.txt")" "gestor-backend-prod-rollback:$RUN_ID"
docker image tag "$(cat "$BACKUP_DIR/imatge-demo.txt")" "gestor-backend-demo-rollback:$RUN_ID"
if docker compose -p "$PROD_PROJECT" -f "$PROD_COMPOSE" exec -T "$PROD_SERVICE" test -f /app/config/config.json; then
  docker compose -p "$PROD_PROJECT" -f "$PROD_COMPOSE" cp "$PROD_SERVICE":/app/config/config.json "$BACKUP_DIR/config-prod.json"
fi
if docker compose -p "$DEMO_PROJECT" -f "$DEMO_COMPOSE" exec -T "$DEMO_SERVICE" test -f /app/config/config.json; then
  docker compose -p "$DEMO_PROJECT" -f "$DEMO_COMPOSE" cp "$DEMO_SERVICE":/app/config/config.json "$BACKUP_DIR/config-demo.json"
fi
docker compose -p "$PROD_PROJECT" -f "$PROD_COMPOSE" stop "$PROD_SERVICE"
docker compose -p "$DEMO_PROJECT" -f "$DEMO_COMPOSE" stop "$DEMO_SERVICE"
sudo tar --acls --xattrs --numeric-owner -C "$APP_ROOT" --exclude='./logs' --exclude='*.log' \
  --exclude='./backend/venv' --exclude='./frontend/node_modules' \
  -czpf "$BACKUP_DIR/aplicacio-abans.tar.gz" .
sudo tar -tzf "$BACKUP_DIR/aplicacio-abans.tar.gz" > "$BACKUP_DIR/inventari-privat.txt"
```

Amb tots els escriptors aturats, la còpia completa inclou BD i fitxers WAL/SHM
coherents, configuració, demo activa i instantània del reset. Si algun volum o
AUTH_DB_PATH és fora d’APP_ROOT, copiar-lo també amb propietaris i permisos
preservats i anotar el camí a l’inventari privat. Verificar l’arxiu i la còpia
abans de continuar. No compartir l’inventari: pot contenir noms de centres.

## Esquemes anteriors als antics _ensure_*

La 0003 ja tracta directament els esquemes coneguts que encara no tenen
`substitucions.aula`, `vigilancies.ordre` o `users.versio_sessio`, també a la
instantània de demo. **No executar prèviament els `_ensure_*` de la imatge
anterior**: el seu ompliment d’ordre podia modificar valors. L’actualització
normalitza l’esquema i conserva els valors que ja hi ha, sense reconstruir
regles de negoci antigues ni reexecutar migradors de dades.

Si la comprovació rebutja una estructura, mantenir els serveis aturats i
restaurar una còpia compatible o demanar ajuda. No improvisar ALTER, DROP,
stamp ni preparacions amb versions anteriors.

## Actualitzar codi, configuració i permisos

Actualitzeu el codi (`git fetch`, reviseu els canvis i `git pull`). Després, amb
els backends encara aturats:

```bash
# Instal·lar les còpies antigues només si no hi ha ja configuració al volum.
if [ -f "$BACKUP_DIR/config-prod.json" ] && [ ! -e "$PROD_DATA/config.json" ]; then
  sudo install -m 600 -o 10001 -g 10001 "$BACKUP_DIR/config-prod.json" "$PROD_DATA/config.json"
fi
if [ -f "$BACKUP_DIR/config-demo.json" ] && [ ! -e "$DEMO_DATA/config.json" ]; then
  sudo install -m 600 -o 10001 -g 10001 "$BACKUP_DIR/config-demo.json" "$DEMO_DATA/config.json"
fi
# Els directoris, no només els .db, necessiten escriptura per a WAL i journals.
sudo chown -R 10001:10001 "$PROD_DATA" "$DEMO_DATA"
sudo chmod -R u+rwX "$PROD_DATA" "$DEMO_DATA"
sudo chown -R 10001:10001 "$(dirname "$DEMO_SNAPSHOT")"
sudo chmod -R u+rwX "$(dirname "$DEMO_SNAPSHOT")"
docker compose -p "$PROD_PROJECT" -f "$PROD_COMPOSE" build "$PROD_SERVICE"
docker compose -p "$DEMO_PROJECT" -f "$DEMO_COMPOSE" build "$DEMO_SERVICE"
```

No fer `chmod 777` ni `chown` de la carpeta de codi. Si hi ha volums o fitxers
externs, incloent-hi un APP_CONFIG_PATH explícit, donar els mateixos permisos a
les seves carpetes de dades. Ajustar la tasca de reset perquè les còpies que
creï també siguin de `10001:10001`, llegibles i escrivibles per aquest usuari.
Si el reset usa `cp -a`, la propietat de la instantània anterior ho garanteix;
si usa una altra ordre, definir la propietat explícitament després de copiar.
No reactivar el reset fins que s’hagi comprovat aquest punt.

## Migrar totes les BD, també la instantània

Crear un manifest privat de camins explícits, sense imprimir noms de centres.
Inclou tots els `*/gestor.db` dels dos DATA_DIR (també centres desactivats), les
auth i la instantània. Executar el codi nou dins d’un contenidor efímer amb els
mateixos UID/GID. Els muntatges conserven els camins absoluts del manifest;
només es munten els directoris indicats, mai l’arrel sencera del servidor.

```bash
export PROD_DATA DEMO_DATA PROD_AUTH DEMO_AUTH DEMO_SNAPSHOT BACKUP_DIR
python3 - <<'PY'
import os
from pathlib import Path
roots = [Path(os.environ[k]).resolve() for k in ('PROD_DATA', 'DEMO_DATA')]
items = [('auth', Path(os.environ[k]).resolve()) for k in ('PROD_AUTH', 'DEMO_AUTH')]
items += [('data', p.resolve()) for root in roots for p in sorted(root.glob('*/gestor.db'))]
items += [('data', Path(os.environ['DEMO_SNAPSHOT']).resolve())]
seen = set()
with open(Path(os.environ['BACKUP_DIR']) / 'bd-manifest.nul', 'xb') as f:
    for kind, path in items:
        if path in seen:
            continue
        if not path.is_file():
            raise SystemExit('Falta una BD esperada; no continuar.')
        seen.add(path)
        f.write(('--' + kind).encode() + b'\0' + str(path).encode() + b'\0')
print('BD previstes:', len(seen))
PY
mapfile -d '' -t DB_ARGS < "$BACKUP_DIR/bd-manifest.nul"
sudo install -d -m 700 -o 10001 -g 10001 "$BACKUP_DIR/sqlite"
# Cal que els camins dins del contenidor coincideixin amb els del manifest.
MOUNTS=(-v "$PROD_DATA:$PROD_DATA" -v "$DEMO_DATA:$DEMO_DATA"
        -v "$(dirname "$DEMO_SNAPSHOT"):$(dirname "$DEMO_SNAPSHOT")"
        -v "$BACKUP_DIR/sqlite:/copies")
docker compose -p "$PROD_PROJECT" -f "$PROD_COMPOSE" run --rm --no-deps --user 10001:10001 \
  "${MOUNTS[@]}" --entrypoint python "$PROD_SERVICE" \
  -m scripts.migra_esquemes --comprova "${DB_ARGS[@]}"
# Reviseu el resultat de la comprovació abans d'aplicar la migració.
docker compose -p "$PROD_PROJECT" -f "$PROD_COMPOSE" run --rm --no-deps --user 10001:10001 \
  "${MOUNTS[@]}" --entrypoint python "$PROD_SERVICE" \
  -m scripts.migra_esquemes --aplica --backup-dir /copies "${DB_ARGS[@]}"
docker compose -p "$PROD_PROJECT" -f "$PROD_COMPOSE" run --rm --no-deps --user 10001:10001 \
  "${MOUNTS[@]}" --entrypoint python "$PROD_SERVICE" \
  -m scripts.migra_esquemes --comprova "${DB_ARGS[@]}"
```

Afegir muntatges explícits per a qualsevol auth fora dels dos DATA_DIR. El nou
script no carrega `.env` ni busca centres pel seu compte. A la tercera ordre,
totes les BD han de dir `vigent`; repetir `--aplica` no ha de fer cap canvi ni
crear més còpies. Qualsevol error ha de donar sortida diferent de zero.

## Arrencar i comprovar

```bash
docker compose -p "$PROD_PROJECT" -f "$PROD_COMPOSE" up -d --no-deps "$PROD_SERVICE"
docker compose -p "$DEMO_PROJECT" -f "$DEMO_COMPOSE" up -d --no-deps "$DEMO_SERVICE"
docker compose -p "$PROD_PROJECT" -f "$PROD_COMPOSE" exec -T "$PROD_SERVICE" id -u
docker compose -p "$DEMO_PROJECT" -f "$DEMO_COMPOSE" exec -T "$DEMO_SERVICE" id -u
```

Els dos UID han de ser 10001. Comprovar web i `/api/health` (200), entrada i
operacions diàries en un centre de prova, configuració persistent després d’un
reinici, i escriptures de BD sense errors de permisos. Revisar errors d’arrencada
sense compartir IP ni dades dels registres. No cal cap canvi al frontend.
Comprovar també una còpia **temporal** de la instantània amb el mateix mecanisme
de reset i `--comprova`: ha de mantenir la revisió i els permisos. Reactivar el
reset i les tasques periòdiques exactament com estaven només després d’aquesta
validació. Conserveu la còpia completa fins que hàgiu validat l'actualització.

## Desfer el desplegament

1. Aturar altre cop els dos backends i el reset. No permetre escriptures mentre
   es restaura. Si hi ha hagut activitat des del desplegament, fer-ne una còpia
   separada abans i decidir com conservar-la: restaurar perd els
   canvis posteriors a la còpia.
2. Mostrar abans el llistat de fitxers que se sobreescriuran o retiraran. Inclou
   els `config.json` nous i qualsevol `.db-wal`/`.db-shm` creat després de la
   còpia. No restaurar un .db sobre un WAL d’una altra versió.
3. Apartar la instal·lació fallida en una carpeta privada i restaurar l’arxiu
   complet a APP_ROOT, amb el mateix propietari/permisos que tenia abans. Amb
   APP_ROOT i BACKUP_DIR ja verificats, exemple (no executar sobre un directori
   en ús):

```bash
FAILED_ROOT="${APP_ROOT}.fallida-$RUN_ID"
sudo mv "$APP_ROOT" "$FAILED_ROOT"
sudo mkdir -p "$APP_ROOT"
sudo tar --acls --xattrs --numeric-owner -xzpf "$BACKUP_DIR/aplicacio-abans.tar.gz" -C "$APP_ROOT"
```

4. Restaurar també els volums externs des de les còpies anotades. Verificar que
   codi, Compose, variables d’entorn, configuració, auth, centres, demo activa i
   instantània del reset són els del mateix instant. Els permisos originals es
   restauren amb l’arxiu; no cal deduir quin UID tenia cada fitxer.
5. Arrencar amb el codi de l’arxiu i les imatges anteriors conservades:

```bash
test "$(git -C "$APP_ROOT" rev-parse HEAD)" = "$(cat "$BACKUP_DIR/commit-anterior.txt")"
# Fer servir exactament les imatges conservades, sense reconstruir dependències.
export BACKUP_DIR PROD_SERVICE DEMO_SERVICE RUN_ID
python3 - <<'PY_RETORN'
import json
import os
from pathlib import Path
for kind, key in [('prod', 'PROD_SERVICE'), ('demo', 'DEMO_SERVICE')]:
    override = {'services': {os.environ[key]: {'image': f"gestor-backend-{kind}-rollback:{os.environ['RUN_ID']}"}}}
    path = Path(os.environ['BACKUP_DIR']) / f'rollback-{kind}.json'
    path.write_text(json.dumps(override))
PY_RETORN
docker compose -p "$PROD_PROJECT" -f "$PROD_COMPOSE" -f "$BACKUP_DIR/rollback-prod.json" \
  up -d --no-deps --no-build --pull never --force-recreate "$PROD_SERVICE"
docker compose -p "$DEMO_PROJECT" -f "$DEMO_COMPOSE" -f "$BACKUP_DIR/rollback-demo.json" \
  up -d --no-deps --no-build --pull never --force-recreate "$DEMO_SERVICE"
```

Comprovar 200, entrada i dades del centre de prova; reactivar el reset anterior.
Conserveu aquests tags locals fins que s'hagi validat l'actualització; no feu neteja
d’imatges durant la finestra de desplegament.
No executar `alembic downgrade base`: es nega a esborrar les taules. La reversió
segura és la còpia completa.

Per crear revisions futures, afegir un fitxer a `schema_migrations/versions/`
amb `down_revision` apuntant a l’anterior, usar `op`/SQL congelat i discriminar
`context.config.attributes['kind']` quan només afecti auth o dades. Mai editar la
base ni importar els models actuals des d’una revisió. Afegir tests d’upgrade,
rollback i creació nova; el guardià dels models/base s’haurà d’adaptar a l’esquema
vigent quan canviï l’esquema canònic.

## Registres d’accés i retenció de 30 dies

El backend registra data i hora, usuari, rol, centre, mètode HTTP, plantilla de
la ruta, codi de resposta i durada. Per exemple, la ruta queda com
`/api/substitucions/{data}/{hora}/{professor}`: no hi desa els valors dels
paràmetres, la query ni la IP. Uvicorn no genera un segon registre d’accés.
Els errors que indiquen la ruta també fan servir la plantilla; les traces de
diagnòstic continuen al registre d’errors de la consola.

Sense `ACCESS_LOG_PATH`, els resums continuen a la consola del backend. Per
conservar-los en un fitxer independent, cal configurar
`ACCESS_LOG_PATH=/var/log/gestor/access.log`. El backend rota el fitxer diàriament
a mitjanit UTC i en conserva 30 còpies; no duplica aquests resums a la consola.

Caddy desa l’accés a `/var/log/caddy/access.log` (volum `./logs/caddy`), amb les
IP escapçades, sense query i amb els segments de professor i assignatura
substituïts per `{professor}`, `{nom}` o `{assignatura}`. El filtre compartit
cobreix Substitucions (absències, hores i assignació), Estadístiques (detall)
i les assignatures de Configuració i Prioritats. Els fitxers rotats tenen un
termini màxim de `720h` (30 dies) i un màxim de 10 còpies. La sintaxi de filtratge
i de retenció és la de la [documentació de Caddy](https://caddyserver.com/docs/caddyfile/directives/log).

Canvis que cal fer al servidor en actualitzar una instal·lació existent:

1. Crear `logs/backend` i donar-hi propietat `10001:10001`, l’UID/GID del backend.
2. Afegir la variable `ACCESS_LOG_PATH` indicada a l’entorn del backend i el volum
   `./logs/backend:/var/log/gestor` al servei `backend` (hi ha el comentari a
   `docker-compose.yml.example`). El fitxer ha de ser escrivible per aquest UID.
3. Incorporar el filtre i `roll_keep_for 720h` de `Caddyfile.example` al Caddyfile
   del servidor, conservant el domini i els altres ajustos propis. Mantenir el
   volum de Caddy existent.
4. Validar el Caddyfile amb `caddy validate --config /etc/caddy/Caddyfile`, i
   reiniciar Caddy quan es desplegui: els canvis de rotació d’un fitxer ja obert
   necessiten reinici. Recrear el backend perquè rebi la variable i el volum.
5. Comprovar que el filtre funciona, no només que la configuració és vàlida: fer
   una petició sense sessió amb un nom inventat, per exemple
   `/api/substitucions/2026-01-01/1/NomDeProva?x=1`, i confirmar que a la línia
   del registre de Caddy hi ha `{professor}` i no hi ha `NomDeProva` ni `x=1`.

La retenció de la consola sense `ACCESS_LOG_PATH` continua depenent del sistema
que la recull (a l’exemple de Compose, rotació per mida de Docker).