diff --git a/core/pom.xml b/core/pom.xml index 89ae07f..79d3f56 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.7.0 + 0.7.1 LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java index fde3406..a82e6bf 100644 --- a/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java +++ b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java @@ -32,8 +32,13 @@ import java.util.concurrent.ConcurrentHashMap; * 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. + * 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). * @@ -84,6 +89,9 @@ public class UpdateCheckService { this.watchtowerToken = watchtowerToken; } + /** 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()) { @@ -91,7 +99,19 @@ public class UpdateCheckService { 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) { @@ -102,6 +122,33 @@ public class UpdateCheckService { 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() { @@ -110,10 +157,11 @@ public class UpdateCheckService { public UpdateStatus check() { if (!isEnabled()) { - return new UpdateStatus(false, false, List.of(), Instant.now()); + return new UpdateStatus(false, false, false, List.of(), Instant.now()); } List statuses = new ArrayList<>(); boolean anyUpdate = false; + boolean anyUnknown = false; for (String image : images) { String baseline = baselineDigests.get(image); String remote = null; @@ -122,17 +170,21 @@ public class UpdateCheckService { } catch (Exception e) { log.warn("Check failed for {}: {}", image, e.getMessage()); } - // Si on n'a pas de baseline (echec au boot), on l'aligne maintenant - // pour eviter un faux positif "MAJ dispo". - if (baseline == null && remote != null) { - baselineDigests.put(image, remote); - baseline = remote; + // 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) { + kind = ImageStatusKind.UNKNOWN; + anyUnknown = true; + } else if (baseline.equals(remote)) { + kind = ImageStatusKind.UP_TO_DATE; + } else { + kind = ImageStatusKind.UPDATE_AVAILABLE; + anyUpdate = true; } - boolean updateAvailable = baseline != null && remote != null && !baseline.equals(remote); - if (updateAvailable) anyUpdate = true; - statuses.add(new ImageStatus(image, baseline, remote, updateAvailable)); + statuses.add(new ImageStatus(image, baseline, remote, kind)); } - return new UpdateStatus(true, anyUpdate, statuses, Instant.now()); + return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now()); } public void apply() { @@ -278,15 +330,38 @@ public class UpdateCheckService { // Records de retour (sortis sous forme JSON par Jackson) // ----------------------------------------------------------------------- + /** + * Etat tri-state d'une image vis-a-vis du registry. + * + */ + public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN } + public record UpdateStatus( boolean enabled, boolean updateAvailable, + boolean anyUnknown, List images, Instant checkedAt) {} + /** + * 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, - boolean updateAvailable) {} + ImageStatusKind status, + boolean updateAvailable) { + + public ImageStatus(String image, String localDigest, String remoteDigest, ImageStatusKind status) { + this(image, localDigest, remoteDigest, status, status == ImageStatusKind.UPDATE_AVAILABLE); + } + } } diff --git a/core/src/test/java/com/loremind/infrastructure/updates/UpdateCheckServiceTest.java b/core/src/test/java/com/loremind/infrastructure/updates/UpdateCheckServiceTest.java new file mode 100644 index 0000000..3dfcd2a --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/updates/UpdateCheckServiceTest.java @@ -0,0 +1,202 @@ +package com.loremind.infrastructure.updates; + +import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus; +import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind; +import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpHeaders; +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 static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour UpdateCheckService. + * + * 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) + */ +public class UpdateCheckServiceTest { + + private static UpdateCheckService newService(String token) { + return new UpdateCheckService( + new RestTemplateBuilder(), + "ghcr.io", + "igmlcreation/loremind-core,igmlcreation/loremind-brain", + "latest", + "http://watchtower:8080", + token + ); + } + + /** + * 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 baselines) { + ((Map) 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 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))) + .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))) + .thenThrow(new RuntimeException("network down")); + } + + @Test + void disabledWhenTokenMissing() { + UpdateCheckService svc = newService(""); + UpdateStatus status = svc.check(); + assertFalse(status.enabled()); + assertFalse(status.updateAvailable()); + assertFalse(status.anyUnknown()); + assertTrue(status.images().isEmpty()); + } + + @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" + )); + RestTemplate http = stubHttp(svc); + stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:aaa"); + stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb"); + + UpdateStatus status = svc.check(); + + assertTrue(status.enabled()); + assertFalse(status.updateAvailable()); + assertFalse(status.anyUnknown()); + for (ImageStatus img : status.images()) { + assertEquals(ImageStatusKind.UP_TO_DATE, img.status()); + 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" + )); + RestTemplate http = stubHttp(svc); + stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW"); + stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb"); + + 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()); + 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 + RestTemplate http = stubHttp(svc); + stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:remote-now"); + stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:remote-now-2"); + + UpdateStatus status = svc.check(); + + assertTrue(status.enabled()); + assertFalse(status.updateAvailable()); + assertTrue(status.anyUnknown()); + 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 baselines = (Map) 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)."); + } + + @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 + } + + @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"); + + UpdateStatus status = svc.check(); + + assertTrue(status.updateAvailable(), "core a une MAJ disponible"); + assertTrue(status.anyUnknown(), "brain est UNKNOWN"); + } +} diff --git a/installers/install.ps1 b/installers/install.ps1 index f5ee86e..cbafff7 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -40,7 +40,7 @@ Auteur : ietm64 Licence : AGPL-3.0 Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR - Version : 0.7.0 + Version : 0.7.1 .LINK https://github.com/IGMLcreation/LoreMind diff --git a/web/package-lock.json b/web/package-lock.json index c42a612..4cad094 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/web/package.json b/web/package.json index 35f7313..cbfefdd 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.7.0", + "version": "0.7.1", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/services/updates.service.ts b/web/src/app/services/updates.service.ts index b3121e7..97cf22f 100644 --- a/web/src/app/services/updates.service.ts +++ b/web/src/app/services/updates.service.ts @@ -4,17 +4,29 @@ import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs'; /** * Reflet de UpdateCheckService.UpdateStatus cote backend. + * + * 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. */ +export type ImageStatusKind = 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN'; + export interface ImageStatus { image: string; localDigest: string | null; remoteDigest: string | null; + status: ImageStatusKind; + /** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */ updateAvailable: boolean; } export interface UpdateStatus { enabled: boolean; + /** True si au moins une image a status === 'UPDATE_AVAILABLE'. */ updateAvailable: boolean; + /** True si au moins une image a status === 'UNKNOWN'. */ + anyUnknown: boolean; images: ImageStatus[]; checkedAt: string; } diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html index 1f53e79..dc4d4e6 100644 --- a/web/src/app/settings/settings.component.html +++ b/web/src/app/settings/settings.component.html @@ -244,16 +244,21 @@ Une mise a jour est disponible. -
+
+ + Verification impossible pour certaines images — voir details ci-dessous. +
+
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
  • {{ img.image }} - MAJ dispo - a jour - indisponible + MAJ dispo + a jour + verification impossible
diff --git a/web/src/app/settings/settings.component.scss b/web/src/app/settings/settings.component.scss index 27b7cf2..4af27b9 100644 --- a/web/src/app/settings/settings.component.scss +++ b/web/src/app/settings/settings.component.scss @@ -303,6 +303,7 @@ } .alert-error { background: rgba(220, 80, 80, 0.15); color: #ff9b9b; } .alert-success { background: rgba(80, 200, 120, 0.15); color: #a2e8b6; } +.alert-warn { background: rgba(245, 158, 11, 0.15); color: #fbbf24; } /* --- Slider fenetre de contexte -------------------------------------- */ .ctx-value {