Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build-switcher (push) Successful in 39s
Build & Push Images / build (web) (push) Successful in 1m40s
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build-switcher (push) Successful in 39s
Build & Push Images / build (web) (push) Successful in 1m40s
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.8.4-beta</version>
|
||||
<version>0.8.5</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -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`.
|
||||
*
|
||||
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
|
||||
* dans un volume partage. Quand on depose une commande, il :
|
||||
* <ol>
|
||||
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
|
||||
* <li>Lance docker compose pull + up -d</li>
|
||||
* <li>Ecrit son resultat dans {@code result.json}</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>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).
|
||||
*
|
||||
* <p>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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>{@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.
|
||||
*
|
||||
* <p>{@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());
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
26
switcher/Dockerfile
Normal file
26
switcher/Dockerfile
Normal file
@@ -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"]
|
||||
66
switcher/README.md
Normal file
66
switcher/README.md
Normal file
@@ -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.
|
||||
112
switcher/switch.sh
Normal file
112
switcher/switch.sh
Normal file
@@ -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}*."
|
||||
84
switcher/watch.sh
Normal file
84
switcher/watch.sh
Normal file
@@ -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}" <<EOF
|
||||
{
|
||||
"id": "${id}",
|
||||
"status": "${status}",
|
||||
"channel": "${channel}",
|
||||
"message": $(printf '%s' "${message}" | jq -Rs .),
|
||||
"completedAt": "$(date -u --iso-8601=seconds)"
|
||||
}
|
||||
EOF
|
||||
mv "${tmp}" "${RESULT_FILE}"
|
||||
}
|
||||
|
||||
log "LoreMind channel switcher started — watching ${COMMAND_FILE}"
|
||||
|
||||
# Boucle de polling. Intervalle court (1s) — la charge est negligeable
|
||||
# (un test de fichier) et l'utilisateur attend une reaction rapide.
|
||||
while true; do
|
||||
if [[ -f "${COMMAND_FILE}" ]]; then
|
||||
# Parse la commande. Tolere les JSON malformes : on ignore et on attend.
|
||||
if ! id=$(jq -er '.id' "${COMMAND_FILE}" 2>/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
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.4-beta",
|
||||
"version": "0.8.5",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -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<ChannelStatusDTO | null> {
|
||||
return this.http.get<ChannelStatusDTO>(`${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' }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,19 +390,68 @@
|
||||
Indisponible : {{ betaStatus.disabledReason }}
|
||||
</div>
|
||||
<div *ngIf="!betaChecking && betaStatus?.enabled">
|
||||
<div *ngIf="betaStatus?.updateAvailable" class="alert alert-success">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>Une version beta est disponible. Pour l'installer, modifie ton fichier <code>.env</code> :
|
||||
<code>IMAGE_NAMESPACE=igmlcreation/loremind-beta-</code> puis
|
||||
<code>docker compose pull && docker compose up -d</code>.</span>
|
||||
</div>
|
||||
<div *ngIf="betaStatus?.anyUnknown && !betaStatus?.updateAvailable" class="alert alert-warn">
|
||||
<div *ngIf="betaStatus?.anyUnknown" class="alert alert-warn">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>Verification beta impossible (registry beta injoignable ou baseline absente).</span>
|
||||
</div>
|
||||
<div *ngIf="!betaStatus?.updateAvailable && !betaStatus?.anyUnknown" class="hint">
|
||||
Aucune version beta plus recente disponible.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bascule de canal (stable <-> beta) via sidecar switcher -->
|
||||
<div class="channel-switch" *ngIf="channelStatus">
|
||||
<div class="channel-current">
|
||||
<span class="channel-label">Canal actuel :</span>
|
||||
<span class="channel-badge"
|
||||
[class.channel-stable]="channelStatus.currentChannel === 'stable'"
|
||||
[class.channel-beta]="channelStatus.currentChannel === 'beta'">
|
||||
{{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Sidecar dispo : boutons d'action -->
|
||||
<ng-container *ngIf="channelStatus.switcherAvailable">
|
||||
<!-- On stable -> proposer passage beta (uniquement si licence active) -->
|
||||
<button *ngIf="channelStatus.currentChannel === 'stable'"
|
||||
type="button" class="btn-primary"
|
||||
[disabled]="switchInFlight"
|
||||
(click)="requestChannelSwitch('beta')">
|
||||
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
||||
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- On beta -> proposer retour stable -->
|
||||
<button *ngIf="channelStatus.currentChannel === 'beta'"
|
||||
type="button" class="btn-secondary"
|
||||
[disabled]="switchInFlight"
|
||||
(click)="requestChannelSwitch('stable')">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Switch en cours : on prévient que la page va se rendre injoignable -->
|
||||
<div *ngIf="switchInFlight" class="alert alert-warn">
|
||||
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
|
||||
<span>Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret.</span>
|
||||
</div>
|
||||
|
||||
<!-- Erreur eventuelle remontee par le sidecar -->
|
||||
<div *ngIf="switchError && !switchInFlight" class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>{{ switchError }}</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) -->
|
||||
<div *ngIf="!channelStatus.switcherAvailable" class="alert alert-warn">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>
|
||||
Le sidecar de bascule n'est pas installe. Pour beneficier du switch
|
||||
automatique, recupere le dernier <code>docker-compose.yml</code> du repo
|
||||
et fais <code>docker compose pull && docker compose up -d</code> une
|
||||
fois. Sinon, bascule manuellement en editant <code>IMAGE_NAMESPACE</code>
|
||||
dans ton <code>.env</code> (<code>igmlcreation/loremind-</code> pour stable,
|
||||
<code>igmlcreation/loremind-beta-</code> pour beta).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user