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 @@ com.loremind loremind-core - 0.8.4-beta + 0.8.5 LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/application/licensing/ChannelSwitcherService.java b/core/src/main/java/com/loremind/application/licensing/ChannelSwitcherService.java new file mode 100644 index 0000000..d23c14e --- /dev/null +++ b/core/src/main/java/com/loremind/application/licensing/ChannelSwitcherService.java @@ -0,0 +1,135 @@ +package com.loremind.application.licensing; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`. + * + *

Le sidecar tourne en permanence et watch un fichier {@code command.json} + * dans un volume partage. Quand on depose une commande, il : + *

    + *
  1. Sed la ligne IMAGE_NAMESPACE du .env
  2. + *
  3. Lance docker compose pull + up -d
  4. + *
  5. Ecrit son resultat dans {@code result.json}
  6. + *
+ * + *

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 command = new LinkedHashMap<>(); + command.put("id", id); + command.put("channel", target.name().toLowerCase()); + command.put("requestedAt", Instant.now().toString()); + + Path commandFile = switcherDataPath.resolve("command.json"); + Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp"); + try { + json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command); + // Atomic move : evite que le sidecar lise un fichier partiellement ecrit. + Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } finally { + // Cleanup au cas ou move aurait echoue avant le rename. + Files.deleteIfExists(tmp); + } + log.info("Switch command written: id={} channel={}", id, target); + return id; + } + + /** + * Lit le dernier resultat ecrit par le sidecar, s'il existe. + * Renvoie null si aucun switch n'a encore ete tente sur cette instance. + */ + public SwitchResult getLastResult() { + Path resultFile = switcherDataPath.resolve("result.json"); + if (!Files.exists(resultFile)) return null; + try { + return json.readValue(resultFile.toFile(), SwitchResult.class); + } catch (IOException e) { + log.warn("Cannot parse switcher result.json: {}", e.getMessage()); + return null; + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/LicenseController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/LicenseController.java index fc291ca..27b4627 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/LicenseController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/LicenseController.java @@ -1,12 +1,16 @@ package com.loremind.infrastructure.web.controller; +import com.loremind.application.licensing.ChannelSwitcherService; import com.loremind.application.licensing.LicenseService; import com.loremind.application.licensing.LicenseService.InstallException; import com.loremind.domain.licensing.LicenseSnapshot; +import com.loremind.infrastructure.web.dto.licensing.ChannelStatusDTO; import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.io.IOException; +import java.util.Locale; import java.util.Map; /** @@ -26,9 +30,11 @@ import java.util.Map; public class LicenseController { private final LicenseService licenseService; + private final ChannelSwitcherService channelSwitcher; - public LicenseController(LicenseService licenseService) { + public LicenseController(LicenseService licenseService, ChannelSwitcherService channelSwitcher) { this.licenseService = licenseService; + this.channelSwitcher = channelSwitcher; } @GetMapping @@ -82,6 +88,68 @@ public class LicenseController { } } + // ─── Bascule de canal (stable <-> beta) via sidecar switcher ──────────── + // + // Le flux : + // 1. UI POST /api/license/channel/switch { channel: "beta" } + // 2. Core valide la licence (refus si target=beta sans Patreon actif) + // 3. Core depose une commande dans le volume partage + // 4. Sidecar `switcher` la traite (sed .env, docker compose up -d) + // 5. UI poll GET /api/license/channel pour suivre le status + + /** Etat courant : canal actuel + dispo du sidecar + dernier resultat. */ + @GetMapping("/channel") + public ChannelStatusDTO getChannel() { + return ChannelStatusDTO.from(channelSwitcher); + } + + /** Declenche un switch de canal. Renvoie l'ID de la commande pour le polling. */ + @PostMapping("/channel/switch") + public ResponseEntity switchChannel(@RequestBody ChannelSwitchRequest request) { + if (request == null || request.channel() == null) { + return ResponseEntity.badRequest().body(Map.of("error", "missing channel")); + } + + ChannelSwitcherService.Channel target; + try { + target = ChannelSwitcherService.Channel.valueOf(request.channel().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of( + "error", "invalid channel (allowed: stable, beta)")); + } + + // Garde : pas de switch vers beta sans licence Patreon valide. + // Le switcher ferait le boulot quoi qu'il arrive (il valide juste le + // format), donc c'est ici qu'on doit refuser cote metier. + // VALID + GRACE autorisent l'acces beta (cf. javadoc de LicenseStatus). + if (target == ChannelSwitcherService.Channel.BETA) { + LicenseSnapshot snap = licenseService.getCurrentSnapshot(); + com.loremind.domain.licensing.LicenseStatus s = (snap != null) ? snap.status() : null; + boolean allowed = s == com.loremind.domain.licensing.LicenseStatus.VALID + || s == com.loremind.domain.licensing.LicenseStatus.GRACE; + if (!allowed) { + return ResponseEntity.status(403).body(Map.of( + "error", "Aucune licence Patreon active — impossible de basculer sur le canal beta.")); + } + } + + if (!channelSwitcher.isSwitcherAvailable()) { + return ResponseEntity.status(503).body(Map.of( + "error", "Sidecar switcher non disponible (mise a jour requise du docker-compose.yml).")); + } + + try { + String id = channelSwitcher.requestSwitch(target); + return ResponseEntity.accepted().body(Map.of( + "id", id, + "channel", target.name().toLowerCase(Locale.ROOT))); + } catch (IOException e) { + return ResponseEntity.status(500).body(Map.of( + "error", "Impossible d'ecrire la commande de switch: " + e.getMessage())); + } + } + public record InstallRequest(String jwt) {} public record BetaChannelRequest(boolean enabled) {} + public record ChannelSwitchRequest(String channel) {} } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/licensing/ChannelStatusDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/licensing/ChannelStatusDTO.java new file mode 100644 index 0000000..d923a08 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/licensing/ChannelStatusDTO.java @@ -0,0 +1,30 @@ +package com.loremind.infrastructure.web.dto.licensing; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.loremind.application.licensing.ChannelSwitcherService; + +/** + * Etat du canal courant + dernier resultat de switch. + * + *

{@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}" </dev/null); then + sleep 1 + continue + fi + + # Idempotence : skip si on a deja traite cet ID. + last_id="" + [[ -f "${LAST_PROCESSED_FILE}" ]] && last_id=$(cat "${LAST_PROCESSED_FILE}") + if [[ "${id}" == "${last_id}" ]]; then + sleep 1 + continue + fi + + channel=$(jq -er '.channel' "${COMMAND_FILE}" 2>/dev/null || echo "") + + log "New command received: id=${id} channel=${channel}" + write_result "in-progress" "${channel}" "Switch en cours..." "${id}" + + # Lance le switch. On capture stdout+stderr et le code de sortie. + if output=$(/switcher/switch.sh "${channel}" 2>&1); then + log "Switch SUCCESS for id=${id} channel=${channel}" + write_result "success" "${channel}" "${output}" "${id}" + else + rc=$? + log "Switch FAILED for id=${id} channel=${channel} rc=${rc}" + write_result "error" "${channel}" "${output}" "${id}" + fi + + # Marque l'ID comme traite — empeche les replays. + echo "${id}" > "${LAST_PROCESSED_FILE}" + fi + sleep 1 +done diff --git a/web/package-lock.json b/web/package-lock.json index 8bd95db..4bde808 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.8.4-beta", + "version": "0.8.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.8.4-beta", + "version": "0.8.5", "dependencies": { "@angular/animations": "^17.0.0", "@angular/common": "^17.0.0", diff --git a/web/package.json b/web/package.json index 7855672..8473b91 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.8.4-beta", + "version": "0.8.5", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/services/license.service.ts b/web/src/app/services/license.service.ts index f8b95d0..0a184e8 100644 --- a/web/src/app/services/license.service.ts +++ b/web/src/app/services/license.service.ts @@ -19,6 +19,24 @@ export interface LicenseStatusDTO { betaChannelEnabled: boolean; } +/** + * Etat du canal courant + dernier resultat de bascule (cf. ChannelStatusDTO cote backend). + */ +export type ChannelName = 'stable' | 'beta'; +export type SwitchStatus = 'IN_PROGRESS' | 'SUCCESS' | 'ERROR'; + +export interface ChannelStatusDTO { + currentChannel: ChannelName; + switcherAvailable: boolean; + lastSwitch: { + id: string; + status: SwitchStatus; + channel: ChannelName; + message: string; + completedAt: string; + } | null; +} + /** * Reflet de UpdateCheckService.BetaStatus. */ @@ -91,4 +109,23 @@ export class LicenseService { catchError(() => of(null)) ); } + + /** Etat du canal courant et dernier resultat de switch (pour polling UI). */ + getChannelStatus(): Observable { + return this.http.get(`${this.apiUrl}/channel`, this.authOptions).pipe( + catchError(() => of(null)) + ); + } + + /** + * Declenche un switch de canal. 202 + { id, channel } si accepte, + * sinon erreur (403 = pas de licence, 503 = sidecar indispo, etc.). + */ + switchChannel(channel: ChannelName): Observable<{ id: string; channel: ChannelName } | { error: string }> { + return this.http.post<{ id: string; channel: ChannelName }>( + `${this.apiUrl}/channel/switch`, { channel }, this.authOptions + ).pipe( + catchError((err) => of({ error: err?.error?.error ?? 'Echec du switch de canal' })) + ); + } } diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html index 3eba6ca..6a8d4af 100644 --- a/web/src/app/settings/settings.component.html +++ b/web/src/app/settings/settings.component.html @@ -390,19 +390,68 @@ Indisponible : {{ betaStatus.disabledReason }}

-
- - Une version beta est disponible. Pour l'installer, modifie ton fichier .env : - IMAGE_NAMESPACE=igmlcreation/loremind-beta- puis - docker compose pull && docker compose up -d. -
-
+
Verification beta impossible (registry beta injoignable ou baseline absente).
-
- Aucune version beta plus recente disponible. +
+
+ + +
+
+ Canal actuel : + + {{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }} + +
+ + + + + + + + + + +
+ + Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret.
+ + +
+ + {{ switchError }} +
+
+ + +
+ + + Le sidecar de bascule n'est pas installe. Pour beneficier du switch + automatique, recupere le dernier 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). +
diff --git a/web/src/app/settings/settings.component.scss b/web/src/app/settings/settings.component.scss index 409583b..2d5529f 100644 --- a/web/src/app/settings/settings.component.scss +++ b/web/src/app/settings/settings.component.scss @@ -322,6 +322,42 @@ accent-color: #6c63ff; } +.channel-switch { + margin-top: 1rem; + padding: 1rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.channel-current { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + + .channel-label { color: #9ca3af; } +} +.channel-badge { + padding: 0.2rem 0.65rem; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + + &.channel-stable { + background: rgba(76, 175, 80, 0.2); + color: #81c784; + } + &.channel-beta { + background: rgba(108, 99, 255, 0.2); + color: #a39bff; + } +} + .badge-ok { margin-left: auto; background: rgba(76, 175, 80, 0.2); diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts index cfcf4ca..0d754f0 100644 --- a/web/src/app/settings/settings.component.ts +++ b/web/src/app/settings/settings.component.ts @@ -1,13 +1,13 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { interval, switchMap, Subscription } from 'rxjs'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular'; import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service'; -import { Subscription } from 'rxjs'; import { UpdatesService, UpdateStatus } from '../services/updates.service'; import { ConfigService } from '../services/config.service'; -import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/license.service'; +import { LicenseService, LicenseStatusDTO, BetaStatusDTO, ChannelStatusDTO, ChannelName } from '../services/license.service'; import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service'; /** @@ -28,7 +28,7 @@ import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.se templateUrl: './settings.component.html', styleUrls: ['./settings.component.scss'] }) -export class SettingsComponent implements OnInit { +export class SettingsComponent implements OnInit, OnDestroy { readonly ArrowLeft = ArrowLeft; readonly RefreshCw = RefreshCw; @@ -53,6 +53,17 @@ export class SettingsComponent implements OnInit { betaStatus: BetaStatusDTO | null = null; betaChecking = false; + // --- Bascule de canal stable <-> beta via sidecar switcher --- + channelStatus: ChannelStatusDTO | null = null; + /** True pendant le polling apres clic. Bloque les boutons. */ + switchInFlight = false; + /** ID de la commande de switch en cours, pour ignorer les vieux resultats. */ + private switchCommandId: string | null = null; + /** Subscription du polling pour pouvoir l'arreter. */ + private switchPollSub: Subscription | null = null; + /** Erreur affichee si le switch a echoue. */ + switchError = ''; + // --- Pull / delete de modeles Ollama --- /** Dialog d'ajout de modele ouvert/ferme. */ pullDialogOpen = false; @@ -131,6 +142,7 @@ export class SettingsComponent implements OnInit { this.checkUpdates(); } this.loadLicense(); + this.loadChannelStatus(); } // --- Licence Patreon --------------------------------------------------- @@ -237,6 +249,106 @@ export class SettingsComponent implements OnInit { }); } + // --- Bascule de canal stable <-> beta -------------------------------------- + + loadChannelStatus(): void { + this.licenseService.getChannelStatus().subscribe({ + next: (s) => { + this.channelStatus = s; + // Si on revient sur l'ecran apres un reload (post-switch reussi), + // on affiche le dernier resultat eventuel jusqu'a interaction utilisateur. + }, + error: () => { this.channelStatus = null; } + }); + } + + /** + * Declenche un switch de canal. La sequence cote UI : + * 1. Confirm modal (action destructrice : recreate des containers) + * 2. POST /api/license/channel/switch -> 202 avec l'ID de la commande + * 3. Polling /api/license/channel toutes les 2s jusqu'a status != IN_PROGRESS + * 4. Si SUCCESS : la page va se rendre injoignable (Core recree). On affiche + * "Recharge la page dans quelques secondes" et on essaie de poll quand + * meme — au retour de Core, on detectera SUCCESS et on rechargera auto. + * 5. Si ERROR : on affiche le message d'erreur et on debloque les boutons. + */ + requestChannelSwitch(target: ChannelName): void { + const confirmMessage = target === 'beta' + ? 'Basculer LoreMind sur le canal beta ? Les containers core/brain/web vont etre recrees avec les images beta. L\'application sera indisponible 10-30 secondes.' + : 'Repasser LoreMind sur le canal stable ? Les containers core/brain/web vont etre recrees avec les images stables. L\'application sera indisponible 10-30 secondes.'; + + this.confirmDialog.confirm({ + title: target === 'beta' ? 'Passer en beta ?' : 'Repasser en stable ?', + message: confirmMessage, + details: [ + 'Les donnees (DB, images) sont preservees.', + 'Tu pourras refaire le chemin inverse a tout moment depuis cet ecran.' + ], + confirmLabel: target === 'beta' ? 'Passer en beta' : 'Repasser en stable', + variant: 'warning' + }).then(ok => { + if (!ok) return; + this.doChannelSwitch(target); + }); + } + + private doChannelSwitch(target: ChannelName): void { + this.switchInFlight = true; + this.switchError = ''; + this.licenseService.switchChannel(target).subscribe((res) => { + if ('error' in res) { + this.switchError = res.error; + this.switchInFlight = false; + return; + } + this.switchCommandId = res.id; + this.startSwitchPolling(); + }); + } + + /** + * Poll /api/license/channel toutes les 2s. S'arrete quand on detecte un + * resultat avec un ID >= a celui qu'on a soumis (le sidecar le met a jour + * a la fin de son traitement). + */ + private startSwitchPolling(): void { + this.stopSwitchPolling(); + this.switchPollSub = interval(2000).pipe( + switchMap(() => this.licenseService.getChannelStatus()) + ).subscribe((status) => { + if (!status) return; + this.channelStatus = status; + const last = status.lastSwitch; + if (!last || last.id !== this.switchCommandId) return; + if (last.status === 'SUCCESS') { + // La page va se rafraichir auto via l'update-banner qui detecte le + // restart de Core. On laisse switchInFlight a true pour bloquer + // toute autre action en attendant. + this.stopSwitchPolling(); + this.switchInFlight = false; + } else if (last.status === 'ERROR') { + this.switchError = last.message || 'Echec du switch'; + this.stopSwitchPolling(); + this.switchInFlight = false; + } + // IN_PROGRESS : on continue a poll. + }); + } + + private stopSwitchPolling(): void { + if (this.switchPollSub) { + this.switchPollSub.unsubscribe(); + this.switchPollSub = null; + } + } + + ngOnDestroy(): void { + this.stopSwitchPolling(); + if (this.pullSubscription) { + this.pullSubscription.unsubscribe(); + } + } + /** * Mapping tier_id Patreon → nom lisible. Les IDs viennent du dashboard * Patreon de LoreMind (Settings -> Tiers). Sans entree dans la map, on