diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index e6b8ac8..1d1a04c 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -85,3 +85,57 @@ jobs:
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
+
+ # Job separe pour le sidecar `switcher`.
+ # Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
+ # (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
+ # `loremind-switcher`, quel que soit le canal de l'instance. On le build
+ # donc uniquement sur les releases stables — pas la peine de re-publier
+ # une variante beta du switcher, c'est une infrastructure neutre.
+ build-switcher:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Detect channel
+ id: meta
+ run: |
+ VERSION="${GITHUB_REF_NAME#v}"
+ echo "version=${VERSION}" >> $GITHUB_OUTPUT
+ if [[ "${VERSION}" == *-beta* ]]; then
+ echo "channel=beta" >> $GITHUB_OUTPUT
+ else
+ echo "channel=stable" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Login to Gitea Registry
+ if: steps.meta.outputs.channel == 'stable'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.GITEA_REGISTRY }}
+ username: ${{ env.GITEA_REGISTRY_USER }}
+ password: ${{ secrets.DOCKER_PAT }}
+
+ - name: Login to GHCR
+ if: steps.meta.outputs.channel == 'stable'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.GHCR_REGISTRY }}
+ username: ${{ env.GHCR_NAMESPACE }}
+ password: ${{ secrets.GHCR_TOKEN }}
+
+ - name: Build & push switcher (stable only)
+ if: steps.meta.outputs.channel == 'stable'
+ uses: docker/build-push-action@v5
+ with:
+ context: ./switcher
+ push: true
+ tags: |
+ ${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
+ ${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
+ ${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
+ ${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}
diff --git a/brain/app/main.py b/brain/app/main.py
index f73ed79..28c55d7 100644
--- a/brain/app/main.py
+++ b/brain/app/main.py
@@ -41,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
- version="0.8.4-beta",
+ version="0.8.5",
)
diff --git a/core/pom.xml b/core/pom.xml
index 9402941..5a29ce5 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -14,7 +14,7 @@
Le sidecar tourne en permanence et watch un fichier {@code command.json} + * dans un volume partage. Quand on depose une commande, il : + *
Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar + * via fichiers, ce qui evite que la compromission du Core ne donne RCE + * sur l'hote. Le sidecar valide strictement le contenu de la commande + * (channel ∈ {stable, beta} uniquement). + * + *
Le canal actuel se deduit du prefixe d'image courant (recupere via
+ * la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
+ * presence de "loremind-beta-" => canal beta, sinon stable.
+ */
+@Service
+public class ChannelSwitcherService {
+
+ private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
+
+ public enum Channel { STABLE, BETA }
+
+ public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
+
+ /** Snapshot du dernier resultat de switch ecrit par le sidecar. */
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public record SwitchResult(
+ String id,
+ SwitchStatus status,
+ Channel channel,
+ String message,
+ Instant completedAt) {}
+
+ private final Path switcherDataPath;
+ private final String imageNamespace;
+ private final ObjectMapper json = new ObjectMapper();
+
+ public ChannelSwitcherService(
+ @Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
+ // On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
+ // (la deuxieme est toujours injectee par compose, contrairement a la premiere
+ // qui peut etre absente dans les .env legacy).
+ @Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
+ this.switcherDataPath = Path.of(switcherDataPath);
+ this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
+ log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
+ switcherDataPath, this.imageNamespace);
+ }
+
+ /**
+ * Detection du canal courant a partir du prefixe d'image charge au demarrage.
+ * Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
+ */
+ public Channel getCurrentChannel() {
+ return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
+ }
+
+ /**
+ * Indique si le sidecar est disponible (volume partage accessible).
+ * Si non, on degrade en lecture seule (l'UI affichera l'ancien message
+ * avec instructions manuelles).
+ */
+ public boolean isSwitcherAvailable() {
+ return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
+ }
+
+ /**
+ * Depose une commande de switch dans le volume partage. Renvoie l'ID
+ * de la commande, que le client peut utiliser pour poller le status.
+ *
+ * @throws IllegalStateException si le sidecar n'est pas disponible
+ * @throws IOException si l'ecriture du fichier echoue
+ */
+ public String requestSwitch(Channel target) throws IOException {
+ if (!isSwitcherAvailable()) {
+ throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
+ }
+ String id = UUID.randomUUID().toString();
+ Map {@code currentChannel} : detecte au demarrage de Core a partir du prefixe
+ * d'image. {@code switcherAvailable} : indique si le sidecar de switch est
+ * monte (V0.9+) ou si on est sur une vieille install qui doit encore passer
+ * par les instructions manuelles.
+ *
+ * {@code lastSwitch} : null tant qu'aucun switch n'a ete tente sur cette
+ * instance. Sinon, contient le resultat du dernier appel (en cours / succes /
+ * erreur), utilise par l'UI pour suivre la progression apres clic.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record ChannelStatusDTO(
+ String currentChannel,
+ boolean switcherAvailable,
+ ChannelSwitcherService.SwitchResult lastSwitch) {
+
+ public static ChannelStatusDTO from(ChannelSwitcherService service) {
+ return new ChannelStatusDTO(
+ service.getCurrentChannel().name().toLowerCase(),
+ service.isSwitcherAvailable(),
+ service.getLastResult());
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index bade05b..72689a2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -102,11 +102,18 @@ services:
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
# Chemin du docker config.json partage avec Watchtower
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
+ # Chemin du repertoire partage avec le switcher (commande + resultat).
+ # Doit matcher le volume `switcher-data` monte ci-dessous.
+ SWITCHER_DATA_PATH: /shared/switcher
volumes:
# Volume partage avec Watchtower : Core ecrit les credentials registry
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
# privees du canal beta. Pas de creds = no-op.
- docker-config:/shared/docker
+ # Volume partage avec le switcher : Core ecrit une commande de switch
+ # de canal ici (command.json), le switcher la traite et y depose son
+ # resultat (result.json). Cf. service `switcher` ci-dessous.
+ - switcher-data:/shared/switcher
restart: unless-stopped
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
@@ -167,6 +174,44 @@ services:
- "${WEB_PORT:-8081}:80"
restart: unless-stopped
+ # Sidecar de bascule de canal (stable <-> beta).
+ #
+ # Pourquoi : la bascule entre canaux change le PREFIXE d'image (loremind- vs
+ # loremind-beta-), donc Watchtower seul ne peut pas la faire — il met a jour
+ # des images, pas leur reference. Ce sidecar fait le `sed .env` + le
+ # `docker compose pull/up -d` quand le Core depose une commande JSON.
+ #
+ # Securite : pas de port expose. La commande arrive via volume partage
+ # (`switcher-data`) que SEUL le Core ecrit. Le switcher valide strictement
+ # le contenu (channel ∈ {stable, beta}, rien d'autre) — pas de RCE via
+ # compromission du Core.
+ #
+ # L'image switcher est volontairement HORS de IMAGE_NAMESPACE : elle reste
+ # `igmlcreation/loremind-switcher` sur les deux canaux. Sinon le switcher
+ # se tuerait lui-meme pendant le `docker compose up -d` (race condition).
+ switcher:
+ image: ghcr.io/igmlcreation/loremind-switcher:${SWITCHER_TAG:-latest}
+ container_name: loremind-switcher
+ # PAS de label watchtower : la maj du switcher se fait via le canal
+ # stable uniquement, et hors du flow d'auto-update.
+ volumes:
+ # Socket Docker du host : permet de lancer docker compose pull/up.
+ - /var/run/docker.sock:/var/run/docker.sock
+ # Repertoire compose du host (docker-compose.yml + .env) — RW pour
+ # pouvoir sed la ligne IMAGE_NAMESPACE.
+ - ${COMPOSE_PROJECT_DIR:-./}:/compose
+ # Volume partage avec le Core pour la commande + le resultat.
+ - switcher-data:/data
+ environment:
+ # Repertoire interne ou trouver docker-compose.yml et .env. Bind au
+ # volume ci-dessus (COMPOSE_PROJECT_DIR = repertoire d'install du host).
+ COMPOSE_DIR: /compose
+ # Nom de projet docker compose : fixe ici pour que le switcher cible
+ # le MEME stack que celui qui tourne (sinon il creerait un duplicate).
+ # Doit matcher le `name:` (en V2.x) ou le nom du dossier du host.
+ COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-loremind}
+ restart: unless-stopped
+
# Mises a jour automatiques des images core/brain/web.
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
@@ -214,3 +259,5 @@ volumes:
# Volume partage Core <-> Watchtower : config.json Docker pour
# l'authentification au registry prive GHCR (canal beta Patreon).
docker-config:
+ # Volume partage Core <-> Switcher : commande de bascule de canal + resultat.
+ switcher-data:
diff --git a/switcher/Dockerfile b/switcher/Dockerfile
new file mode 100644
index 0000000..5583653
--- /dev/null
+++ b/switcher/Dockerfile
@@ -0,0 +1,26 @@
+# LoreMind channel switcher — sidecar minimal qui orchestre les bascules
+# stable <-> beta. Tourne en permanence en attente d'une commande deposee
+# dans le volume partage par le Core.
+#
+# Image volontairement legere (Alpine + docker-cli + bash). Pas de port
+# expose, pas de processus reseau : tout passe par fichiers + socket Docker.
+FROM alpine:3.20
+
+# docker-cli : pour parler au socket Docker du host
+# docker-cli-compose : pour `docker compose pull/up`
+# bash : pour les scripts (sh ne suffit pas, on utilise des features bash)
+# jq : parsing JSON de la commande
+# coreutils : pour `date -u --iso-8601=seconds`
+RUN apk add --no-cache \
+ docker-cli \
+ docker-cli-compose \
+ bash \
+ jq \
+ coreutils
+
+WORKDIR /switcher
+COPY watch.sh switch.sh ./
+RUN chmod +x watch.sh switch.sh
+
+# Tourne en permanence en mode polling.
+ENTRYPOINT ["/switcher/watch.sh"]
diff --git a/switcher/README.md b/switcher/README.md
new file mode 100644
index 0000000..8bade16
--- /dev/null
+++ b/switcher/README.md
@@ -0,0 +1,66 @@
+# LoreMind channel switcher
+
+Sidecar qui bascule LoreMind entre les canaux **stable** et **beta** depuis l'UI,
+sans manipulation manuelle du `.env` ni de docker-compose.
+
+## Principe
+
+Le switcher est un container minimal (Alpine + docker-cli + bash) qui :
+
+1. Watch un fichier `command.json` dans un volume partagé avec le Core
+2. Quand une commande arrive :
+ - Valide le canal cible (`stable` | `beta`)
+ - Sed la ligne `IMAGE_NAMESPACE` du `.env` du host
+ - Lance `docker compose pull` puis `docker compose up -d` sur core/brain/web
+3. Écrit son résultat dans `result.json` (le Core remonte ça à l'UI via polling)
+
+## Sécurité
+
+Le switcher a accès au socket Docker et au répertoire compose du host (RW),
+donc beaucoup de pouvoir. Pour éviter qu'une compromission du Core devienne
+un RCE sur l'hôte :
+
+- Le Core n'a **pas** accès au socket Docker — il dépose une commande dans un
+ fichier, point.
+- Le switcher **valide strictement** le contenu : `channel` doit valoir exactement
+ `stable` ou `beta` (case statement, pas de regex laxiste).
+- Aucun port n'est exposé. La communication se fait uniquement via volume
+ partagé.
+
+## Architecture
+
+```
+┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌────────────┐
+│ User clique │ │ Core │ │ switcher │ │ Docker │
+│ "Passer beta"│─▶│ écrit command.json│─▶│ sed .env │─▶│ daemon │
+│ dans UI │ │ dans volume │ │ docker compose│ │ (recreate) │
+└──────────────┘ └──────────────────┘ └──────────────┘ └────────────┘
+ │
+ ▼
+ ┌─────────────┐
+ │ result.json │ ◄── Core poll
+ └─────────────┘
+```
+
+## Upgrade pour les installs existantes
+
+Le sidecar est arrivé dans LoreMind 0.9.0. Pour les installs antérieures qui
+ne l'ont pas dans leur `docker-compose.yml`, l'utilisateur doit faire une
+**dernière** manipulation :
+
+1. Récupérer le nouveau `docker-compose.yml` du repo
+2. Lancer `docker compose pull && docker compose up -d`
+
+Après ça, tous les switchs futurs se font depuis l'UI sans intervention CLI.
+
+## Pourquoi le switcher n'est PAS dans `IMAGE_NAMESPACE`
+
+L'image du switcher est codée en dur (`ghcr.io/igmlcreation/loremind-switcher`)
+plutôt que d'utiliser `${IMAGE_NAMESPACE}`. Raison : pendant un switch, le
+switcher exécute `docker compose up -d`. Si son propre image faisait partie
+de `IMAGE_NAMESPACE`, le compose voudrait le recréer en même temps que
+core/brain/web — et il se tuerait au milieu de sa propre commande. Race
+condition fatale.
+
+Pour la même raison, le `docker compose up -d` dans `switch.sh` cible
+explicitement `core brain web --no-deps` — jamais le switcher lui-même.
diff --git a/switcher/switch.sh b/switcher/switch.sh
new file mode 100644
index 0000000..392c24b
--- /dev/null
+++ b/switcher/switch.sh
@@ -0,0 +1,112 @@
+#!/bin/bash
+# switch.sh — execute le switch de canal pour LoreMind.
+#
+# Usage interne (appele par watch.sh) :
+# ./switch.sh stable
+# ./switch.sh beta
+#
+# Ce que ca fait, dans l'ordre :
+# 1. Valide l'argument (stable|beta uniquement, rien d'autre — defense
+# contre command injection si le Core etait compromis)
+# 2. Sed la ligne IMAGE_NAMESPACE= du .env du host pour basculer le prefixe
+# 3. docker compose pull (recupere les nouvelles images du canal cible)
+# 4. docker compose up -d (recree core/brain/web avec les nouvelles images)
+#
+# Le switcher LUI-MEME n'est PAS dans IMAGE_NAMESPACE — il survit au switch
+# sans interruption (cf. docker-compose.yml).
+
+set -euo pipefail
+
+CHANNEL="${1:-}"
+
+# --- Validation stricte -----------------------------------------------------
+# Aucune autre valeur acceptee. Pas d'echappement, pas de slash, rien.
+# C'est le filet de securite si le JSON depose dans /data/command.json
+# contenait un payload exotique (Core compromis = on ne laisse PAS
+# executer du code arbitraire sur l'hote).
+case "${CHANNEL}" in
+ stable|beta) ;;
+ *)
+ echo "Channel invalide: '${CHANNEL}' (attendu: stable|beta)" >&2
+ exit 2
+ ;;
+esac
+
+# --- Configuration ---------------------------------------------------------
+# Repertoire monte depuis l'hote contenant docker-compose.yml + .env
+COMPOSE_DIR="${COMPOSE_DIR:-/compose}"
+ENV_FILE="${COMPOSE_DIR}/.env"
+
+if [[ ! -f "${ENV_FILE}" ]]; then
+ echo "Fichier .env introuvable dans ${COMPOSE_DIR}" >&2
+ exit 3
+fi
+if [[ ! -f "${COMPOSE_DIR}/docker-compose.yml" ]]; then
+ echo "docker-compose.yml introuvable dans ${COMPOSE_DIR}" >&2
+ exit 3
+fi
+
+# --- Detection du nom de projet compose ------------------------------------
+# Pour eviter que le switcher cree un projet PARALLELE (cas ou COMPOSE_PROJECT_NAME
+# ne correspond pas au nom du projet sous lequel les containers tournent),
+# on lit le label compose du container core en cours d'execution.
+# Ce label est ecrit par docker compose au moment du `up -d` initial — c'est
+# la source de verite.
+PROJECT_NAME=$(docker inspect loremind-core \
+ --format '{{ index .Config.Labels "com.docker.compose.project" }}' \
+ 2>/dev/null || echo "")
+if [[ -z "${PROJECT_NAME}" ]]; then
+ # Fallback : env var ou defaut. Ne devrait pas arriver en prod
+ # (loremind-core tourne forcement quand l'UI declenche un switch).
+ PROJECT_NAME="${COMPOSE_PROJECT_NAME:-loremind}"
+ echo "Warning: nom de projet auto-detecte impossible, fallback sur '${PROJECT_NAME}'" >&2
+fi
+export COMPOSE_PROJECT_NAME="${PROJECT_NAME}"
+echo "→ Projet compose cible: ${PROJECT_NAME}"
+
+# --- Mapping canal -> namespace --------------------------------------------
+# Le slash final est important : il est concatene avec le suffixe image
+# (core/brain/web) dans le docker-compose.yml.
+case "${CHANNEL}" in
+ stable) NAMESPACE="igmlcreation/loremind-" ;;
+ beta) NAMESPACE="igmlcreation/loremind-beta-" ;;
+esac
+
+# --- Etape 1 : sed le .env -------------------------------------------------
+# On veut REMPLACER une ligne existante IMAGE_NAMESPACE=... ou AJOUTER
+# si absente. Cas typique : .env utilisateur peut avoir cette ligne ou non.
+#
+# Sed -i avec un pattern qui matche la ligne entiere. Si pas de match,
+# on append.
+echo "→ Mise a jour de IMAGE_NAMESPACE dans .env (canal: ${CHANNEL})"
+if grep -q '^IMAGE_NAMESPACE=' "${ENV_FILE}"; then
+ # Sur Alpine, sed -i sans backup. Le pattern d'echappement '/' dans
+ # le namespace impose un delimiter alternatif (|).
+ sed -i "s|^IMAGE_NAMESPACE=.*|IMAGE_NAMESPACE=${NAMESPACE}|" "${ENV_FILE}"
+else
+ # Ligne absente → on l'ajoute en fin de fichier avec un commentaire.
+ {
+ echo ""
+ echo "# Ajoute automatiquement par le switcher de canal LoreMind."
+ echo "IMAGE_NAMESPACE=${NAMESPACE}"
+ } >> "${ENV_FILE}"
+fi
+
+# --- Etape 2 : docker compose pull -----------------------------------------
+echo "→ Pull des nouvelles images (${NAMESPACE}*)"
+# --no-deps inutile ici : pull n'a pas de notion de deps.
+# --policy missing eviterait de re-puller si deja la, mais on VEUT puller
+# pour avoir la derniere version disponible — c'est le but du switch.
+cd "${COMPOSE_DIR}"
+docker compose pull core brain web
+
+# --- Etape 3 : recreate les containers avec les nouvelles images -----------
+# On cible explicitement core/brain/web — pas le switcher (qui s'auto-tuerait
+# au milieu de la commande), pas postgres/minio (pas de changement d'image).
+# --no-deps : ne pas re-recreer postgres/minio comme effet de bord.
+echo "→ Recreation des containers avec les nouvelles images"
+docker compose up -d --no-deps core brain web
+
+echo ""
+echo "Switch vers le canal ${CHANNEL} termine avec succes."
+echo "Containers core/brain/web recrees avec ${NAMESPACE}*."
diff --git a/switcher/watch.sh b/switcher/watch.sh
new file mode 100644
index 0000000..0d4f3cb
--- /dev/null
+++ b/switcher/watch.sh
@@ -0,0 +1,84 @@
+#!/bin/bash
+# watch.sh — boucle principale du switcher.
+#
+# Surveille /data/command.json (depose par le Core via l'API HTTP) et lance
+# switch.sh quand une nouvelle commande arrive. L'ID de la commande sert
+# d'idempotence : on ne traite pas deux fois la meme requete.
+#
+# Le resultat est ecrit dans /data/result.json pour que le Core puisse le
+# remonter a l'UI via son endpoint de status.
+
+set -euo pipefail
+
+DATA_DIR="/data"
+COMMAND_FILE="${DATA_DIR}/command.json"
+RESULT_FILE="${DATA_DIR}/result.json"
+LAST_PROCESSED_FILE="${DATA_DIR}/.last-processed-id"
+
+mkdir -p "${DATA_DIR}"
+
+log() {
+ echo "[$(date -u --iso-8601=seconds)] $*"
+}
+
+# Ecrit un resultat JSON dans result.json — atomique via tmp + mv.
+write_result() {
+ local status="$1" # "in-progress" | "success" | "error"
+ local channel="$2" # "stable" | "beta" | ""
+ local message="$3"
+ local id="$4"
+
+ local tmp
+ tmp="$(mktemp -p "${DATA_DIR}" result.XXXXXX)"
+ cat > "${tmp}" <.env :
- IMAGE_NAMESPACE=igmlcreation/loremind-beta- puis
- docker compose pull && docker compose up -d.
- docker-compose.yml du repo
+ et fais docker compose pull && docker compose up -d une
+ fois. Sinon, bascule manuellement en editant IMAGE_NAMESPACE
+ dans ton .env (igmlcreation/loremind- pour stable,
+ igmlcreation/loremind-beta- pour beta).
+