Repo complet : scripts, templates, docs et durcissement secrets

- Ajout scripts/ reproduisant chaque étape (secrets, render config, up CLI,
  init Garage, bootstrap prod, admin, vérif E2E, adoption Coolify, backup)
- Templates envsubst (config.yml, garage.toml, cantaloupe.properties) + .example
- deploy.conf (config non-secrète : domaine, versions, uuids Coolify)
- docs/ : ARCHITECTURE, TROUBLESHOOTING (11 problèmes/fixes), RUNBOOK, SECURITY
- Makefile (raccourcis)
- .gitignore + retrait du suivi de .env/.secrets.env/config.yml/garage.toml
  (fichiers conservés sur disque ; secrets restent dans l'historique -> voir SECURITY.md)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nak0x 2026-07-09 16:24:41 +02:00
parent 0e08632bce
commit 18064be674
22 changed files with 871 additions and 11 deletions

5
.env.example Normal file
View File

@ -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

13
.gitignore vendored Normal file
View File

@ -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/

15
.secrets.env.example Normal file
View File

@ -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

44
Makefile Normal file
View File

@ -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

View File

@ -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 `<uuid>_*` 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-<uuid> …`). 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 `<service>-<uuid>`.
- **Ne déclencher le deploy qu'une fois** (POST *ou* GET, pas les deux) — sinon double déploiement.
---

40
deploy.conf Normal file
View File

@ -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.<DOMAIN>, iiif.<DOMAIN>, uploads.iiif.<DOMAIN>,
# ingest.iiif.<DOMAIN> et <bucket>.s3.<DOMAIN> 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 (= <service>-<COOLIFY_SERVICE_UUID>).
# Vide = mode CLI (conteneurs ark-* sans suffixe).
CONTAINER_SUFFIX="-tdquwauzxsq0985fk9kjqqys"

76
docs/ARCHITECTURE.md Normal file
View File

@ -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.<DOMAIN> ───┘ :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 (`<uuid>`) 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** (`<bucket>.s3.<DOMAIN>`, `<x>.iiif.<DOMAIN>`). 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.<DOMAIN>` — front (catch-all, priorité 1) + backend (`/api`,`/api-docs`,`/admin`,`/rq`,`/static`, priorité 100)
- `s3.<DOMAIN>` + `<bucket>.s3.<DOMAIN>` × 9 buckets — Garage
- `iiif.<DOMAIN>`, `uploads.iiif.<DOMAIN>`, `ingest.iiif.<DOMAIN>` — Cantaloupe
## Stockage S3 (Garage)
- Le backend utilise boto3 avec `addressing_style="auto"` (codé en dur) → **virtual-hosted**
(`<bucket>.s3.<DOMAIN>`). 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.<DOMAIN>` (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.<DOMAIN>` → le bucket
est déduit du sous-domaine (`uploads.iiif.<DOMAIN>` → bucket `uploads`).
- 2 `ImageServer` en base : `12345` (uploads) et `67890` (ingest), URLs `https://…iiif.<DOMAIN>/iiif/2`.
## Recherche (Solr)
- SolrCloud (`solr -f -cloud`). Les collections sont créées **par corpus** (`project-<uuid>`) à 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`).

76
docs/RUNBOOK.md Normal file
View File

@ -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-<suffix> # logs backend
docker logs -f ark-worker-<suffix> # 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-<suffix> arkindex migrate
```
## Shell Django / admin
```bash
docker exec -it ark-backend-<suffix> arkindex shell
docker exec -it ark-backend-<suffix> 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-<suffix> arkindex reindex --corpus-id <UUID> --drop
# tout réindexer
docker exec ark-backend-<suffix> arkindex reindex --all
```
## Stockage S3 (Garage)
```bash
G=ark-garage-<suffix>
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 <uuid>_* (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`.

97
docs/TROUBLESHOOTING.md Normal file
View File

@ -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-<suffix> getent hosts redis # IP de coolify-redis (mauvais)
docker exec ark-worker-<suffix> 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) → `<bucket>.s3.<DOMAIN>`.
**Fix** : énumérer les 9 sous-domaines de bucket + `root_domain` dans `garage.toml`.
**Test** :
```bash
docker exec -i ark-backend-<suffix> 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 `<uuid>_*` et **ignore `external:`** → volumes vides.
2. Coolify écrase `container_name` en `<service>-<uuid>` (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-<suffix>
# résolution DNS depuis un conteneur
docker exec ark-worker-<suffix> getent hosts ark-redis
# état santé
docker inspect ark-backend-<suffix> --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
```

