Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m4s
Build & Push Images / build (core) (push) Successful in 1m31s
Build & Push Images / build (web) (push) Successful in 1m38s

Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
This commit is contained in:
2026-04-29 10:56:37 +02:00
parent 0f2d1b1efe
commit 4fe93b5ff3
11 changed files with 391 additions and 395 deletions

5
.gitignore vendored
View File

@@ -7,6 +7,11 @@
brain/data/settings.json brain/data/settings.json
*.key *.key
*.pem *.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 # Java / Spring Boot / Maven

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.8.0</version> <version>0.8.1</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -111,6 +111,16 @@
</exclude> </exclude>
</excludes> </excludes>
</configuration> </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> </plugin>
<!-- JaCoCo : rapport de couverture des tests unitaires. <!-- JaCoCo : rapport de couverture des tests unitaires.

View File

@@ -4,10 +4,10 @@ import com.loremind.application.licensing.LicenseService;
import com.loremind.domain.licensing.LicenseSnapshot; import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.domain.licensing.LicenseStatus; import com.loremind.domain.licensing.LicenseStatus;
import com.loremind.domain.licensing.RegistryCredentials; import com.loremind.domain.licensing.RegistryCredentials;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.info.BuildProperties;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -29,187 +29,121 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* Detection des mises a jour disponibles + declenchement via Watchtower. * 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 : * <b>Pourquoi pas les digests ?</b> Le bug historique etait : le baseline-digest
* - Au demarrage, on interroge le registry pour le digest courant de chaque * pose au @PostConstruct supposait que le pull venait d'avoir lieu (vrai apres
* image suivie ({@code update-check.images}). On stocke ces digests comme * `docker compose pull && up -d`, faux apres un simple restart de daemon ou un
* "baseline" (= ce que le conteneur en cours d'execution est cense faire * OOM). La version semver lue depuis le binaire est <b>fiable par construction</b> :
* tourner, puisque le `docker compose pull` precede toujours `up -d`). * c'est ce que le code source declare faire tourner.
* - 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.
*/ */
@Service @Service
public class UpdateCheckService { public class UpdateCheckService {
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class); 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 RestTemplate http;
private final String registry; private final String registry;
private final List<String> images; private final List<String> images;
private final String tag;
private final String watchtowerUrl; private final String watchtowerUrl;
private final String watchtowerToken; private final String watchtowerToken;
private final List<String> betaImages; private final List<String> betaImages;
private final String betaTag;
private final LicenseService licenseService; private final LicenseService licenseService;
/** Version semver courante du binaire (ex: "0.8.0"). Source de verite. */
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>(); private final String currentVersion;
public UpdateCheckService( public UpdateCheckService(
RestTemplateBuilder builder, RestTemplateBuilder builder,
@Value("${update-check.registry:}") String registry, @Value("${update-check.registry:}") String registry,
@Value("${update-check.images:}") String imagesCsv, @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-url:http://watchtower:8080}") String watchtowerUrl,
@Value("${update-check.watchtower-token:}") String watchtowerToken, @Value("${update-check.watchtower-token:}") String watchtowerToken,
@Value("${licensing.beta.images:}") String betaImagesCsv, @Value("${licensing.beta.images:}") String betaImagesCsv,
@Value("${licensing.beta.tag:latest}") String betaTag, LicenseService licenseService,
LicenseService licenseService) { @Nullable BuildProperties buildProperties) {
this.http = builder this.http = builder
.setConnectTimeout(Duration.ofSeconds(5)) .setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15)) .setReadTimeout(Duration.ofSeconds(15))
.build(); .build();
this.registry = normalizeRegistry(registry); this.registry = normalizeRegistry(registry);
this.images = parseImages(imagesCsv); this.images = parseImages(imagesCsv);
this.tag = tag;
this.watchtowerUrl = watchtowerUrl; this.watchtowerUrl = watchtowerUrl;
this.watchtowerToken = watchtowerToken; this.watchtowerToken = watchtowerToken;
this.betaImages = parseImages(betaImagesCsv); this.betaImages = parseImages(betaImagesCsv);
this.betaTag = betaTag;
this.licenseService = licenseService; this.licenseService = licenseService;
} this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
log.info("Update check init - registry={} images={} currentVersion={}",
/** Backoff progressif (ms) pour retry de baseline en cas d'echec initial. */ this.registry, this.images, this.currentVersion);
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();
} }
public boolean isEnabled() { public boolean isEnabled() {
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty(); 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() { public UpdateStatus check() {
if (!isEnabled()) { 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<>(); List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false; boolean anyUpdate = false;
boolean anyUnknown = false; boolean anyUnknown = false;
for (String image : images) { for (String image : images) {
String baseline = baselineDigests.get(image); String latest = null;
String remote = null;
try { try {
remote = fetchRemoteDigest(image); latest = fetchLatestSemverTag(registry, image, null);
} catch (Exception e) { } 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; ImageStatusKind kind;
if (baseline == null || remote == null) { if (latest == null) {
kind = ImageStatusKind.UNKNOWN; kind = ImageStatusKind.UNKNOWN;
anyUnknown = true; anyUnknown = true;
} else if (baseline.equals(remote)) { } else {
int cmp = compareSemver(currentVersion, latest);
if (cmp >= 0) {
kind = ImageStatusKind.UP_TO_DATE; kind = ImageStatusKind.UP_TO_DATE;
} else { } else {
kind = ImageStatusKind.UPDATE_AVAILABLE; kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true; anyUpdate = true;
} }
statuses.add(new ImageStatus(image, baseline, remote, kind));
} }
return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now()); statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
} }
/** /**
* Verifie l'etat du canal beta (images privees GHCR). * Verifie l'etat du canal beta (images privees GHCR) avec auth basique.
* 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)
*/ */
public BetaStatus checkBeta() { public BetaStatus checkBeta() {
if (!licenseService.isLicensingEnabled()) { if (!licenseService.isLicensingEnabled()) {
@@ -239,48 +173,156 @@ public class UpdateCheckService {
boolean anyUpdate = false; boolean anyUpdate = false;
boolean anyUnknown = false; boolean anyUnknown = false;
for (String image : betaImages) { for (String image : betaImages) {
String remote = null; String latest = null;
try { try {
remote = fetchRemoteDigestAuth(betaRegistry, image, betaTag, basicAuth); latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
} catch (Exception e) { } 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 ImageStatusKind kind;
// ne sait pas quelle version le user fait tourner. On expose juste le if (latest == null) {
// digest remote ; l'UI affichera "version disponible : <tag>" sans kind = ImageStatusKind.UNKNOWN;
// comparaison locale tant qu'il n'y a pas un mecanisme de baseline. anyUnknown = true;
ImageStatusKind kind = (remote == null) ? ImageStatusKind.UNKNOWN : ImageStatusKind.UPDATE_AVAILABLE; } else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
if (kind == ImageStatusKind.UNKNOWN) anyUnknown = true; kind = ImageStatusKind.UP_TO_DATE;
else anyUpdate = true; } else {
statuses.add(new ImageStatus(image, null, remote, kind)); kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true;
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
} }
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null); return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
} }
private String fetchRemoteDigestAuth(String registryUrl, String image, String tagName, String authHeader) { public void apply() {
String url = registryUrl + "/v2/" + image + "/manifests/" + tagName; if (!isEnabled()) {
HttpHeaders headers = new HttpHeaders(); throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
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);
} }
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") @SuppressWarnings("rawtypes")
private String obtainBearerTokenWithAuth(@Nullable String wwwAuth, String authHeader) { private String obtainBearerToken(@Nullable String wwwAuth, @Nullable String basicAuth) {
if (wwwAuth == null) return null; if (wwwAuth == null) return null;
String prefix = "Bearer "; String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null; if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
@@ -301,7 +343,9 @@ public class UpdateCheckService {
} }
try { try {
HttpHeaders headers = new HttpHeaders(); 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, ResponseEntity<Map> resp = http.exchange(url.toString(), HttpMethod.GET,
new HttpEntity<>(headers), Map.class); new HttpEntity<>(headers), Map.class);
Map<?, ?> body = resp.getBody(); Map<?, ?> body = resp.getBody();
@@ -309,97 +353,6 @@ public class UpdateCheckService {
Object t = body.get("token"); Object t = body.get("token");
if (t == null) t = body.get("access_token"); if (t == null) t = body.get("access_token");
return t == null ? null : t.toString(); 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) { } catch (Exception e) {
log.warn("Bearer token request failed: {}", e.getMessage()); log.warn("Bearer token request failed: {}", e.getMessage());
return null; 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 enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN }
public record UpdateStatus( public record UpdateStatus(
boolean enabled, boolean enabled,
boolean updateAvailable, boolean updateAvailable,
boolean anyUnknown, boolean anyUnknown,
String currentVersion,
List<ImageStatus> images, List<ImageStatus> images,
Instant checkedAt) {} Instant checkedAt) {}
/** /**
* Etat du canal beta. * Statut par image. {@code localVersion} = version embarquee dans le binaire ;
* <ul> * {@code remoteVersion} = plus haute version semver trouvee dans le registry.
* <li>{@code enabled} : true si le canal beta est actif et la licence valide.</li> * {@code updateAvailable} est derive de {@code status} (back-compat front).
* <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>
*/ */
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( public record BetaStatus(
boolean enabled, boolean enabled,
boolean updateAvailable, boolean updateAvailable,
@@ -499,20 +451,9 @@ public class UpdateCheckService {
} }
} }
/** /** DTO pour deserialisation Jackson de /v2/.../tags/list. */
* Le champ {@code updateAvailable} est conserve pour la compatibilite static class TagsListResponse {
* avec les anciens clients ; il est strictement derive de {@code status} public String name;
* dans le constructeur compact. public List<String> tags;
*/
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);
}
} }
} }

View File

@@ -65,7 +65,6 @@ app.demo-mode=${DEMO_MODE:false}
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide. # Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
update-check.registry=${UPDATE_CHECK_REGISTRY:} update-check.registry=${UPDATE_CHECK_REGISTRY:}
update-check.images=${UPDATE_CHECK_IMAGES:} 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-url=${WATCHTOWER_URL:http://watchtower:8080}
update-check.watchtower-token=${WATCHTOWER_TOKEN:} 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, # Image beta : si la licence est valide ET le toggle canal beta active,
# UpdateCheckService check ces images en plus du canal stable. # 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.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. # Chemin de sortie pour le docker config.json partage avec Watchtower.
# Volume Docker `docker-config` monte sur ce chemin dans Core, et sur # Volume Docker `docker-config` monte sur ce chemin dans Core, et sur

View File

@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEArbfaqBq54HJR1pKqliTShKrNIab32gpBwSTDw90I4wg=
-----END PUBLIC KEY-----

View File

@@ -1,17 +1,21 @@
package com.loremind.infrastructure.updates; package com.loremind.infrastructure.updates;
import com.loremind.application.licensing.LicenseService;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus; import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind; import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus; import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.info.BuildProperties;
import org.springframework.boot.web.client.RestTemplateBuilder; 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.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import java.util.Map; import java.util.List;
import java.util.concurrent.ConcurrentHashMap; import java.util.Properties;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -19,69 +23,68 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; 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 : * Couvre :
* - feature desactivee si token absent * - feature desactivee si WATCHTOWER_TOKEN absent
* - status UP_TO_DATE quand baseline == remote * - UP_TO_DATE quand version locale == max(tags remote)
* - status UPDATE_AVAILABLE quand baseline != remote * - UPDATE_AVAILABLE quand un tag plus eleve existe
* - status UNKNOWN quand baseline manque (PAS d'alignement lazy — invariant * - UNKNOWN quand le registry echoue
* central, regression historique) * - UNKNOWN quand BuildProperties est absent (currentVersion = null)
* - status UNKNOWN quand remote impossible a fetcher * - parseSemver / findMaxSemver / compareSemver utilitaires
* - drapeaux top-level updateAvailable / anyUnknown coherents
* - back-compat : champ updateAvailable sur ImageStatus = (status == UPDATE_AVAILABLE)
*/ */
public class UpdateCheckServiceTest { public class UpdateCheckServiceTest {
private static UpdateCheckService newService(String token) { private static UpdateCheckService newService(String token, String currentVersion) {
// licensing.* params left empty + LicenseService null : la feature beta est BuildProperties bp = null;
// desactivee dans ces tests, qui couvrent uniquement le canal stable. 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( return new UpdateCheckService(
new RestTemplateBuilder(), new RestTemplateBuilder(),
"ghcr.io", "ghcr.io",
"igmlcreation/loremind-core,igmlcreation/loremind-brain", "igmlcreation/loremind-core,igmlcreation/loremind-brain",
"latest",
"http://watchtower:8080", "http://watchtower:8080",
token, 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) { private static RestTemplate stubHttp(UpdateCheckService svc) {
RestTemplate http = mock(RestTemplate.class); RestTemplate http = mock(RestTemplate.class);
ReflectionTestUtils.setField(svc, "http", http); ReflectionTestUtils.setField(svc, "http", http);
return http; return http;
} }
private static void stubRemoteDigest(RestTemplate http, String image, String digest) { private static void stubTags(RestTemplate http, String image, List<String> tags) {
HttpHeaders headers = new HttpHeaders(); TagsListResponse body = new TagsListResponse();
if (digest != null) headers.add("Docker-Content-Digest", digest); body.name = image;
ResponseEntity<Void> resp = new ResponseEntity<>(headers, org.springframework.http.HttpStatus.OK); body.tags = tags;
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"), ResponseEntity<TagsListResponse> resp = new ResponseEntity<>(body, HttpStatus.OK);
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class))) when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
.thenReturn(resp); .thenReturn(resp);
} }
private static void stubRemoteFailure(RestTemplate http, String image) { private static void stubTagsFailure(RestTemplate http, String image) {
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"), when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class))) eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
.thenThrow(new RuntimeException("network down")); .thenThrow(new RuntimeException("network down"));
} }
// -----------------------------------------------------------------
// Comportement du service
// -----------------------------------------------------------------
@Test @Test
void disabledWhenTokenMissing() { void disabledWhenTokenMissing() {
UpdateCheckService svc = newService(""); UpdateCheckService svc = newService("", "0.8.0");
UpdateStatus status = svc.check(); UpdateStatus status = svc.check();
assertFalse(status.enabled()); assertFalse(status.enabled());
assertFalse(status.updateAvailable()); assertFalse(status.updateAvailable());
@@ -90,118 +93,153 @@ public class UpdateCheckServiceTest {
} }
@Test @Test
void upToDate_whenBaselineEqualsRemote() { void upToDate_whenCurrentEqualsMaxRemote() {
UpdateCheckService svc = newService("token"); UpdateCheckService svc = newService("token", "0.8.0");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
setBaselines(svc, Map.of(
"igmlcreation/loremind-core", "sha256:aaa",
"igmlcreation/loremind-brain", "sha256:bbb"
));
RestTemplate http = stubHttp(svc); RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:aaa"); stubTags(http, "igmlcreation/loremind-core",
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb"); 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(); UpdateStatus status = svc.check();
assertTrue(status.enabled()); assertTrue(status.enabled());
assertFalse(status.updateAvailable()); assertFalse(status.updateAvailable());
assertFalse(status.anyUnknown()); assertFalse(status.anyUnknown());
assertEquals("0.8.0", status.currentVersion());
for (ImageStatus img : status.images()) { for (ImageStatus img : status.images()) {
assertEquals(ImageStatusKind.UP_TO_DATE, img.status()); 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"); assertFalse(img.updateAvailable(), "back-compat bool");
} }
} }
@Test @Test
void updateAvailable_whenRemoteDiffers() { void updateAvailable_whenRemoteHigher() {
UpdateCheckService svc = newService("token"); UpdateCheckService svc = newService("token", "0.7.2");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
setBaselines(svc, Map.of(
"igmlcreation/loremind-core", "sha256:OLD",
"igmlcreation/loremind-brain", "sha256:bbb"
));
RestTemplate http = stubHttp(svc); RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW"); stubTags(http, "igmlcreation/loremind-core",
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb"); 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(); UpdateStatus status = svc.check();
assertTrue(status.updateAvailable()); assertTrue(status.updateAvailable());
assertFalse(status.anyUnknown()); assertFalse(status.anyUnknown());
ImageStatus core = status.images().stream() ImageStatus core = status.images().stream()
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow(); .filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status()); assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
assertEquals("0.7.2", core.localVersion());
assertEquals("0.8.0", core.remoteVersion());
assertTrue(core.updateAvailable(), "back-compat bool"); assertTrue(core.updateAvailable(), "back-compat bool");
ImageStatus brain = status.images().stream() ImageStatus brain = status.images().stream()
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow(); .filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status()); assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
} }
@Test @Test
void unknown_whenBaselineMissing_DOES_NOT_lazyAlign() { void unknown_whenRegistryFails() {
// INVARIANT CENTRAL : si la baseline est absente (echec init au boot), UpdateCheckService svc = newService("token", "0.8.0");
// 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
RestTemplate http = stubHttp(svc); RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:remote-now"); stubTagsFailure(http, "igmlcreation/loremind-core");
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:remote-now-2"); 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(); UpdateStatus status = svc.check();
assertTrue(status.enabled()); assertTrue(status.enabled());
assertFalse(status.updateAvailable()); assertFalse(status.updateAvailable());
assertTrue(status.anyUnknown()); assertTrue(status.anyUnknown());
assertNull(status.currentVersion());
for (ImageStatus img : status.images()) { for (ImageStatus img : status.images()) {
assertEquals(ImageStatusKind.UNKNOWN, img.status()); 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") // Utilitaires semver
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 — " @Test
+ "regression de bug historique (faux negatif silencieux)."); 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 @Test
void unknown_whenRemoteFetchFails() { void parseSemver_rejectsInvalid() {
UpdateCheckService svc = newService("token"); assertNull(UpdateCheckService.parseSemver(null));
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>()); assertNull(UpdateCheckService.parseSemver(""));
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:aaa", assertNull(UpdateCheckService.parseSemver("latest"));
"igmlcreation/loremind-brain", "sha256:bbb")); assertNull(UpdateCheckService.parseSemver("stable"));
RestTemplate http = stubHttp(svc); assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
stubRemoteFailure(http, "igmlcreation/loremind-core"); assertNull(UpdateCheckService.parseSemver("0.x.0"));
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
} }
@Test @Test
void mixedStatuses_anyUnknownAndAnyUpdateBothTrue() { void compareSemver_basic() {
UpdateCheckService svc = newService("token"); assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>()); assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:OLD")); assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
// brain n'a pas de baseline -> UNKNOWN assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
RestTemplate http = stubHttp(svc); assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW"); assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
stubRemoteFailure(http, "igmlcreation/loremind-brain"); }
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"); @Test
assertTrue(status.anyUnknown(), "brain est UNKNOWN"); void findMaxSemver_returnsNullWhenNoValidTag() {
assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main")));
assertNull(UpdateCheckService.findMaxSemver(List.of()));
} }
} }

