diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ba6d07b --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Consommé par `docker compose` pour la substitution ${...} dans les compose. +# Généré à partir de .secrets.env par scripts/01-render-config.sh. NE PAS committer .env. +PG_PASS=CHANGE_ME +GK_ID=CHANGE_ME +GK_SECRET=CHANGE_ME diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f14189 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# ---- Secrets : NE JAMAIS committer ---- +.secrets.env +.env +config.yml +garage.toml +*.key +*.pem +.ark_coolify_token + +# ---- Généré / local ---- +*.log +tmp/ +backups/ diff --git a/.secrets.env.example b/.secrets.env.example new file mode 100644 index 0000000..fae039e --- /dev/null +++ b/.secrets.env.example @@ -0,0 +1,15 @@ +# Copie ce fichier en .secrets.env puis génère de VRAIES valeurs (scripts/00-gen-secrets.sh). +# NE JAMAIS committer .secrets.env (il est dans .gitignore). + +# Mot de passe PostgreSQL -> openssl rand -hex 16 +PG_PASS=CHANGE_ME +# Django SECRET_KEY (50 chars) -> openssl rand -base64 48 | tr -d '\n/+=' | head -c 50 +SECRET_KEY=CHANGE_ME +# Secret RPC Garage (64 hex) -> openssl rand -hex 32 +GARAGE_RPC=CHANGE_ME +# Token admin Garage -> openssl rand -hex 24 +GARAGE_ADMIN_TOKEN=CHANGE_ME +# Clé d'accès S3 Garage (GK + 24 hex) -> echo GK$(openssl rand -hex 12) +GK_ID=CHANGE_ME +# Clé secrète S3 Garage (64 hex) -> openssl rand -hex 32 +GK_SECRET=CHANGE_ME diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f6d2365 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +# Arkindex sur Coolify — cibles pratiques. +# Les scripts détaillés sont dans scripts/. Voir README.md et docs/. + +.DEFAULT_GOAL := help +SHELL := /bin/bash + +help: ## Affiche cette aide + @grep -hE '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | \ + awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-16s\033[0m %s\n",$$1,$$2}' + +secrets: ## Génère .secrets.env (si absent) + @scripts/00-gen-secrets.sh + +config: ## Rend config.yml / garage.toml / cantaloupe.properties / .env + @scripts/01-render-config.sh + +deploy-cli: ## Déploiement complet en docker compose (CLI) + @scripts/10-up-cli.sh + +garage: ## (Ré)initialise Garage (layout, clé, buckets) + @scripts/20-garage-init.sh + +init: ## Migrations + ImageServers + farm Ponos + @scripts/30-arkindex-init.sh + +admin: ## Crée/maj le compte admin (ADMIN_PASS=... optionnel) + @scripts/40-create-admin.sh + +verify: ## Vérification end-to-end (ADMIN_PASS=... pour tester le login) + @scripts/50-verify.sh + +coolify-adopt: ## Crée la ressource Coolify (API) — voir sortie pour la bascule + @scripts/90-coolify-adopt.sh + +backup: ## Dump PostgreSQL + inventaire Garage -> backups/ + @scripts/99-backup-db.sh + +ps: ## Liste les conteneurs Arkindex + @docker ps --filter name=ark- --format 'table {{.Names}}\t{{.Status}}' + +logs: ## Logs backend (SVC=ark-worker pour un autre service) + @docker logs -f $${SVC:-ark-backend}$${CONTAINER_SUFFIX:-} + +.PHONY: help secrets config deploy-cli garage init admin verify coolify-adopt backup ps logs diff --git a/README.md b/README.md index dffac17..8cbf809 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,36 @@ Déploiement d'**Arkindex** (plateforme Teklia d'analyse de documents, édition - **Date** : 2026-07-09. - **Dossier** : `~/arkindex-deploy/`. -> ⚠️ Les secrets ne figurent PAS dans ce guide. Ils sont dans `.secrets.env` (généré aléatoirement) -> et injectés dans `.env`, `config.yml`, `garage.toml`, `cantaloupe.properties`. +> ⚠️ Les secrets ne figurent PAS dans le dépôt. Ils sont dans `.secrets.env` (git-ignoré, généré +> aléatoirement) et injectés dans `.env`, `config.yml`, `garage.toml`. Voir `docs/SECURITY.md`. + +## Démarrage rapide +```bash +make secrets # génère .secrets.env +make config # rend config.yml / garage.toml / cantaloupe.properties / .env +make deploy-cli # déploiement complet en docker compose (CLI) +make verify # vérification end-to-end +# adoption dans l'UI Coolify : +make coolify-adopt # crée la ressource, puis suivre les instructions affichées +make help # toutes les cibles +``` +Adapter d'abord le domaine et les versions dans **`deploy.conf`**. + +## Structure du dépôt +``` +README.md ce guide (architecture, procédure, problèmes/fixes) +deploy.conf config NON-secrète (domaine, versions, uuids Coolify) +docker-compose.yml stack CLI (docker compose) +docker-compose.coolify.yml variante adoptée par Coolify (services ark-*, volumes external) +config.yml / garage.toml / fichiers de conf RÉELS (git-ignorés — contiennent des secrets ; + cantaloupe.properties montés par la stack en cours) +templates/*.tmpl templates rendus par scripts/01 (envsubst) +scripts/ 00-gen-secrets, 01-render-config, 10-up-cli, 20-garage-init, + 30-arkindex-init, 40-create-admin, 50-verify, 90-coolify-adopt, + 99-backup-db, lib.sh +docs/ ARCHITECTURE.md, TROUBLESHOOTING.md, RUNBOOK.md, SECURITY.md +Makefile raccourcis +``` --- @@ -260,17 +288,36 @@ free -h; docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}' | gr --- -## 7. Adoption dans l'UI Coolify (étape en attente) +## 7. Adoption dans l'UI Coolify — ✅ FAIT -Utiliser `docker-compose.coolify.yml`. Deux voies : -- **API Coolify** : `POST /api/v1/services` avec `docker_compose_raw` (nécessite un token API - read/write) — créer un projet « arkindex », y coller le compose, fournir les secrets en variables - d'environnement (`PG_PASS`, `GK_ID`, `GK_SECRET`), puis déployer. -- **UI** : New Resource → Docker Compose → coller `docker-compose.coolify.yml`. +La stack est désormais une ressource **gérée par Coolify** : projet **arkindex**, service **arkindex** +(uuid `tdquwauzxsq0985fk9kjqqys`), conteneurs `ark-*-tdquwauzxsq0985fk9kjqqys`. Source = +`docker-compose.coolify.yml`. Pilotage (deploy/logs/restart) via le dashboard Coolify. -Bascule sans perte : arrêter la stack CLI (`docker compose down` **sans `-v`** → les volumes -persistent), puis déployer via Coolify qui réutilise les volumes `external`. Prévoir une **coupure de -quelques secondes**. +### Procédure suivie (API Coolify) +```bash +TOKEN=$(cat ~/.ark_coolify_token) +# 1) projet + récup environnement production +curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"name":"arkindex"}' http://localhost:8000/api/v1/projects +# 2) créer le service (docker_compose_raw en BASE64 obligatoire), instant_deploy=false +# body: server_uuid, project_uuid, environment_name/uuid, name, docker_compose_raw(b64) +# 3) renseigner les secrets en variables d'env du service (PATCH .../envs) : PG_PASS, GK_ID, GK_SECRET +# 4) arrêter la stack CLI puis déployer UNE SEULE FOIS : +cd ~/arkindex-deploy && docker compose down # garde les volumes en backup +curl -s -X POST -H "Authorization: Bearer $TOKEN" \ + "http://localhost:8000/api/v1/deploy?uuid=tdquwauzxsq0985fk9kjqqys" +``` + +### ⚠️ Points d'attention (cf. aussi P11) +- **Coolify remappe les volumes nommés** en `_*` et **ignore `external:`** → au premier + déploiement la stack repart sur des **volumes vides**. Comme il n'y avait pas encore de données + réelles, on a simplement **ré-initialisé** (Garage + `migrate` + image servers + admin) contre les + conteneurs Coolify (`docker exec ark-backend- …`). Les anciens volumes `arkindex-deploy_*` + restent en backup (supprimables : `docker volume rm arkindex-deploy_pgdata …`). +- **Coolify préserve** : réseau `coolify` externe, labels Traefik, montages bind **absolus**, et les + **noms de service** (alias DNS `ark-*`). Il écrase `container_name` en `-`. +- **Ne déclencher le deploy qu'une fois** (POST *ou* GET, pas les deux) — sinon double déploiement. --- diff --git a/deploy.conf b/deploy.conf new file mode 100644 index 0000000..b949394 --- /dev/null +++ b/deploy.conf @@ -0,0 +1,40 @@ +# Configuration NON-secrète du déploiement Arkindex/Coolify. +# Sourcé par les scripts (scripts/lib.sh). Les SECRETS sont dans .secrets.env (git-ignoré). + +# --- Domaine --- +# Hostname racine ; les sous-domaines s3., iiif., uploads.iiif., +# ingest.iiif. et .s3. en découlent. +DOMAIN="ark.nak0x.dev" + +# --- Versions d'images (dernières finales stables, pas de -rc/-beta) --- +BACKEND_TAG="1.12.2" +FRONTEND_TAG="1.12.2" +TASKS_TAG="0.6.2" +CANTALOUPE_TAG="5.0.7" +POSTGIS_TAG="17-3.5" +SOLR_TAG="9" +GARAGE_TAG="v2.1.0" +REDIS_TAG="alpine" + +# --- Compte admin --- +ADMIN_EMAIL="theolesage38@gmail.com" +ADMIN_DISPLAY_NAME="nak0x" + +# --- Registry Teklia (images publiques, pull anonyme) --- +REGISTRY="registry.gitlab.teklia.com" + +# --- Buckets S3 Garage (fixes) --- +GARAGE_BUCKETS="uploads ingest export iiif-cache ponos-artifacts ponos-logs staging thumbnails training" + +# --- Coolify (identifiants de la ressource déployée ; le TOKEN est hors dépôt) --- +COOLIFY_API="http://localhost:8000/api/v1" +COOLIFY_TOKEN_FILE="$HOME/.ark_coolify_token" +COOLIFY_SERVER_UUID="vedsaj5e96wtmu8jcd3nkxi2" +COOLIFY_PROJECT_UUID="h1s06qu51vf7x33axjy94d0e" +COOLIFY_ENV_UUID="gqje671dsr1ulvb7y9fw80t2" +COOLIFY_ENV_NAME="production" +COOLIFY_SERVICE_UUID="tdquwauzxsq0985fk9kjqqys" + +# Suffixe des conteneurs gérés par Coolify (= -). +# Vide = mode CLI (conteneurs ark-* sans suffixe). +CONTAINER_SUFFIX="-tdquwauzxsq0985fk9kjqqys" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..588677c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,76 @@ +# Architecture — Arkindex sur Coolify + +## Vue d'ensemble + +``` + Internet (navigateur) + │ HTTPS (Let's Encrypt, HTTP-01) + ▼ + ┌─────────────────────────────┐ + │ coolify-proxy (Traefik v3) │ :80/:443 + │ routage par labels Docker │ + └───────┬───────────┬──────────┘ + ark.nak0x.dev │ │ s3.nak0x… / iiif.nak0x… + (split par chemin) │ │ + ┌───────────────┬────────┘ └──────────┬──────────────┐ + ▼ ▼ ▼ ▼ + ark-front ark-backend ark-garage ark-cantaloupe + (SPA nginx) (API gunicorn) (S3) (IIIF) + │ │ \ ▲ │ + │ │ \ hairpin S3 │ http interne│ http interne + │ │ └──► https://s3. ───┘ :3900 │ :3900 + │ ▼ │ + │ ark-worker (RQ/Ponos) │ + │ │ ▼ + │ ┌──────┴──────┬──────────┐ (lit les images + ▼ ▼ ▼ ▼ dans les buckets) + [réseau "ark"] ark-database ark-redis ark-solr + (Postgres) (RQ+cache) (recherche) +``` + +## Réseaux Docker +- **`ark`** (interne) : tous les services ; communication par **nom de service** (`ark-database`, + `ark-redis`, …). Ces noms sont **uniques** exprès (cf. Troubleshooting P4). +- **`coolify`** (externe, partagé) : uniquement `ark-garage`, `ark-cantaloupe`, `ark-backend`, + `ark-front`, `ark-worker` — pour être joignables par le proxy Traefik et pour le hairpin S3. +- En mode Coolify, un 3ᵉ réseau par ressource (``) est ajouté automatiquement. + +## TLS & hostnames (contrainte centrale) +Coolify n'a que le challenge **HTTP-01** → **pas de certificat wildcard**. Or Arkindex sert S3 et +IIIF en **virtual-hosted** (`.s3.`, `.iiif.`). Comme l'ensemble des +hostnames est **fini et connu**, on les énumère dans les règles `Host(...)` Traefik → Traefik émet +un certificat HTTP-01 par hostname. Le **wildcard DNS** `*.nak0x.dev` (résolution, pas TLS) fait +pointer tous ces noms vers le serveur, donc **zéro entrée DNS à créer**. + +Hostnames servis : +- `ark.` — front (catch-all, priorité 1) + backend (`/api`,`/api-docs`,`/admin`,`/rq`,`/static`, priorité 100) +- `s3.` + `.s3.` × 9 buckets — Garage +- `iiif.`, `uploads.iiif.`, `ingest.iiif.` — Cantaloupe + +## Stockage S3 (Garage) +- Le backend utilise boto3 avec `addressing_style="auto"` (codé en dur) → **virtual-hosted** + (`.s3.`). D'où l'énumération des sous-domaines de bucket + `root_domain` dans + `garage.toml` (Garage mappe sous-domaine → bucket). +- **Hairpin** : le backend doit joindre `https://s3.` (les URLs présignées données au + navigateur utilisent ce host) : requête → IP publique → `coolify-proxy` (TLS valide) → Garage. + Vérifié fonctionnel ici. +- Cantaloupe, lui, parle à Garage **en interne** (`http://ark-garage:3900`, sans TLS). +- 9 buckets : `uploads, ingest, export, iiif-cache, ponos-artifacts, ponos-logs, staging, thumbnails, training`. +- Clé S3 : importée dans Garage (admin API/CLI) = `GK_ID`/`GK_SECRET` de `.secrets.env`. + +## IIIF (Cantaloupe) +- Image custom Teklia : `ScriptLookupStrategy` + `S3Source.top_domain = iiif.` → le bucket + est déduit du sous-domaine (`uploads.iiif.` → bucket `uploads`). +- 2 `ImageServer` en base : `12345` (uploads) et `67890` (ingest), URLs `https://…iiif./iiif/2`. + +## Recherche (Solr) +- SolrCloud (`solr -f -cloud`). Les collections sont créées **par corpus** (`project-`) à la + volée lors de l'indexation (`arkindex reindex`). Rien à pré-créer. + +## Images (versions) +`backend/frontend 1.12.2`, `tasks 0.6.2`, `cantaloupe 5.0.7`, `postgis 17-3.5`, `solr 9`, +`garage v2.1.0`, `redis alpine`. Toutes **publiques** (pull anonyme). + +## Bootstrap : pourquoi on ne l'utilise pas +`arkindex bootstrap` est **dev-only** : il refuse si `DEBUG=False` et code les URLs en +`*.ark.localhost`. On réplique ses 3 actions avec le vrai domaine (`scripts/20` + `scripts/30`). diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 0000000..8795974 --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,76 @@ +# Runbook — exploitation (day-2) + +> En mode Coolify, les conteneurs ont le suffixe `-tdquwauzxsq0985fk9kjqqys` (cf. `deploy.conf` +> `CONTAINER_SUFFIX`). Les helpers `scripts/*.sh` le gèrent automatiquement via `cname`. + +## Consulter / piloter +```bash +docker ps --filter name=ark- # état +docker logs -f ark-backend- # logs backend +docker logs -f ark-worker- # logs worker (tâches) +# depuis Coolify : dashboard -> projet arkindex -> service arkindex (Deploy / Logs / Restart) +``` + +## Redémarrer / redéployer +```bash +# CLI : +cd ~/arkindex-deploy && docker compose restart backend +# Coolify (API) : +curl -s -X POST -H "Authorization: Bearer $(cat ~/.ark_coolify_token)" \ + "http://localhost:8000/api/v1/deploy?uuid=tdquwauzxsq0985fk9kjqqys" +``` + +## Mettre à jour Arkindex (nouvelle version d'image) +1. Vérifier la dernière finale (cf. Troubleshooting P1). +2. Changer les tags dans `docker-compose.coolify.yml` (backend/front) + `deploy.conf`. +3. En Coolify : mettre à jour le compose du service (UI ou API) puis redéployer. +4. **Toujours** relancer les migrations : + ```bash + docker exec ark-backend- arkindex migrate + ``` + +## Shell Django / admin +```bash +docker exec -it ark-backend- arkindex shell +docker exec -it ark-backend- arkindex createsuperuser # (interactif) +scripts/40-create-admin.sh # (scripté, idempotent) +``` + +## Recherche (Solr) +```bash +# (re)indexer un corpus après import de données +docker exec ark-backend- arkindex reindex --corpus-id --drop +# tout réindexer +docker exec ark-backend- arkindex reindex --all +``` + +## Stockage S3 (Garage) +```bash +G=ark-garage- +docker exec $G /garage bucket list +docker exec $G /garage bucket info staging +docker exec $G /garage stats +``` + +## Sauvegardes +```bash +scripts/99-backup-db.sh # pg_dump + inventaire buckets -> backups/ +# volumes gérés par Coolify : arkindex-deploy_* (anciens, backup) et _* (actifs) +docker volume ls | grep -E 'arkindex-deploy_|tdquwauzxsq' +``` + +## Nettoyage des anciens volumes CLI (après validation Coolify) +```bash +docker volume rm arkindex-deploy_pgdata arkindex-deploy_redisdata \ + arkindex-deploy_solrdata arkindex-deploy_garagedata +``` + +## Santé / vérification complète +```bash +ADMIN_PASS='…' scripts/50-verify.sh +``` + +## Mémoire serrée ? +- Soupape : désactiver la recherche → `features.search: no` dans `config.yml`, retirer le service + `ark-solr`, redéployer. Solr est le plus gros consommateur (~450 Mo). +- Le swap (4 Go) absorbe les pics ; surveiller `free -h` et `docker stats`. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..5b6580b --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,97 @@ +# Troubleshooting — problèmes rencontrés & diagnostics + +Récapitulatif des 11 problèmes du déploiement (symptôme → cause → fix), + commandes de diagnostic +réutilisables. Voir aussi `README.md` §5. + +--- + +## P1 — `manifest unknown` au pull d'une image +**Symptôme** : `docker pull …/backend:1.13.0` → `manifest unknown`. +**Cause** : `1.13.0` n'existe qu'en `-rc1` ; l'extraction du cœur numérique inventait une version. +**Fix** : n'utiliser que des **finales** (dernière = `1.12.2`). Vérifier : +```bash +tok=$(curl -s "https://gitlab.teklia.com/jwt/auth?service=container_registry&scope=repository:arkindex/backend:pull" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') +curl -s -H "Authorization: Bearer $tok" https://registry.gitlab.teklia.com/v2/arkindex/backend/tags/list \ + | tr ',' '\n' | grep -oE '"[0-9]+\.[0-9]+\.[0-9]+"' | tr -d '"' | sort -uV | tail +``` + +## P2 — Pas de certificat wildcard possible +**Cause** : Coolify = challenge HTTP-01 seulement. +**Fix** : énumérer chaque hostname dans les règles `Host(...)` (Garage, Cantaloupe). Diag certs : +```bash +for h in ark s3.ark staging.s3.ark uploads.iiif.ark; do + curl -sS -o /dev/null -w "$h.nak0x.dev -> %{http_code} tls=%{ssl_verify_result}\n" "https://$h.nak0x.dev/" +done # tls=0 = certificat valide +``` + +## P3 — `PermissionError: /arkindex.yml` +**Cause** : fichiers montés en `600` (uid 1000), backend en uid **2000**. +**Fix** : `chmod 644 config.yml garage.toml cantaloupe.properties` (fait par `scripts/01`). + +## P4 — Worker : `redis AuthenticationError` ⭐ +**Symptôme** : « HELLO must be called with the client already authenticated ». +**Cause** : sur le réseau `coolify`, le nom générique `redis` résout vers **`coolify-redis`** +(protégé par mot de passe), pas le nôtre. +**Diagnostic** : +```bash +docker exec ark-worker- getent hosts redis # IP de coolify-redis (mauvais) +docker exec ark-worker- getent hosts ark-redis # IP correcte +``` +**Fix** : référencer les services par leur **nom unique** `ark-redis`/`ark-database`/`ark-solr`/ +`ark-garage` (jamais `redis`/`db`). Appliqué dans `config.yml` et `cantaloupe.properties`. + +## P5 — `bootstrap` refuse en prod +**Cause** : `You cannot run this script in production.` + URLs `*.ark.localhost` codées en dur. +**Fix** : réplique manuelle → `scripts/20-garage-init.sh` + `scripts/30-arkindex-init.sh`. + +## P6 — S3 en virtual-host (et non path-style) +**Cause** : backend force `addressing_style="auto"` (boto3) → `.s3.`. +**Fix** : énumérer les 9 sous-domaines de bucket + `root_domain` dans `garage.toml`. +**Test** : +```bash +docker exec -i ark-backend- arkindex shell <<'PY' +from arkindex.project.aws import s3 +o=s3.Bucket("staging").Object("t"); o.put(Body=b"x"); print(o.get()["Body"].read()); o.delete() +PY +``` + +## P7 — Trop de workers gunicorn +**Cause** : `nproc*2+1` = 9. +**Fix** : `MAX_WORKERS: "3"` dans l'env du backend. + +## P8 — Statics admin/swagger non stylés +**Cause** : pas de WhiteNoise ; statics servis par CDN. +**Fix** : `static.cdn_assets_url: https://assets.teklia.com/arkindex`. Cosmétique (API/SPA OK). + +## P9 — `reindex --setup` demande un corpus +**Cause** : collections Solr créées **par corpus** à la volée. Rien à pré-créer. + +## P10 — Backend sans Pillow (tests) +**Cause** : traitement image côté workers. Pour un test : générer le JPEG hors backend + +uploader via un conteneur `amazon/aws-cli` (cf. `scripts/50-verify.sh`). + +## P11 — Adoption Coolify : volumes vidés + container_name écrasé +**Causes** : +1. Coolify **remappe** les volumes nommés en `_*` et **ignore `external:`** → volumes vides. +2. Coolify écrase `container_name` en `-` (mais garde le **nom de service** = alias DNS). +**Fix** : +1. Sur un déploiement neuf : ré-init (`scripts/20/30/40`) contre les conteneurs Coolify. Pour des + données existantes : utiliser des **bind-mounts absolus** (dossiers hôte) au lieu de volumes nommés. +2. Nommer les **services** `ark-*` (déjà unique → pas de collision P4). +**Piège** : ne déclencher le deploy **qu'une fois** (POST *ou* GET `/api/v1/deploy?uuid=`). + +--- + +## Diagnostics rapides +```bash +# logs d'un service +docker logs --tail 50 ark-backend- +# résolution DNS depuis un conteneur +docker exec ark-worker- getent hosts ark-redis +# état santé +docker inspect ark-backend- --format '{{.State.Health.Status}}' +# mémoire par conteneur +docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}' | grep ark- +# routers Traefik vus par le proxy +docker exec coolify-proxy wget -qO- http://localhost:8080/api/http/routers 2>/dev/null | head +``` diff --git a/scripts/00-gen-secrets.sh b/scripts/00-gen-secrets.sh new file mode 100755 index 0000000..dfd4d48 --- /dev/null +++ b/scripts/00-gen-secrets.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Génère .secrets.env avec des valeurs aléatoires (idempotent : ne réécrit pas si déjà présent). +source "$(dirname "$0")/lib.sh" + +if [ -f "$ROOT/.secrets.env" ]; then + warn ".secrets.env existe déjà — rien à faire (supprime-le pour régénérer)." + exit 0 +fi + +log "Génération des secrets…" +umask 077 +cat > "$ROOT/.secrets.env" < "$ROOT/config.yml" +envsubst '${DOMAIN} ${GARAGE_RPC} ${GARAGE_ADMIN_TOKEN}' \ + < "$ROOT/templates/garage.toml.tmpl" > "$ROOT/garage.toml" +envsubst '${DOMAIN}' \ + < "$ROOT/templates/cantaloupe.properties.tmpl" > "$ROOT/cantaloupe.properties" + +cat > "$ROOT/.env" < -> serveur. +# NB : ce script force le mode CLI (conteneurs sans suffixe Coolify). +source "$(dirname "$0")/lib.sh" +export CONTAINER_SUFFIX="" # mode CLI + +cd "$ROOT" + +log "1/6 Secrets + configs" +scripts/00-gen-secrets.sh +scripts/01-render-config.sh + +log "2/6 Infra de base (db, redis, solr, garage)" +docker compose up -d db redis solr garage +# attendre Postgres +until docker inspect ark-database --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; do sleep 3; done + +log "3/6 Init Garage" +scripts/20-garage-init.sh + +log "4/6 Backend + migrations + bootstrap prod" +docker compose up -d backend +until docker inspect ark-backend --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; do sleep 3; done +scripts/30-arkindex-init.sh + +log "5/6 Reste de la stack (cantaloupe, front, worker)" +docker compose up -d cantaloupe front worker + +log "6/6 Compte admin" +scripts/40-create-admin.sh + +ok "Déploiement CLI terminé. Vérifie avec scripts/50-verify.sh" diff --git a/scripts/20-garage-init.sh b/scripts/20-garage-init.sh new file mode 100755 index 0000000..aea624c --- /dev/null +++ b/scripts/20-garage-init.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Initialise Garage : layout du cluster (1 nœud), import de la clé S3, création + droits des buckets. +# Idempotent. Fonctionne en mode CLI ou Coolify (via CONTAINER_SUFFIX). +source "$(dirname "$0")/lib.sh" +load_secrets + +G=$(cname ark-garage) +log "Init Garage ($G)…" + +NODE=$(docker exec "$G" /garage status 2>/dev/null | awk '/NO ROLE ASSIGNED/{print $1}') +if [ -n "${NODE:-}" ]; then + docker exec "$G" /garage layout assign -z dev -c 1G "$NODE" >/dev/null + # applique la version courante+1 (1 si cluster neuf) + VER=$(docker exec "$G" /garage layout show 2>/dev/null | awk '/Current cluster layout version/{print $NF}') + docker exec "$G" /garage layout apply --version "$(( ${VER:-0} + 1 ))" >/dev/null + ok "Layout appliqué." +else + warn "Nœud déjà configuré (layout existant)." +fi + +# Import de la clé S3 (même GK_ID/GK_SECRET que config.yml) +docker exec "$G" /garage key import --yes -n arkindex "$GK_ID" "$GK_SECRET" >/dev/null 2>&1 \ + && ok "Clé S3 importée." || warn "Clé S3 déjà présente." + +for b in $GARAGE_BUCKETS; do + docker exec "$G" /garage bucket create "$b" >/dev/null 2>&1 || true + docker exec "$G" /garage bucket allow --read --write --owner --key "$GK_ID" "$b" >/dev/null 2>&1 || true +done +N=$(docker exec "$G" /garage bucket list 2>/dev/null | grep -c '20' || true) +ok "Buckets prêts ($N)." diff --git a/scripts/30-arkindex-init.sh b/scripts/30-arkindex-init.sh new file mode 100755 index 0000000..e7c1ce7 --- /dev/null +++ b/scripts/30-arkindex-init.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Migrations DB + équivalent PROD de `arkindex bootstrap` (dev-only) : +# crée les 2 ImageServers (uploads/ingest) avec le VRAI domaine + la farm Ponos. +source "$(dirname "$0")/lib.sh" + +log "Migrations…" +dex ark-backend arkindex migrate 2>&1 | tail -2 +ok "Migrations appliquées." + +log "ImageServers + farm Ponos (domaine=$DOMAIN)…" +dexi ark-backend env DOMAIN="$DOMAIN" arkindex shell <<'PYEOF' 2>&1 | tail -3 +import os +from arkindex.images.models import ImageServer +from arkindex.ponos.models import Farm +d = os.environ["DOMAIN"] +for sid, url, bucket, name in [ + (12345, f"https://uploads.iiif.{d}/iiif/2", "uploads", "Local uploads IIIF server"), + (67890, f"https://ingest.iiif.{d}/iiif/2", "ingest", "Ingest IIIF server"), +]: + ImageServer.objects.update_or_create(id=sid, defaults=dict(url=url, s3_bucket=bucket, s3_region="local", display_name=name)) +Farm.objects.update_or_create( + id="001e411a-1111-2222-3333-444455556666", + defaults=dict(name="Bootstrap farm", seed="b12868101dab84984481741663d809d2393784894d6e807ceee0bd95051bf971")) +print("ImageServers + farm OK") +PYEOF +ok "Bootstrap (prod) terminé." diff --git a/scripts/40-create-admin.sh b/scripts/40-create-admin.sh new file mode 100755 index 0000000..63a5e86 --- /dev/null +++ b/scripts/40-create-admin.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Crée (ou met à jour) le compte admin avec toutes les capacités. Génère un mot de passe si non fourni. +# Usage: ADMIN_PASS='monmotdepasse' scripts/40-create-admin.sh (sinon mot de passe aléatoire) +source "$(dirname "$0")/lib.sh" + +PASS="${ADMIN_PASS:-$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)}" +log "Compte admin $ADMIN_EMAIL…" +dexi ark-backend env E="$ADMIN_EMAIL" P="$PASS" N="$ADMIN_DISPLAY_NAME" arkindex shell <<'PYEOF' 2>&1 | tail -2 +import os +from django.contrib.auth import get_user_model +U = get_user_model() +email, pw, name = os.environ["E"], os.environ["P"], os.environ["N"] +u = U.objects.filter(email=email).first() or U.objects.create_superuser(email=email, display_name=name, password=pw) +u.set_password(pw); u.is_admin = True; u.verified_email = True +for f in ["can_upload_s3_image","can_create_iiif_image","can_ingest","can_manage_workers","can_create_worker_version","can_validate_images"]: + setattr(u, f, True) +u.save() +print("admin OK:", u.email, "is_admin =", u.is_admin) +PYEOF +ok "Admin prêt." +echo "########################################" +echo " Email : $ADMIN_EMAIL" +echo " Password : $PASS" +echo " (à changer après la 1ère connexion)" +echo "########################################" diff --git a/scripts/50-verify.sh b/scripts/50-verify.sh new file mode 100755 index 0000000..a725f74 --- /dev/null +++ b/scripts/50-verify.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Vérifications end-to-end : HTTPS/API, S3 (hairpin), pipeline IIIF, état conteneurs, RAM. +# Usage: [ADMIN_PASS=... ] scripts/50-verify.sh +source "$(dirname "$0")/lib.sh" +load_secrets + +echo "=== 1) HTTPS public + certificat ===" +curl -sS -o /dev/null -w " health -> HTTP %{http_code} (tls_verify=%{ssl_verify_result})\n" \ + "https://${DOMAIN}/api/v1/health/" + +if [ -n "${ADMIN_PASS:-}" ]; then + echo "=== 2) Login admin ===" + curl -sS -X POST "https://${DOMAIN}/api/v1/user/login/" -H 'Content-Type: application/json' \ + -d "{\"email\":\"${ADMIN_EMAIL}\",\"password\":\"${ADMIN_PASS}\"}" \ + -o /dev/null -w " login -> HTTP %{http_code}\n" +fi + +echo "=== 3) S3 hairpin (backend write/read/delete via https://s3.${DOMAIN}) ===" +dexi ark-backend arkindex shell <<'PY' 2>&1 | tail -2 +from arkindex.project.aws import s3 +o=s3.Bucket("staging").Object("healthcheck.txt") +o.put(Body=b"ok"); print(" S3 read:", o.get()["Body"].read()); o.delete(); print(" S3 delete: ok") +PY + +echo "=== 4) Pipeline IIIF (upload image de test -> Cantaloupe) ===" +TMP=$(mktemp -d) +docker run --rm -v "$TMP":/out alpine sh -c "apk add -q imagemagick imagemagick-jpeg && magick -size 400x300 xc:'#1e78c8' /out/p.jpg" >/dev/null 2>&1 +docker run --rm --network ark -v "$TMP/p.jpg":/img.jpg:ro \ + -e AWS_ACCESS_KEY_ID="$GK_ID" -e AWS_SECRET_ACCESS_KEY="$GK_SECRET" \ + amazon/aws-cli --endpoint-url http://ark-garage:3900 --region local \ + s3 cp /img.jpg s3://uploads/_verify/p.jpg >/dev/null 2>&1 +curl -sS -o /dev/null -w " IIIF info.json -> HTTP %{http_code}\n" \ + "https://uploads.iiif.${DOMAIN}/iiif/2/_verify%2Fp.jpg/info.json" +curl -sS -o /dev/null -w " IIIF render -> HTTP %{http_code}\n" \ + "https://uploads.iiif.${DOMAIN}/iiif/2/_verify%2Fp.jpg/full/200,/0/default.jpg" +docker run --rm --network ark -e AWS_ACCESS_KEY_ID="$GK_ID" -e AWS_SECRET_ACCESS_KEY="$GK_SECRET" \ + amazon/aws-cli --endpoint-url http://ark-garage:3900 --region local s3 rm s3://uploads/_verify/p.jpg >/dev/null 2>&1 +rm -rf "$TMP" + +echo "=== 5) Conteneurs ===" +docker ps --filter "name=ark-" --format ' {{.Names}} {{.Status}}' | sort + +echo "=== 6) RAM ===" +free -h | awk 'NR==2{printf " used=%s free=%s dispo=%s\n",$3,$4,$7}' diff --git a/scripts/90-coolify-adopt.sh b/scripts/90-coolify-adopt.sh new file mode 100755 index 0000000..5095f92 --- /dev/null +++ b/scripts/90-coolify-adopt.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Adopte la stack comme ressource "Docker Compose" gérée par Coolify (via l'API). +# Prérequis : token API Coolify dans $COOLIFY_TOKEN_FILE (Settings -> API Tokens, read/write). +# +# ⚠️ Coolify remappe les volumes nommés en _* et IGNORE `external:` -> la stack repart sur +# des volumes VIDES. Sur un déploiement neuf : ré-init ensuite (20/30/40). Sur une stack avec +# données : préférer des bind-mounts absolus (host dirs) plutôt que des volumes nommés. +source "$(dirname "$0")/lib.sh" +load_secrets +[ -f "$COOLIFY_TOKEN_FILE" ] || { echo "❌ Token Coolify absent ($COOLIFY_TOKEN_FILE)"; exit 1; } + +COMPOSE_FILE="$ROOT/docker-compose.coolify.yml" + +log "1) Projet 'arkindex' (si absent)" +if [ -z "${COOLIFY_PROJECT_UUID:-}" ]; then + COOLIFY_PROJECT_UUID=$(capi POST /projects -d '{"name":"arkindex","description":"Arkindex full stack"}' \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["uuid"])') + echo " project_uuid=$COOLIFY_PROJECT_UUID (à reporter dans deploy.conf)" +fi + +log "2) Création du service (docker_compose_raw en BASE64)" +BODY=$(python3 - "$COMPOSE_FILE" </dev/null) +[ -n "$SVC" ] || { echo "❌ Échec création service: $RESP"; exit 1; } +echo " service_uuid=$SVC (à reporter dans deploy.conf : COOLIFY_SERVICE_UUID + CONTAINER_SUFFIX=-$SVC)" + +log "3) Variables d'env (secrets) sur le service" +for K in PG_PASS GK_ID GK_SECRET; do + V="${!K}" + capi PATCH "/services/$SVC/envs" -d "{\"key\":\"$K\",\"value\":\"$V\"}" >/dev/null \ + || capi POST "/services/$SVC/envs" -d "{\"key\":\"$K\",\"value\":\"$V\"}" >/dev/null +done +ok "Env posées." + +warn "Étape suivante MANUELLE (coupure) :" +echo " cd $ROOT && docker compose down # arrête la stack CLI (garde les volumes)" +echo " # UNE SEULE fois :" +echo " curl -s -X POST -H \"Authorization: Bearer \$(cat $COOLIFY_TOKEN_FILE)\" \\" +echo " \"$COOLIFY_API/deploy?uuid=$SVC\"" +echo " # puis ré-init sur les volumes neufs :" +echo " CONTAINER_SUFFIX=-$SVC scripts/20-garage-init.sh" +echo " CONTAINER_SUFFIX=-$SVC scripts/30-arkindex-init.sh" +echo " CONTAINER_SUFFIX=-$SVC scripts/40-create-admin.sh" diff --git a/scripts/99-backup-db.sh b/scripts/99-backup-db.sh new file mode 100755 index 0000000..9a30def --- /dev/null +++ b/scripts/99-backup-db.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Sauvegarde PostgreSQL (pg_dump) + liste des buckets Garage. Sortie dans backups/. +source "$(dirname "$0")/lib.sh" +mkdir -p "$ROOT/backups" +TS=$(date +%Y%m%d-%H%M%S) +OUT="$ROOT/backups/arkindex-db-$TS.sql.gz" + +log "Dump PostgreSQL -> $OUT" +dex ark-database pg_dump -U arkindex arkindex | gzip > "$OUT" +ok "Dump DB : $(du -h "$OUT" | cut -f1)" + +log "Inventaire Garage" +dex ark-garage /garage bucket list 2>/dev/null | tee "$ROOT/backups/garage-buckets-$TS.txt" +ok "Backup terminé dans backups/" diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..e03e77c --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Fonctions et variables partagées par les scripts de déploiement. +# Usage : source "$(dirname "$0")/lib.sh" +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Config non-secrète +[ -f "$ROOT/deploy.conf" ] && . "$ROOT/deploy.conf" + +# Secrets (optionnels selon le script) +load_secrets() { + if [ ! -f "$ROOT/.secrets.env" ]; then + echo "❌ $ROOT/.secrets.env manquant. Lance d'abord scripts/00-gen-secrets.sh" >&2 + exit 1 + fi + set -a; . "$ROOT/.secrets.env"; set +a +} + +# Nom réel d'un conteneur (avec suffixe Coolify si défini) +# ex: cname ark-backend -> ark-backend-tdquwauzxsq0985fk9kjqqys (mode Coolify) +# -> ark-backend (mode CLI) +cname() { echo "$1${CONTAINER_SUFFIX:-}"; } + +# docker exec sur un service Arkindex +dex() { local svc="$1"; shift; docker exec "$(cname "$svc")" "$@"; } +dexi() { local svc="$1"; shift; docker exec -i "$(cname "$svc")" "$@"; } + +log() { printf '\033[1;34m▶ %s\033[0m\n' "$*"; } +ok() { printf '\033[1;32m✅ %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33m⚠ %s\033[0m\n' "$*"; } + +coolify_token() { cat "${COOLIFY_TOKEN_FILE}"; } +capi() { # capi METHOD PATH [curl-args...] + local m="$1" p="$2"; shift 2 + curl -s -X "$m" -H "Authorization: Bearer $(coolify_token)" \ + -H "Content-Type: application/json" -H "Accept: application/json" \ + "$@" "${COOLIFY_API}${p}" +} diff --git a/templates/cantaloupe.properties.tmpl b/templates/cantaloupe.properties.tmpl new file mode 100644 index 0000000..48a86b6 --- /dev/null +++ b/templates/cantaloupe.properties.tmpl @@ -0,0 +1,39 @@ +# Template config Cantaloupe (serveur IIIF). Rendu par scripts/01-render-config.sh (envsubst). +# Variable attendue : DOMAIN. +# - Cantaloupe lit les images dans Garage EN INTERNE (http://ark-garage:3900, sans TLS). +# - Le bucket est déduit du sous-domaine de la requête via ScriptLookupStrategy + top_domain +# (ex: uploads.iiif. -> bucket "uploads"). Le script delegate est fourni par l'image. +# - Les identifiants S3 viennent des variables d'env AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY. + +http.enabled = true +http.host = 0.0.0.0 +http.port = 80 +http.http2.enabled = false +https.enabled = false + +endpoint.iiif.1.enabled = false +endpoint.iiif.2.enabled = true + +source.static = S3Source +S3Source.endpoint = http://ark-garage:3900 +S3Source.region = local +S3Source.top_domain = iiif.${DOMAIN} +S3Source.lookup_strategy = ScriptLookupStrategy +S3Source.BasicLookupStrategy.bucket.name = + +cache.server.derivative.enabled = true +cache.server.derivative = S3Cache +cache.server.derivative.ttl_seconds = 604800 +S3Cache.endpoint = http://ark-garage:3900 +S3Cache.region = local +S3Cache.bucket.name = iiif-cache + +log.application.level = info +log.application.ConsoleAppender.enabled = true +log_error_responses = true + +HeapCache.target_size = 512M +HeapCache.persist = false + +processor.downscale_filter = lanczos3 +processor.upscale_filter = lanczos3 diff --git a/templates/config.yml.tmpl b/templates/config.yml.tmpl new file mode 100644 index 0000000..fd69471 --- /dev/null +++ b/templates/config.yml.tmpl @@ -0,0 +1,74 @@ +--- +# Template de la configuration Arkindex. Rendu par scripts/01-render-config.sh (envsubst). +# Variables attendues : DOMAIN, SECRET_KEY, PG_PASS, GK_ID, GK_SECRET. +# IMPORTANT : les services internes sont référencés par leur NOM DE CONTENEUR UNIQUE +# (ark-database, ark-redis, ark-solr) et jamais par db/redis/solr — sinon collision DNS +# avec coolify-redis/coolify-db sur le réseau partagé "coolify". + +arkindex_env: production +public_hostname: https://${DOMAIN} +local_imageserver_id: 12345 +secret_key: "${SECRET_KEY}" + +cache: + type: redis + url: redis://ark-redis:6379/1 + +database: + host: ark-database + port: 5432 + name: arkindex + user: arkindex + password: "${PG_PASS}" + +redis: + host: ark-redis + port: 6379 + +s3: + access_key_id: "${GK_ID}" + secret_access_key: "${GK_SECRET}" + endpoint: https://s3.${DOMAIN} + region: local + +allowed_hosts: + - ${DOMAIN} + +session: + cookie_domain: ${DOMAIN} + cookie_secure: true + +csrf: + cookie_domain: ${DOMAIN} + cookie_secure: true + trusted_origins: + - 'https://${DOMAIN}' + +cors: + origin_whitelist: + - https://${DOMAIN} + +features: + signup: no + search: yes + +solr: + api_url: http://ark-solr:8983/solr/ + +static: + cdn_assets_url: https://assets.teklia.com/arkindex + frontend_version: 1.12.2 + +ponos: + default_env: + ARKINDEX_API_URL: https://${DOMAIN}/api/v1/ + +ingest: + access_key_id: "${GK_ID}" + secret_access_key: "${GK_SECRET}" + endpoint: https://s3.${DOMAIN} + region: local + imageserver_id: 67890 + extra_buckets: + - ingest + prefix_by_bucket_name: false diff --git a/templates/garage.toml.tmpl b/templates/garage.toml.tmpl new file mode 100644 index 0000000..a300f0b --- /dev/null +++ b/templates/garage.toml.tmpl @@ -0,0 +1,23 @@ +# Template de la config Garage (S3). Rendu par scripts/01-render-config.sh (envsubst). +# Variables attendues : DOMAIN, GARAGE_RPC, GARAGE_ADMIN_TOKEN. +# root_domain permet l'adressage "virtual-hosted" .s3. (imposé par boto3 +# addressing_style=auto côté backend). + +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "sqlite" + +replication_factor = 1 + +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "127.0.0.1:3901" +rpc_secret = "${GARAGE_RPC}" + +[s3_api] +s3_region = "local" +api_bind_addr = "[::]:3900" +root_domain = ".s3.${DOMAIN}" + +[admin] +api_bind_addr = "[::]:3903" +admin_token = "${GARAGE_ADMIN_TOKEN}"