20
scripts/00-gen-secrets.sh Executable file
View File

@ -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" <<EOF
PG_PASS=$(openssl rand -hex 16)
SECRET_KEY=$(openssl rand -base64 48 | tr -d '\n/+=' | head -c 50)
GARAGE_RPC=$(openssl rand -hex 32)
GARAGE_ADMIN_TOKEN=$(openssl rand -hex 24)
GK_ID=GK$(openssl rand -hex 12)
GK_SECRET=$(openssl rand -hex 32)
EOF
ok ".secrets.env généré (mode 600)."

27
scripts/01-render-config.sh Executable file
View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Rend config.yml, garage.toml, cantaloupe.properties et .env à partir des templates + secrets.
source "$(dirname "$0")/lib.sh"
load_secrets
command -v envsubst >/dev/null || { echo "❌ 'envsubst' requis (paquet gettext)"; exit 1; }
log "Rendu des fichiers de configuration (DOMAIN=$DOMAIN)…"
export DOMAIN SECRET_KEY PG_PASS GK_ID GK_SECRET GARAGE_RPC GARAGE_ADMIN_TOKEN
envsubst '${DOMAIN} ${SECRET_KEY} ${PG_PASS} ${GK_ID} ${GK_SECRET}' \
< "$ROOT/templates/config.yml.tmpl" > "$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" <<EOF
PG_PASS=${PG_PASS}
GK_ID=${GK_ID}
GK_SECRET=${GK_SECRET}
EOF
# Le backend tourne en uid 2000 : les fichiers montés doivent être lisibles (644).
chmod 644 "$ROOT/config.yml" "$ROOT/garage.toml" "$ROOT/cantaloupe.properties"
chmod 600 "$ROOT/.env"
ok "config.yml, garage.toml, cantaloupe.properties, .env générés."

33
scripts/10-up-cli.sh Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Déploiement complet en mode "docker compose" (CLI), de A à Z.
# Prérequis : Docker + réseau externe "coolify" (proxy Traefik) + DNS *.<DOMAIN> -> 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"

30
scripts/20-garage-init.sh Executable file
View File

@ -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)."

26
scripts/30-arkindex-init.sh Executable file
View File

@ -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é."

25
scripts/40-create-admin.sh Executable file
View File

@ -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 "########################################"

44
scripts/50-verify.sh Executable file
View File

@ -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}'

53
scripts/90-coolify-adopt.sh Executable file
View File

@ -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 <uuid>_* 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" <<PY
import base64,json,sys
raw=open(sys.argv[1]).read()
print(json.dumps({
"server_uuid":"$COOLIFY_SERVER_UUID","project_uuid":"$COOLIFY_PROJECT_UUID",
"environment_name":"$COOLIFY_ENV_NAME","environment_uuid":"$COOLIFY_ENV_UUID",
"name":"arkindex","description":"Arkindex full stack",
"docker_compose_raw":base64.b64encode(raw.encode()).decode(),"instant_deploy":False}))
PY
)
RESP=$(capi POST /services -d "$BODY")
SVC=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("uuid",""))' 2>/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"

14
scripts/99-backup-db.sh Executable file
View File

@ -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/"

39
scripts/lib.sh Executable file
View File

@ -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}"
}

View File

@ -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.<DOMAIN> -> 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

74
templates/config.yml.tmpl Normal file
View File

@ -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

View File

@ -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" <bucket>.s3.<DOMAIN> (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}"