View File

@@ -40,7 +40,7 @@
Auteur : ietm64 Auteur : ietm64
Licence : AGPL-3.0 Licence : AGPL-3.0
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
Version : 0.8.0 Version : 0.8.1
.LINK .LINK
https://github.com/IGMLcreation/LoreMind https://github.com/IGMLcreation/LoreMind

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.8.0", "version": "0.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.8.0", "version": "0.8.1",
"dependencies": { "dependencies": {
"@angular/animations": "^17.0.0", "@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0", "@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.8.0", "version": "0.8.1",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -28,8 +28,8 @@ export interface BetaStatusDTO {
anyUnknown: boolean; anyUnknown: boolean;
images: Array<{ images: Array<{
image: string; image: string;
localDigest: string | null; localVersion: string | null;
remoteDigest: string | null; remoteVersion: string | null;
status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN'; status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
updateAvailable: boolean; updateAvailable: boolean;
}>; }>;

View File

@@ -3,19 +3,18 @@ import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs'; 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. * Etat tri-state par image : UP_TO_DATE / UPDATE_AVAILABLE / UNKNOWN.
* UNKNOWN signale que la comparaison est impossible (baseline absente ou * Comparaison faite par version semver (BuildProperties cote backend), plus
* remote injoignable) — l'UI doit afficher un avertissement plutot que * fiable que les digests qui ne survivaient pas a un restart sans pull.
* d'annoncer "a jour" silencieusement.
*/ */
export type ImageStatusKind = 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN'; export type ImageStatusKind = 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
export interface ImageStatus { export interface ImageStatus {
image: string; image: string;
localDigest: string | null; localVersion: string | null;
remoteDigest: string | null; remoteVersion: string | null;
status: ImageStatusKind; status: ImageStatusKind;
/** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */ /** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */
updateAvailable: boolean; updateAvailable: boolean;
@@ -27,6 +26,8 @@ export interface UpdateStatus {
updateAvailable: boolean; updateAvailable: boolean;
/** True si au moins une image a status === 'UNKNOWN'. */ /** True si au moins une image a status === 'UNKNOWN'. */
anyUnknown: boolean; anyUnknown: boolean;
/** Version courante du binaire (BuildProperties). null si build-info absent. */
currentVersion: string | null;
images: ImageStatus[]; images: ImageStatus[];
checkedAt: string; checkedAt: string;
} }