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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user