Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efaf5a3794 | |||
| 4fe93b5ff3 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,6 +7,11 @@
|
||||
brain/data/settings.json
|
||||
*.key
|
||||
*.pem
|
||||
# Exception : la cle PUBLIQUE JWT du relais Patreon est destinee a etre
|
||||
# embarquee dans le binaire. Pas de risque a la committer (c'est une cle
|
||||
# publique par construction). Sans cette exception, le module licensing
|
||||
# est silencieusement desactive dans les builds CI.
|
||||
!core/src/main/resources/licensing/jwt-public-key.pem
|
||||
|
||||
# ============================================================================
|
||||
# Java / Spring Boot / Maven
|
||||
|
||||
12
core/pom.xml
12
core/pom.xml
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.8.0</version>
|
||||
<version>0.8.1</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
@@ -111,6 +111,16 @@
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
<executions>
|
||||
<!-- Genere META-INF/build-info.properties (project.version)
|
||||
consomme par Spring BuildProperties pour exposer la
|
||||
version courante a l'application (UpdateCheckService). -->
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>build-info</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- JaCoCo : rapport de couverture des tests unitaires.
|
||||
|
||||
@@ -4,10 +4,10 @@ import com.loremind.application.licensing.LicenseService;
|
||||
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||
import com.loremind.domain.licensing.LicenseStatus;
|
||||
import com.loremind.domain.licensing.RegistryCredentials;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.info.BuildProperties;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -29,187 +29,121 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Detection des mises a jour disponibles + declenchement via Watchtower.
|
||||
* <p>
|
||||
* <b>Strategie</b> : comparaison de versions semver, pas de digests.
|
||||
* <ul>
|
||||
* <li>La version courante de l'app est lue depuis {@link BuildProperties}
|
||||
* (genere par spring-boot-maven-plugin dans META-INF/build-info.properties).</li>
|
||||
* <li>Pour chaque image suivie, on interroge le registry sur
|
||||
* {@code /v2/<image>/tags/list}, on extrait les tags semver, on prend le max.</li>
|
||||
* <li>Si max > version courante => UPDATE_AVAILABLE.</li>
|
||||
* <li>Si max == version courante => UP_TO_DATE.</li>
|
||||
* <li>Si registry injoignable ou aucun tag valide => UNKNOWN.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Strategie :
|
||||
* - Au demarrage, on interroge le registry pour le digest courant de chaque
|
||||
* image suivie ({@code update-check.images}). On stocke ces digests comme
|
||||
* "baseline" (= ce que le conteneur en cours d'execution est cense faire
|
||||
* tourner, puisque le `docker compose pull` precede toujours `up -d`).
|
||||
* - Si l'init echoue (reseau Docker pas encore pret, registry transitoirement
|
||||
* indisponible), un thread daemon de retry avec backoff complete les
|
||||
* baselines manquantes en arriere-plan.
|
||||
* - {@link #check()} re-interroge le registry et compare. Si un digest a
|
||||
* change, une mise a jour est disponible. Si la baseline manque (echec
|
||||
* de tous les retries), retourne {@link ImageStatusKind#UNKNOWN} pour
|
||||
* cette image — JAMAIS d'alignement silencieux (eviterait des MAJ ratees).
|
||||
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
|
||||
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
|
||||
*
|
||||
* Apres un apply reussi, Watchtower redemarre core => ce service est
|
||||
* re-instancie => baseline re-aligne sur le registry => check renvoie
|
||||
* "pas de MAJ" (etat coherent).
|
||||
*
|
||||
* La feature est <b>desactivee silencieusement</b> si {@code WATCHTOWER_TOKEN}
|
||||
* n'est pas defini : check/apply renvoient des reponses neutres et l'UI
|
||||
* masque le badge / bouton.
|
||||
* <b>Pourquoi pas les digests ?</b> Le bug historique etait : le baseline-digest
|
||||
* pose au @PostConstruct supposait que le pull venait d'avoir lieu (vrai apres
|
||||
* `docker compose pull && up -d`, faux apres un simple restart de daemon ou un
|
||||
* OOM). La version semver lue depuis le binaire est <b>fiable par construction</b> :
|
||||
* c'est ce que le code source declare faire tourner.
|
||||
*/
|
||||
@Service
|
||||
public class UpdateCheckService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
|
||||
|
||||
private static final List<MediaType> MANIFEST_ACCEPT = List.of(
|
||||
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.v2+json"),
|
||||
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.list.v2+json"),
|
||||
MediaType.parseMediaType("application/vnd.oci.image.manifest.v1+json"),
|
||||
MediaType.parseMediaType("application/vnd.oci.image.index.v1+json")
|
||||
);
|
||||
|
||||
private final RestTemplate http;
|
||||
private final String registry;
|
||||
private final List<String> images;
|
||||
private final String tag;
|
||||
private final String watchtowerUrl;
|
||||
private final String watchtowerToken;
|
||||
private final List<String> betaImages;
|
||||
private final String betaTag;
|
||||
private final LicenseService licenseService;
|
||||
|
||||
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>();
|
||||
/** Version semver courante du binaire (ex: "0.8.0"). Source de verite. */
|
||||
private final String currentVersion;
|
||||
|
||||
public UpdateCheckService(
|
||||
RestTemplateBuilder builder,
|
||||
@Value("${update-check.registry:}") String registry,
|
||||
@Value("${update-check.images:}") String imagesCsv,
|
||||
@Value("${update-check.tag:latest}") String tag,
|
||||
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
|
||||
@Value("${update-check.watchtower-token:}") String watchtowerToken,
|
||||
@Value("${licensing.beta.images:}") String betaImagesCsv,
|
||||
@Value("${licensing.beta.tag:latest}") String betaTag,
|
||||
LicenseService licenseService) {
|
||||
LicenseService licenseService,
|
||||
@Nullable BuildProperties buildProperties) {
|
||||
this.http = builder
|
||||
.setConnectTimeout(Duration.ofSeconds(5))
|
||||
.setReadTimeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
this.registry = normalizeRegistry(registry);
|
||||
this.images = parseImages(imagesCsv);
|
||||
this.tag = tag;
|
||||
this.watchtowerUrl = watchtowerUrl;
|
||||
this.watchtowerToken = watchtowerToken;
|
||||
this.betaImages = parseImages(betaImagesCsv);
|
||||
this.betaTag = betaTag;
|
||||
this.licenseService = licenseService;
|
||||
}
|
||||
|
||||
/** Backoff progressif (ms) pour retry de baseline en cas d'echec initial. */
|
||||
private static final long[] BASELINE_RETRY_BACKOFFS_MS = {2_000, 5_000, 15_000, 30_000, 60_000};
|
||||
|
||||
@PostConstruct
|
||||
void initBaseline() {
|
||||
if (!isEnabled()) {
|
||||
log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
|
||||
return;
|
||||
}
|
||||
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
|
||||
boolean complete = tryBaselineMissing();
|
||||
if (!complete) {
|
||||
startBaselineRetryThread();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tente de poser la baseline pour les images qui ne l'ont pas encore.
|
||||
* @return true si TOUTES les images ont leur baseline apres cet essai.
|
||||
*/
|
||||
private boolean tryBaselineMissing() {
|
||||
for (String image : images) {
|
||||
if (baselineDigests.containsKey(image)) continue;
|
||||
try {
|
||||
String digest = fetchRemoteDigest(image);
|
||||
if (digest != null) {
|
||||
baselineDigests.put(image, digest);
|
||||
log.debug("Baseline digest for {} = {}", image, digest);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
|
||||
}
|
||||
}
|
||||
return baselineDigests.size() == images.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance un thread daemon qui retente de poser les baselines manquantes
|
||||
* avec backoff. Le thread s'arrete des que toutes les baselines sont
|
||||
* posees, ou apres epuisement des backoffs (et alors {@link #check()}
|
||||
* retournera UNKNOWN pour ces images jusqu'au prochain redemarrage).
|
||||
*/
|
||||
private void startBaselineRetryThread() {
|
||||
Thread t = new Thread(() -> {
|
||||
for (long backoff : BASELINE_RETRY_BACKOFFS_MS) {
|
||||
try {
|
||||
Thread.sleep(backoff);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
if (tryBaselineMissing()) {
|
||||
log.info("Baseline complete after retry");
|
||||
return;
|
||||
}
|
||||
}
|
||||
log.warn("Baseline incomplete after all retries; check() will return UNKNOWN for missing images");
|
||||
}, "update-baseline-retry");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
|
||||
log.info("Update check init - registry={} images={} currentVersion={}",
|
||||
this.registry, this.images, this.currentVersion);
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return version courante exposee aux endpoints (ex: pour affichage UI).
|
||||
* {@code null} si build-info.properties absent (dev en IDE sans build Maven).
|
||||
*/
|
||||
public String getCurrentVersion() {
|
||||
return currentVersion;
|
||||
}
|
||||
|
||||
public UpdateStatus check() {
|
||||
if (!isEnabled()) {
|
||||
return new UpdateStatus(false, false, false, List.of(), Instant.now());
|
||||
return new UpdateStatus(false, false, false, null, List.of(), Instant.now());
|
||||
}
|
||||
if (currentVersion == null) {
|
||||
log.warn("Update check : currentVersion absente (build-info manquant). Tous UNKNOWN.");
|
||||
List<ImageStatus> statuses = new ArrayList<>();
|
||||
for (String image : images) {
|
||||
statuses.add(new ImageStatus(image, null, null, ImageStatusKind.UNKNOWN));
|
||||
}
|
||||
return new UpdateStatus(true, false, true, null, statuses, Instant.now());
|
||||
}
|
||||
|
||||
List<ImageStatus> statuses = new ArrayList<>();
|
||||
boolean anyUpdate = false;
|
||||
boolean anyUnknown = false;
|
||||
for (String image : images) {
|
||||
String baseline = baselineDigests.get(image);
|
||||
String remote = null;
|
||||
String latest = null;
|
||||
try {
|
||||
remote = fetchRemoteDigest(image);
|
||||
latest = fetchLatestSemverTag(registry, image, null);
|
||||
} catch (Exception e) {
|
||||
log.warn("Check failed for {}: {}", image, e.getMessage());
|
||||
log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
|
||||
}
|
||||
// PAS d'alignement lazy si baseline absente : ce serait un faux negatif
|
||||
// silencieux. On reporte UNKNOWN pour que l'UI le signale.
|
||||
ImageStatusKind kind;
|
||||
if (baseline == null || remote == null) {
|
||||
if (latest == null) {
|
||||
kind = ImageStatusKind.UNKNOWN;
|
||||
anyUnknown = true;
|
||||
} else if (baseline.equals(remote)) {
|
||||
kind = ImageStatusKind.UP_TO_DATE;
|
||||
} else {
|
||||
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||
anyUpdate = true;
|
||||
int cmp = compareSemver(currentVersion, latest);
|
||||
if (cmp >= 0) {
|
||||
kind = ImageStatusKind.UP_TO_DATE;
|
||||
} else {
|
||||
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||
anyUpdate = true;
|
||||
}
|
||||
}
|
||||
statuses.add(new ImageStatus(image, baseline, remote, kind));
|
||||
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
|
||||
}
|
||||
return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now());
|
||||
return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie l'etat du canal beta (images privees GHCR).
|
||||
* Necessite licence valide/grace + toggle beta ON.
|
||||
* Authentification basic auth via le PAT distribue par le relais.
|
||||
*
|
||||
* @return statut beta (peut etre {@link BetaStatus#disabled()} si licence absente,
|
||||
* beta off ou licence expiree)
|
||||
* Verifie l'etat du canal beta (images privees GHCR) avec auth basique.
|
||||
*/
|
||||
public BetaStatus checkBeta() {
|
||||
if (!licenseService.isLicensingEnabled()) {
|
||||
@@ -239,48 +173,156 @@ public class UpdateCheckService {
|
||||
boolean anyUpdate = false;
|
||||
boolean anyUnknown = false;
|
||||
for (String image : betaImages) {
|
||||
String remote = null;
|
||||
String latest = null;
|
||||
try {
|
||||
remote = fetchRemoteDigestAuth(betaRegistry, image, betaTag, basicAuth);
|
||||
latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
|
||||
} catch (Exception e) {
|
||||
log.warn("Beta check failed for {}: {}", image, e.getMessage());
|
||||
log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage());
|
||||
}
|
||||
// Pas de baseline pour la beta : on ne peut pas dire "a jour" car on
|
||||
// ne sait pas quelle version le user fait tourner. On expose juste le
|
||||
// digest remote ; l'UI affichera "version disponible : <tag>" sans
|
||||
// comparaison locale tant qu'il n'y a pas un mecanisme de baseline.
|
||||
ImageStatusKind kind = (remote == null) ? ImageStatusKind.UNKNOWN : ImageStatusKind.UPDATE_AVAILABLE;
|
||||
if (kind == ImageStatusKind.UNKNOWN) anyUnknown = true;
|
||||
else anyUpdate = true;
|
||||
statuses.add(new ImageStatus(image, null, remote, kind));
|
||||
ImageStatusKind kind;
|
||||
if (latest == null) {
|
||||
kind = ImageStatusKind.UNKNOWN;
|
||||
anyUnknown = true;
|
||||
} else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
|
||||
kind = ImageStatusKind.UP_TO_DATE;
|
||||
} else {
|
||||
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||
anyUpdate = true;
|
||||
}
|
||||
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
|
||||
}
|
||||
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
|
||||
}
|
||||
|
||||
private String fetchRemoteDigestAuth(String registryUrl, String image, String tagName, String authHeader) {
|
||||
String url = registryUrl + "/v2/" + image + "/manifests/" + tagName;
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setAccept(MANIFEST_ACCEPT);
|
||||
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
|
||||
try {
|
||||
return digestCall(url, headers);
|
||||
} catch (HttpClientErrorException.Unauthorized e) {
|
||||
// GHCR peut exiger d'echanger basic auth contre un bearer token via
|
||||
// le challenge WWW-Authenticate. On reuse la logique existante en
|
||||
// ajoutant l'auth header a la requete /token.
|
||||
String www = e.getResponseHeaders() == null ? null
|
||||
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
||||
String token = obtainBearerTokenWithAuth(www, authHeader);
|
||||
if (token == null) return null;
|
||||
HttpHeaders bearerHeaders = new HttpHeaders();
|
||||
bearerHeaders.setAccept(MANIFEST_ACCEPT);
|
||||
bearerHeaders.setBearerAuth(token);
|
||||
return digestCall(url, bearerHeaders);
|
||||
public void apply() {
|
||||
if (!isEnabled()) {
|
||||
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(watchtowerToken);
|
||||
http.exchange(
|
||||
watchtowerUrl + "/v1/update",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(headers),
|
||||
Void.class);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Registry HTTP API v2 - tags listing + auth bearer
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Interroge le registry pour la liste des tags d'une image, parse les
|
||||
* versions semver et retourne la plus elevee. {@code null} si echec
|
||||
* ou aucun tag valide.
|
||||
*
|
||||
* @param registryUrl URL normalisee (ex: "https://ghcr.io")
|
||||
* @param image nom de l'image (ex: "igmlcreation/loremind-core")
|
||||
* @param authHeader optionnel - "Basic ..." pour les registries prives
|
||||
*/
|
||||
private String fetchLatestSemverTag(String registryUrl, String image, @Nullable String authHeader) {
|
||||
String url = registryUrl + "/v2/" + image + "/tags/list";
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
|
||||
if (authHeader != null) {
|
||||
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
|
||||
}
|
||||
TagsListResponse body;
|
||||
try {
|
||||
body = tagsCall(url, headers);
|
||||
} catch (HttpClientErrorException.Unauthorized e) {
|
||||
String www = e.getResponseHeaders() == null ? null
|
||||
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
||||
String token = obtainBearerToken(www, authHeader);
|
||||
if (token == null) {
|
||||
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
|
||||
return null;
|
||||
}
|
||||
HttpHeaders bearerHeaders = new HttpHeaders();
|
||||
bearerHeaders.setAccept(List.of(MediaType.APPLICATION_JSON));
|
||||
bearerHeaders.setBearerAuth(token);
|
||||
body = tagsCall(url, bearerHeaders);
|
||||
}
|
||||
if (body == null || body.tags == null || body.tags.isEmpty()) return null;
|
||||
return findMaxSemver(body.tags);
|
||||
}
|
||||
|
||||
private TagsListResponse tagsCall(String url, HttpHeaders headers) {
|
||||
ResponseEntity<TagsListResponse> resp = http.exchange(
|
||||
url, HttpMethod.GET, new HttpEntity<>(headers), TagsListResponse.class);
|
||||
return resp.getBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
|
||||
* (1 a 3 chiffres separes par des points, optionnel prefix "v"), retourne le max.
|
||||
* Pre-release / build metadata sont strippes pour la comparaison.
|
||||
*/
|
||||
@Nullable
|
||||
static String findMaxSemver(List<String> tags) {
|
||||
String maxTag = null;
|
||||
int[] maxParts = null;
|
||||
for (String t : tags) {
|
||||
if (t == null || t.isBlank()) continue;
|
||||
int[] parts = parseSemver(t);
|
||||
if (parts == null) continue;
|
||||
if (maxParts == null || compareParts(parts, maxParts) > 0) {
|
||||
maxParts = parts;
|
||||
maxTag = t;
|
||||
}
|
||||
}
|
||||
return maxTag;
|
||||
}
|
||||
|
||||
/** @return [major, minor, patch] ou null si non parsable. */
|
||||
@Nullable
|
||||
static int[] parseSemver(String tag) {
|
||||
if (tag == null) return null;
|
||||
String s = tag.trim();
|
||||
if (s.isEmpty()) return null;
|
||||
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
|
||||
int dashIdx = s.indexOf('-');
|
||||
if (dashIdx > 0) s = s.substring(0, dashIdx);
|
||||
int plusIdx = s.indexOf('+');
|
||||
if (plusIdx > 0) s = s.substring(0, plusIdx);
|
||||
String[] parts = s.split("\\.");
|
||||
if (parts.length < 1 || parts.length > 3) return null;
|
||||
int[] result = new int[]{0, 0, 0};
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
try {
|
||||
int v = Integer.parseInt(parts[i]);
|
||||
if (v < 0) return null;
|
||||
result[i] = v;
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Compare deux versions semver brutes (sans prefix). Negatif si a < b. */
|
||||
static int compareSemver(String a, String b) {
|
||||
int[] aParts = parseSemver(a);
|
||||
int[] bParts = parseSemver(b);
|
||||
if (aParts == null || bParts == null) return 0;
|
||||
return compareParts(aParts, bParts);
|
||||
}
|
||||
|
||||
private static int compareParts(int[] a, int[] b) {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int diff = Integer.compare(a[i], b[i]);
|
||||
if (diff != 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir
|
||||
* un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas
|
||||
* registry prive). Sinon anonyme (cas registry public).
|
||||
*/
|
||||
@SuppressWarnings("rawtypes")
|
||||
private String obtainBearerTokenWithAuth(@Nullable String wwwAuth, String authHeader) {
|
||||
private String obtainBearerToken(@Nullable String wwwAuth, @Nullable String basicAuth) {
|
||||
if (wwwAuth == null) return null;
|
||||
String prefix = "Bearer ";
|
||||
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
||||
@@ -301,7 +343,9 @@ public class UpdateCheckService {
|
||||
}
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
|
||||
if (basicAuth != null) {
|
||||
headers.set(HttpHeaders.AUTHORIZATION, basicAuth);
|
||||
}
|
||||
ResponseEntity<Map> resp = http.exchange(url.toString(), HttpMethod.GET,
|
||||
new HttpEntity<>(headers), Map.class);
|
||||
Map<?, ?> body = resp.getBody();
|
||||
@@ -309,97 +353,6 @@ public class UpdateCheckService {
|
||||
Object t = body.get("token");
|
||||
if (t == null) t = body.get("access_token");
|
||||
return t == null ? null : t.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("Beta bearer token request failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void apply() {
|
||||
if (!isEnabled()) {
|
||||
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(watchtowerToken);
|
||||
// Watchtower /v1/update declenche un scan+update immediat de tous les
|
||||
// conteneurs labellises. La reponse est synchrone et peut prendre
|
||||
// plusieurs secondes; en cas de redemarrage de core, le client
|
||||
// recevra une connexion coupee — c'est attendu, l'UI le gere.
|
||||
http.exchange(
|
||||
watchtowerUrl + "/v1/update",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(headers),
|
||||
Void.class);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Registry HTTP API v2
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private String fetchRemoteDigest(String image) {
|
||||
String url = registry + "/v2/" + image + "/manifests/" + tag;
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setAccept(MANIFEST_ACCEPT);
|
||||
try {
|
||||
return digestCall(url, headers);
|
||||
} catch (HttpClientErrorException.Unauthorized e) {
|
||||
String www = e.getResponseHeaders() == null ? null
|
||||
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
||||
String token = obtainBearerToken(www);
|
||||
if (token == null) {
|
||||
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
|
||||
return null;
|
||||
}
|
||||
headers.setBearerAuth(token);
|
||||
return digestCall(url, headers);
|
||||
}
|
||||
}
|
||||
|
||||
private String digestCall(String url, HttpHeaders headers) {
|
||||
ResponseEntity<Void> resp = http.exchange(
|
||||
url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class);
|
||||
return resp.getHeaders().getFirst("Docker-Content-Digest");
|
||||
}
|
||||
|
||||
/**
|
||||
* Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."}
|
||||
* pour obtenir un jeton (anonyme — suffisant pour les images publiques).
|
||||
*/
|
||||
@SuppressWarnings("rawtypes")
|
||||
private String obtainBearerToken(String wwwAuth) {
|
||||
if (wwwAuth == null) return null;
|
||||
String prefix = "Bearer ";
|
||||
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
||||
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
|
||||
String realm = params.get("realm");
|
||||
if (realm == null) return null;
|
||||
StringBuilder url = new StringBuilder(realm);
|
||||
boolean hasQuery = realm.contains("?");
|
||||
for (String key : new String[]{"service", "scope"}) {
|
||||
String v = params.get(key);
|
||||
if (v != null) {
|
||||
// URLEncoder fait du "form encoding" qui transforme `:` et `/`
|
||||
// en %3A et %2F. La plupart des registries (Docker Hub, Gitea)
|
||||
// acceptent les deux, mais GHCR est strict et rejette le scope
|
||||
// encode (403 DENIED). On preserve donc `:` et `/` dans la
|
||||
// valeur, conformement a ce que GHCR attend
|
||||
// (et que docker pull lui-meme envoie).
|
||||
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
|
||||
.replace("%3A", ":")
|
||||
.replace("%2F", "/");
|
||||
url.append(hasQuery ? '&' : '?')
|
||||
.append(key).append('=')
|
||||
.append(encoded);
|
||||
hasQuery = true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
ResponseEntity<Map> resp = http.getForEntity(url.toString(), Map.class);
|
||||
Map<?, ?> body = resp.getBody();
|
||||
if (body == null) return null;
|
||||
Object t = body.get("token");
|
||||
if (t == null) t = body.get("access_token");
|
||||
return t == null ? null : t.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("Bearer token request failed: {}", e.getMessage());
|
||||
return null;
|
||||
@@ -455,37 +408,36 @@ public class UpdateCheckService {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Records de retour (sortis sous forme JSON par Jackson)
|
||||
// Records / DTO
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Etat tri-state d'une image vis-a-vis du registry.
|
||||
* <ul>
|
||||
* <li>{@link #UP_TO_DATE} : digest local == digest remote.</li>
|
||||
* <li>{@link #UPDATE_AVAILABLE} : digests differents, MAJ disponible.</li>
|
||||
* <li>{@link #UNKNOWN} : impossible de comparer (baseline ou remote manquant).
|
||||
* L'UI doit afficher un avertissement plutot que de declarer "a jour".</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN }
|
||||
|
||||
public record UpdateStatus(
|
||||
boolean enabled,
|
||||
boolean updateAvailable,
|
||||
boolean anyUnknown,
|
||||
String currentVersion,
|
||||
List<ImageStatus> images,
|
||||
Instant checkedAt) {}
|
||||
|
||||
/**
|
||||
* Etat du canal beta.
|
||||
* <ul>
|
||||
* <li>{@code enabled} : true si le canal beta est actif et la licence valide.</li>
|
||||
* <li>{@code disabledReason} : si {@code enabled=false}, raison technique
|
||||
* (licensing-not-configured, license-none, license-expired, beta-toggle-off,
|
||||
* no-beta-images-configured, relay-unavailable). Permet a l'UI d'afficher
|
||||
* un message contextuel.</li>
|
||||
* </ul>
|
||||
* Statut par image. {@code localVersion} = version embarquee dans le binaire ;
|
||||
* {@code remoteVersion} = plus haute version semver trouvee dans le registry.
|
||||
* {@code updateAvailable} est derive de {@code status} (back-compat front).
|
||||
*/
|
||||
public record ImageStatus(
|
||||
String image,
|
||||
String localVersion,
|
||||
String remoteVersion,
|
||||
ImageStatusKind status,
|
||||
boolean updateAvailable) {
|
||||
|
||||
public ImageStatus(String image, String localVersion, String remoteVersion, ImageStatusKind status) {
|
||||
this(image, localVersion, remoteVersion, status, status == ImageStatusKind.UPDATE_AVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
public record BetaStatus(
|
||||
boolean enabled,
|
||||
boolean updateAvailable,
|
||||
@@ -499,20 +451,9 @@ public class UpdateCheckService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Le champ {@code updateAvailable} est conserve pour la compatibilite
|
||||
* avec les anciens clients ; il est strictement derive de {@code status}
|
||||
* dans le constructeur compact.
|
||||
*/
|
||||
public record ImageStatus(
|
||||
String image,
|
||||
String localDigest,
|
||||
String remoteDigest,
|
||||
ImageStatusKind status,
|
||||
boolean updateAvailable) {
|
||||
|
||||
public ImageStatus(String image, String localDigest, String remoteDigest, ImageStatusKind status) {
|
||||
this(image, localDigest, remoteDigest, status, status == ImageStatusKind.UPDATE_AVAILABLE);
|
||||
}
|
||||
/** DTO pour deserialisation Jackson de /v2/.../tags/list. */
|
||||
static class TagsListResponse {
|
||||
public String name;
|
||||
public List<String> tags;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import org.springframework.boot.info.BuildProperties;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Endpoint public exposant la version courante du binaire.
|
||||
* <p>
|
||||
* Consomme par le frontend pour detecter qu'une mise a jour a ete deployee
|
||||
* pendant qu'un onglet utilisateur etait deja ouvert : si la version polled
|
||||
* differe de celle observee au boot, l'UI affiche un bandeau "rechargez".
|
||||
* <p>
|
||||
* Volontairement public (pas d'auth) : la version est deja exposee dans le
|
||||
* JAR / l'image Docker, aucun risque de leak.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/version")
|
||||
public class VersionController {
|
||||
|
||||
private final String version;
|
||||
|
||||
public VersionController(@Nullable BuildProperties buildProperties) {
|
||||
this.version = buildProperties != null ? buildProperties.getVersion() : "dev";
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Map<String, String> getVersion() {
|
||||
return Map.of("version", version);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,6 @@ app.demo-mode=${DEMO_MODE:false}
|
||||
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
|
||||
update-check.registry=${UPDATE_CHECK_REGISTRY:}
|
||||
update-check.images=${UPDATE_CHECK_IMAGES:}
|
||||
update-check.tag=${UPDATE_CHECK_TAG:latest}
|
||||
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
|
||||
update-check.watchtower-token=${WATCHTOWER_TOKEN:}
|
||||
|
||||
@@ -97,7 +96,6 @@ licensing.instance-id-file=${LICENSING_INSTANCE_ID_FILE:}
|
||||
# Image beta : si la licence est valide ET le toggle canal beta active,
|
||||
# UpdateCheckService check ces images en plus du canal stable.
|
||||
licensing.beta.images=${LICENSING_BETA_IMAGES:igmlcreation/loremind-beta-core,igmlcreation/loremind-beta-brain,igmlcreation/loremind-beta-web}
|
||||
licensing.beta.tag=${LICENSING_BETA_TAG:latest}
|
||||
|
||||
# Chemin de sortie pour le docker config.json partage avec Watchtower.
|
||||
# Volume Docker `docker-config` monte sur ce chemin dans Core, et sur
|
||||
|
||||
3
core/src/main/resources/licensing/jwt-public-key.pem
Normal file
3
core/src/main/resources/licensing/jwt-public-key.pem
Normal file
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEArbfaqBq54HJR1pKqliTShKrNIab32gpBwSTDw90I4wg=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,17 +1,21 @@
|
||||
package com.loremind.infrastructure.updates;
|
||||
|
||||
import com.loremind.application.licensing.LicenseService;
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.info.BuildProperties;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -19,69 +23,68 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Test unitaire pour UpdateCheckService.
|
||||
* Tests UpdateCheckService - approche semver (post-refactor v0.8.x).
|
||||
*
|
||||
* Couvre les invariants critiques de la detection de MAJ :
|
||||
* - feature desactivee si token absent
|
||||
* - status UP_TO_DATE quand baseline == remote
|
||||
* - status UPDATE_AVAILABLE quand baseline != remote
|
||||
* - status UNKNOWN quand baseline manque (PAS d'alignement lazy — invariant
|
||||
* central, regression historique)
|
||||
* - status UNKNOWN quand remote impossible a fetcher
|
||||
* - drapeaux top-level updateAvailable / anyUnknown coherents
|
||||
* - back-compat : champ updateAvailable sur ImageStatus = (status == UPDATE_AVAILABLE)
|
||||
* Couvre :
|
||||
* - feature desactivee si WATCHTOWER_TOKEN absent
|
||||
* - UP_TO_DATE quand version locale == max(tags remote)
|
||||
* - UPDATE_AVAILABLE quand un tag plus eleve existe
|
||||
* - UNKNOWN quand le registry echoue
|
||||
* - UNKNOWN quand BuildProperties est absent (currentVersion = null)
|
||||
* - parseSemver / findMaxSemver / compareSemver utilitaires
|
||||
*/
|
||||
public class UpdateCheckServiceTest {
|
||||
|
||||
private static UpdateCheckService newService(String token) {
|
||||
// licensing.* params left empty + LicenseService null : la feature beta est
|
||||
// desactivee dans ces tests, qui couvrent uniquement le canal stable.
|
||||
private static UpdateCheckService newService(String token, String currentVersion) {
|
||||
BuildProperties bp = null;
|
||||
if (currentVersion != null) {
|
||||
Properties p = new Properties();
|
||||
p.setProperty("version", currentVersion);
|
||||
bp = new BuildProperties(p);
|
||||
}
|
||||
// licenseService null : la beta est testee separement, ces tests
|
||||
// couvrent uniquement le canal stable.
|
||||
return new UpdateCheckService(
|
||||
new RestTemplateBuilder(),
|
||||
"ghcr.io",
|
||||
"igmlcreation/loremind-core,igmlcreation/loremind-brain",
|
||||
"latest",
|
||||
"http://watchtower:8080",
|
||||
token,
|
||||
"",
|
||||
"latest",
|
||||
null
|
||||
null,
|
||||
bp
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Injecte un RestTemplate moque dans le service deja construit, et pose
|
||||
* directement les baselines pour eviter les vrais appels HTTP.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void setBaselines(UpdateCheckService svc, Map<String, String> baselines) {
|
||||
((Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests")).putAll(baselines);
|
||||
}
|
||||
|
||||
private static RestTemplate stubHttp(UpdateCheckService svc) {
|
||||
RestTemplate http = mock(RestTemplate.class);
|
||||
ReflectionTestUtils.setField(svc, "http", http);
|
||||
return http;
|
||||
}
|
||||
|
||||
private static void stubRemoteDigest(RestTemplate http, String image, String digest) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (digest != null) headers.add("Docker-Content-Digest", digest);
|
||||
ResponseEntity<Void> resp = new ResponseEntity<>(headers, org.springframework.http.HttpStatus.OK);
|
||||
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"),
|
||||
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class)))
|
||||
private static void stubTags(RestTemplate http, String image, List<String> tags) {
|
||||
TagsListResponse body = new TagsListResponse();
|
||||
body.name = image;
|
||||
body.tags = tags;
|
||||
ResponseEntity<TagsListResponse> resp = new ResponseEntity<>(body, HttpStatus.OK);
|
||||
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
|
||||
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
|
||||
.thenReturn(resp);
|
||||
}
|
||||
|
||||
private static void stubRemoteFailure(RestTemplate http, String image) {
|
||||
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"),
|
||||
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class)))
|
||||
private static void stubTagsFailure(RestTemplate http, String image) {
|
||||
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
|
||||
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
|
||||
.thenThrow(new RuntimeException("network down"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Comportement du service
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void disabledWhenTokenMissing() {
|
||||
UpdateCheckService svc = newService("");
|
||||
UpdateCheckService svc = newService("", "0.8.0");
|
||||
UpdateStatus status = svc.check();
|
||||
assertFalse(status.enabled());
|
||||
assertFalse(status.updateAvailable());
|
||||
@@ -90,118 +93,153 @@ public class UpdateCheckServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void upToDate_whenBaselineEqualsRemote() {
|
||||
UpdateCheckService svc = newService("token");
|
||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
||||
setBaselines(svc, Map.of(
|
||||
"igmlcreation/loremind-core", "sha256:aaa",
|
||||
"igmlcreation/loremind-brain", "sha256:bbb"
|
||||
));
|
||||
void upToDate_whenCurrentEqualsMaxRemote() {
|
||||
UpdateCheckService svc = newService("token", "0.8.0");
|
||||
RestTemplate http = stubHttp(svc);
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:aaa");
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
||||
stubTags(http, "igmlcreation/loremind-core",
|
||||
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
|
||||
stubTags(http, "igmlcreation/loremind-brain",
|
||||
List.of("0.7.0", "0.8.0", "latest"));
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
|
||||
assertTrue(status.enabled());
|
||||
assertFalse(status.updateAvailable());
|
||||
assertFalse(status.anyUnknown());
|
||||
assertEquals("0.8.0", status.currentVersion());
|
||||
for (ImageStatus img : status.images()) {
|
||||
assertEquals(ImageStatusKind.UP_TO_DATE, img.status());
|
||||
assertEquals("0.8.0", img.localVersion());
|
||||
assertEquals("0.8.0", img.remoteVersion());
|
||||
assertFalse(img.updateAvailable(), "back-compat bool");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateAvailable_whenRemoteDiffers() {
|
||||
UpdateCheckService svc = newService("token");
|
||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
||||
setBaselines(svc, Map.of(
|
||||
"igmlcreation/loremind-core", "sha256:OLD",
|
||||
"igmlcreation/loremind-brain", "sha256:bbb"
|
||||
));
|
||||
void updateAvailable_whenRemoteHigher() {
|
||||
UpdateCheckService svc = newService("token", "0.7.2");
|
||||
RestTemplate http = stubHttp(svc);
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW");
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
||||
stubTags(http, "igmlcreation/loremind-core",
|
||||
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
|
||||
stubTags(http, "igmlcreation/loremind-brain",
|
||||
List.of("0.7.2", "latest"));
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
|
||||
assertTrue(status.updateAvailable());
|
||||
assertFalse(status.anyUnknown());
|
||||
|
||||
ImageStatus core = status.images().stream()
|
||||
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
|
||||
assertEquals("0.7.2", core.localVersion());
|
||||
assertEquals("0.8.0", core.remoteVersion());
|
||||
assertTrue(core.updateAvailable(), "back-compat bool");
|
||||
|
||||
ImageStatus brain = status.images().stream()
|
||||
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
|
||||
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknown_whenBaselineMissing_DOES_NOT_lazyAlign() {
|
||||
// INVARIANT CENTRAL : si la baseline est absente (echec init au boot),
|
||||
// on NE DOIT PAS aligner lazy sur le remote courant — sinon une MAJ
|
||||
// pousse APRES le boot serait declaree "a jour" silencieusement.
|
||||
UpdateCheckService svc = newService("token");
|
||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
||||
// baseline DELIBEREMENT vide
|
||||
void unknown_whenRegistryFails() {
|
||||
UpdateCheckService svc = newService("token", "0.8.0");
|
||||
RestTemplate http = stubHttp(svc);
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:remote-now");
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:remote-now-2");
|
||||
stubTagsFailure(http, "igmlcreation/loremind-core");
|
||||
stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
|
||||
assertTrue(status.anyUnknown());
|
||||
ImageStatus core = status.images().stream()
|
||||
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
||||
assertNull(core.remoteVersion());
|
||||
assertEquals("0.8.0", core.localVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknown_whenNoValidSemverTags() {
|
||||
UpdateCheckService svc = newService("token", "0.8.0");
|
||||
RestTemplate http = stubHttp(svc);
|
||||
stubTags(http, "igmlcreation/loremind-core", List.of("latest", "stable", "main"));
|
||||
stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
|
||||
assertTrue(status.anyUnknown());
|
||||
ImageStatus core = status.images().stream()
|
||||
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
||||
assertNull(core.remoteVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknown_whenBuildPropertiesAbsent() {
|
||||
// INVARIANT : pas de version courante => tout UNKNOWN, jamais "a jour"
|
||||
// par defaut. Evite de declarer "a jour" un build dev sans build-info.
|
||||
UpdateCheckService svc = newService("token", null);
|
||||
RestTemplate http = stubHttp(svc);
|
||||
// Meme si on stub des tags, le service doit bypass et renvoyer UNKNOWN
|
||||
stubTags(http, "igmlcreation/loremind-core", List.of("0.8.0"));
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
|
||||
assertTrue(status.enabled());
|
||||
assertFalse(status.updateAvailable());
|
||||
assertTrue(status.anyUnknown());
|
||||
assertNull(status.currentVersion());
|
||||
for (ImageStatus img : status.images()) {
|
||||
assertEquals(ImageStatusKind.UNKNOWN, img.status());
|
||||
assertNull(img.localDigest());
|
||||
assertNotNull(img.remoteDigest()); // remote OK, baseline manquante
|
||||
}
|
||||
}
|
||||
|
||||
// VERIFICATION CRITIQUE : la baseline ne doit PAS avoir ete posee.
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> baselines = (Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests");
|
||||
assertTrue(baselines.isEmpty(),
|
||||
"check() ne doit JAMAIS aligner lazy la baseline sur le remote — "
|
||||
+ "regression de bug historique (faux negatif silencieux).");
|
||||
// -----------------------------------------------------------------
|
||||
// Utilitaires semver
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void parseSemver_acceptsCommonFormats() {
|
||||
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0"));
|
||||
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("v0.8.0"));
|
||||
assertArrayEquals(new int[]{1, 0, 0}, UpdateCheckService.parseSemver("1.0.0"));
|
||||
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0-beta.1"));
|
||||
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0+build.42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknown_whenRemoteFetchFails() {
|
||||
UpdateCheckService svc = newService("token");
|
||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
||||
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:aaa",
|
||||
"igmlcreation/loremind-brain", "sha256:bbb"));
|
||||
RestTemplate http = stubHttp(svc);
|
||||
stubRemoteFailure(http, "igmlcreation/loremind-core");
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
|
||||
assertFalse(status.updateAvailable());
|
||||
assertTrue(status.anyUnknown());
|
||||
ImageStatus core = status.images().stream()
|
||||
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
||||
assertNull(core.remoteDigest());
|
||||
assertEquals("sha256:aaa", core.localDigest()); // baseline preservee
|
||||
void parseSemver_rejectsInvalid() {
|
||||
assertNull(UpdateCheckService.parseSemver(null));
|
||||
assertNull(UpdateCheckService.parseSemver(""));
|
||||
assertNull(UpdateCheckService.parseSemver("latest"));
|
||||
assertNull(UpdateCheckService.parseSemver("stable"));
|
||||
assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
|
||||
assertNull(UpdateCheckService.parseSemver("0.x.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedStatuses_anyUnknownAndAnyUpdateBothTrue() {
|
||||
UpdateCheckService svc = newService("token");
|
||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
||||
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:OLD"));
|
||||
// brain n'a pas de baseline -> UNKNOWN
|
||||
RestTemplate http = stubHttp(svc);
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW");
|
||||
stubRemoteFailure(http, "igmlcreation/loremind-brain");
|
||||
void compareSemver_basic() {
|
||||
assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
|
||||
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
|
||||
assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
|
||||
assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
|
||||
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
|
||||
assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
|
||||
}
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
@Test
|
||||
void findMaxSemver_picksHighest() {
|
||||
assertEquals("0.8.0", UpdateCheckService.findMaxSemver(
|
||||
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
|
||||
assertEquals("0.10.0", UpdateCheckService.findMaxSemver(
|
||||
List.of("0.8.0", "0.10.0", "0.9.5")));
|
||||
assertEquals("v1.0.0", UpdateCheckService.findMaxSemver(
|
||||
List.of("v0.8.0", "v1.0.0", "latest")));
|
||||
}
|
||||
|
||||
assertTrue(status.updateAvailable(), "core a une MAJ disponible");
|
||||
assertTrue(status.anyUnknown(), "brain est UNKNOWN");
|
||||
@Test
|
||||
void findMaxSemver_returnsNullWhenNoValidTag() {
|
||||
assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main")));
|
||||
assertNull(UpdateCheckService.findMaxSemver(List.of()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
Auteur : ietm64
|
||||
Licence : AGPL-3.0
|
||||
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
||||
Version : 0.8.0
|
||||
Version : 0.8.1
|
||||
|
||||
.LINK
|
||||
https://github.com/IGMLcreation/LoreMind
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<app-update-banner></app-update-banner>
|
||||
|
||||
<div class="app-container">
|
||||
<app-sidebar></app-sidebar>
|
||||
|
||||
|
||||
@@ -4,13 +4,23 @@ import { RouterOutlet } from '@angular/router';
|
||||
import { SidebarComponent } from './sidebar/sidebar.component';
|
||||
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
||||
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
|
||||
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
|
||||
import { LayoutService } from './services/layout.service';
|
||||
import { GlobalSearchService } from './services/global-search.service';
|
||||
import { VersionCheckerService } from './services/version-checker.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, AsyncPipe, NgIf],
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
SidebarComponent,
|
||||
SecondarySidebarComponent,
|
||||
GlobalSearchComponent,
|
||||
UpdateBannerComponent,
|
||||
AsyncPipe,
|
||||
NgIf,
|
||||
],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
@@ -19,8 +29,14 @@ export class AppComponent {
|
||||
|
||||
constructor(
|
||||
private layoutService: LayoutService,
|
||||
private globalSearch: GlobalSearchService
|
||||
) {}
|
||||
private globalSearch: GlobalSearchService,
|
||||
versionChecker: VersionCheckerService,
|
||||
) {
|
||||
// Demarre la detection de mise a jour en arriere-plan.
|
||||
// Si une nouvelle version est deployee pendant la session, l'UpdateBanner
|
||||
// s'affichera automatiquement.
|
||||
versionChecker.start();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
|
||||
@@ -28,8 +28,8 @@ export interface BetaStatusDTO {
|
||||
anyUnknown: boolean;
|
||||
images: Array<{
|
||||
image: string;
|
||||
localDigest: string | null;
|
||||
remoteDigest: string | null;
|
||||
localVersion: string | null;
|
||||
remoteVersion: string | null;
|
||||
status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
|
||||
updateAvailable: boolean;
|
||||
}>;
|
||||
|
||||
@@ -3,19 +3,18 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Reflet de UpdateCheckService.UpdateStatus cote backend.
|
||||
* Reflet de UpdateCheckService.UpdateStatus cote backend (post-refactor v0.8.x).
|
||||
*
|
||||
* Etat tri-state par image : UP_TO_DATE / UPDATE_AVAILABLE / UNKNOWN.
|
||||
* UNKNOWN signale que la comparaison est impossible (baseline absente ou
|
||||
* remote injoignable) — l'UI doit afficher un avertissement plutot que
|
||||
* d'annoncer "a jour" silencieusement.
|
||||
* Comparaison faite par version semver (BuildProperties cote backend), plus
|
||||
* fiable que les digests qui ne survivaient pas a un restart sans pull.
|
||||
*/
|
||||
export type ImageStatusKind = 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
|
||||
|
||||
export interface ImageStatus {
|
||||
image: string;
|
||||
localDigest: string | null;
|
||||
remoteDigest: string | null;
|
||||
localVersion: string | null;
|
||||
remoteVersion: string | null;
|
||||
status: ImageStatusKind;
|
||||
/** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */
|
||||
updateAvailable: boolean;
|
||||
@@ -27,6 +26,8 @@ export interface UpdateStatus {
|
||||
updateAvailable: boolean;
|
||||
/** True si au moins une image a status === 'UNKNOWN'. */
|
||||
anyUnknown: boolean;
|
||||
/** Version courante du binaire (BuildProperties). null si build-info absent. */
|
||||
currentVersion: string | null;
|
||||
images: ImageStatus[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
94
web/src/app/services/version-checker.service.ts
Normal file
94
web/src/app/services/version-checker.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Detecte qu'une nouvelle version de LoreMind a ete deployee pendant qu'un
|
||||
* onglet utilisateur est deja ouvert.
|
||||
*
|
||||
* Strategie : polling toutes les {@link POLL_INTERVAL_MS} sur /api/version.
|
||||
* La version observee au boot est figee comme reference. Si la version polled
|
||||
* differe, {@link hasUpdate} passe a true et l'UI peut afficher un bandeau
|
||||
* proposant un reload.
|
||||
*
|
||||
* Pourquoi pas un Service Worker ? Trop lourd pour le besoin (offline pas
|
||||
* pertinent, lifecycle complexe). Polling simple = ~15 lignes, fait le job.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VersionCheckerService {
|
||||
private static readonly POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
private static readonly INITIAL_DELAY_MS = 30 * 1000; // 30s avant premier poll
|
||||
|
||||
private readonly _bootVersion = signal<string | null>(null);
|
||||
private readonly _remoteVersion = signal<string | null>(null);
|
||||
private readonly _hasUpdate = computed(
|
||||
() => {
|
||||
const boot = this._bootVersion();
|
||||
const remote = this._remoteVersion();
|
||||
return boot !== null && remote !== null && boot !== remote;
|
||||
}
|
||||
);
|
||||
|
||||
/** True quand la version remote differe de celle observee au boot. */
|
||||
readonly hasUpdate = this._hasUpdate;
|
||||
/** Version observee a l'ouverture du tab (figee). */
|
||||
readonly bootVersion = this._bootVersion.asReadonly();
|
||||
/** Derniere version observee sur le backend. */
|
||||
readonly remoteVersion = this._remoteVersion.asReadonly();
|
||||
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/**
|
||||
* Demarre le polling. A appeler une fois au boot de l'app.
|
||||
* Ignore si deja demarre (idempotent).
|
||||
*/
|
||||
start(): void {
|
||||
if (this.timer !== null) return;
|
||||
void this.fetchInitial();
|
||||
this.timer = setInterval(
|
||||
() => void this.poll(),
|
||||
VersionCheckerService.POLL_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recharge l'app (force re-fetch index.html + assets). */
|
||||
reload(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
private async fetchInitial(): Promise<void> {
|
||||
// Petit delai pour laisser le bootstrap se finir avant le premier appel.
|
||||
await new Promise(r => setTimeout(r, VersionCheckerService.INITIAL_DELAY_MS));
|
||||
const v = await this.fetchVersion();
|
||||
if (v && this._bootVersion() === null) {
|
||||
this._bootVersion.set(v);
|
||||
this._remoteVersion.set(v);
|
||||
}
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
const v = await this.fetchVersion();
|
||||
if (v) this._remoteVersion.set(v);
|
||||
}
|
||||
|
||||
private async fetchVersion(): Promise<string | null> {
|
||||
try {
|
||||
const resp = await firstValueFrom(
|
||||
this.http.get<{ version: string }>('/api/version'),
|
||||
);
|
||||
return resp?.version ?? null;
|
||||
} catch {
|
||||
// Backend down / restart en cours — on ignore, on retentera au prochain tick
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<div
|
||||
class="update-banner"
|
||||
*ngIf="versionChecker.hasUpdate() && !dismissed"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="banner-content">
|
||||
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
|
||||
<span>
|
||||
Une nouvelle version de LoreMind est disponible
|
||||
(<strong>{{ versionChecker.remoteVersion() }}</strong>).
|
||||
Recharge pour profiter des dernieres ameliorations.
|
||||
</span>
|
||||
</div>
|
||||
<div class="banner-actions">
|
||||
<button type="button" class="btn-reload" (click)="reload()">
|
||||
Recharger
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-dismiss"
|
||||
(click)="dismiss()"
|
||||
title="Fermer (sera reaffiche au prochain demarrage)"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<lucide-icon [img]="X" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
.update-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(90deg, #6d28d9, #7c3aed);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-reload {
|
||||
background: #fff;
|
||||
color: #6d28d9;
|
||||
border: 0;
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-dismiss {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decale le contenu de l'app vers le bas quand le bandeau est present
|
||||
// (bandeau fixed, ne pousse pas naturellement le layout).
|
||||
:host:has(.update-banner) {
|
||||
display: block;
|
||||
}
|
||||
34
web/src/app/shared/update-banner/update-banner.component.ts
Normal file
34
web/src/app/shared/update-banner/update-banner.component.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LucideAngularModule, RefreshCw, X } from 'lucide-angular';
|
||||
import { VersionCheckerService } from '../../services/version-checker.service';
|
||||
|
||||
/**
|
||||
* Bandeau global affiche en haut de l'app quand une nouvelle version de
|
||||
* LoreMind a ete deployee pendant que l'utilisateur avait deja l'onglet
|
||||
* ouvert. Propose un reload en un clic.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-update-banner',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
templateUrl: './update-banner.component.html',
|
||||
styleUrls: ['./update-banner.component.scss']
|
||||
})
|
||||
export class UpdateBannerComponent {
|
||||
readonly RefreshCw = RefreshCw;
|
||||
readonly X = X;
|
||||
|
||||
/** L'utilisateur a explicitement ferme le bandeau pour cette session. */
|
||||
dismissed = false;
|
||||
|
||||
constructor(public versionChecker: VersionCheckerService) {}
|
||||
|
||||
reload(): void {
|
||||
this.versionChecker.reload();
|
||||
}
|
||||
|
||||
dismiss(): void {
|
||||
this.dismissed = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user