Correction du soucis de mise à jour via l'application
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Successful in 1m36s

This commit is contained in:
2026-04-27 16:19:56 +02:00
parent 9ad7651c44
commit a708c74425
9 changed files with 315 additions and 20 deletions

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.7.0</version> <version>0.7.1</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -32,8 +32,13 @@ import java.util.concurrent.ConcurrentHashMap;
* image suivie ({@code update-check.images}). On stocke ces digests comme * image suivie ({@code update-check.images}). On stocke ces digests comme
* "baseline" (= ce que le conteneur en cours d'execution est cense faire * "baseline" (= ce que le conteneur en cours d'execution est cense faire
* tourner, puisque le `docker compose pull` precede toujours `up -d`). * 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 * - {@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 * - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token). * avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
* *
@@ -84,6 +89,9 @@ public class UpdateCheckService {
this.watchtowerToken = watchtowerToken; 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 @PostConstruct
void initBaseline() { void initBaseline() {
if (!isEnabled()) { if (!isEnabled()) {
@@ -91,7 +99,19 @@ public class UpdateCheckService {
return; return;
} }
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag); 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) { for (String image : images) {
if (baselineDigests.containsKey(image)) continue;
try { try {
String digest = fetchRemoteDigest(image); String digest = fetchRemoteDigest(image);
if (digest != null) { if (digest != null) {
@@ -102,6 +122,33 @@ public class UpdateCheckService {
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage()); 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() {
@@ -110,10 +157,11 @@ public class UpdateCheckService {
public UpdateStatus check() { public UpdateStatus check() {
if (!isEnabled()) { if (!isEnabled()) {
return new UpdateStatus(false, false, List.of(), Instant.now()); return new UpdateStatus(false, false, false, List.of(), Instant.now());
} }
List<ImageStatus> statuses = new ArrayList<>(); List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false; boolean anyUpdate = false;
boolean anyUnknown = false;
for (String image : images) { for (String image : images) {
String baseline = baselineDigests.get(image); String baseline = baselineDigests.get(image);
String remote = null; String remote = null;
@@ -122,17 +170,21 @@ public class UpdateCheckService {
} catch (Exception e) { } catch (Exception e) {
log.warn("Check failed for {}: {}", image, e.getMessage()); log.warn("Check failed for {}: {}", image, e.getMessage());
} }
// Si on n'a pas de baseline (echec au boot), on l'aligne maintenant // PAS d'alignement lazy si baseline absente : ce serait un faux negatif
// pour eviter un faux positif "MAJ dispo". // silencieux. On reporte UNKNOWN pour que l'UI le signale.
if (baseline == null && remote != null) { ImageStatusKind kind;
baselineDigests.put(image, remote); if (baseline == null || remote == null) {
baseline = remote; 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); statuses.add(new ImageStatus(image, baseline, remote, kind));
if (updateAvailable) anyUpdate = true;
statuses.add(new ImageStatus(image, baseline, remote, updateAvailable));
} }
return new UpdateStatus(true, anyUpdate, statuses, Instant.now()); return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now());
} }
public void apply() { public void apply() {
@@ -278,15 +330,38 @@ public class UpdateCheckService {
// Records de retour (sortis sous forme JSON par Jackson) // Records de retour (sortis sous forme JSON par Jackson)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/**
* 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( public record UpdateStatus(
boolean enabled, boolean enabled,
boolean updateAvailable, boolean updateAvailable,
boolean anyUnknown,
List<ImageStatus> images, List<ImageStatus> images,
Instant checkedAt) {} 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( public record ImageStatus(
String image, String image,
String localDigest, String localDigest,
String remoteDigest, 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);
}
}
} }

View File

@@ -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<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)))
.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<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).");
}
@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");
}
}

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.7.0 Version : 0.7.1
.LINK .LINK
https://github.com/IGMLcreation/LoreMind https://github.com/IGMLcreation/LoreMind

2
web/package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.7.0", "version": "0.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View File

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

View File

@@ -4,17 +4,29 @@ import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs';
/** /**
* Reflet de UpdateCheckService.UpdateStatus cote backend. * 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 { export interface ImageStatus {
image: string; image: string;
localDigest: string | null; localDigest: string | null;
remoteDigest: string | null; remoteDigest: string | null;
status: ImageStatusKind;
/** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */
updateAvailable: boolean; updateAvailable: boolean;
} }
export interface UpdateStatus { export interface UpdateStatus {
enabled: boolean; enabled: boolean;
/** True si au moins une image a status === 'UPDATE_AVAILABLE'. */
updateAvailable: boolean; updateAvailable: boolean;
/** True si au moins une image a status === 'UNKNOWN'. */
anyUnknown: boolean;
images: ImageStatus[]; images: ImageStatus[];
checkedAt: string; checkedAt: string;
} }

View File

@@ -244,16 +244,21 @@
<lucide-icon [img]="Download" [size]="16"></lucide-icon> <lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une mise a jour est disponible.</span> <span>Une mise a jour est disponible.</span>
</div> </div>
<div *ngIf="!updateStatus?.updateAvailable" class="hint"> <div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Verification impossible pour certaines images — voir details ci-dessous.</span>
</div>
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}). Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</div> </div>
<ul class="update-images" *ngIf="updateStatus?.images?.length"> <ul class="update-images" *ngIf="updateStatus?.images?.length">
<li *ngFor="let img of updateStatus?.images"> <li *ngFor="let img of updateStatus?.images">
<strong>{{ img.image }}</strong> <strong>{{ img.image }}</strong>
<span *ngIf="img.updateAvailable" class="badge-update">MAJ dispo</span> <span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">MAJ dispo</span>
<span *ngIf="!img.updateAvailable && img.remoteDigest" class="badge-ok">a jour</span> <span *ngIf="img.status === 'UP_TO_DATE'" class="badge-ok">a jour</span>
<span *ngIf="!img.remoteDigest" class="badge-warn">indisponible</span> <span *ngIf="img.status === 'UNKNOWN'" class="badge-warn"
title="Impossible de comparer (baseline absente ou registry injoignable)">verification impossible</span>
</li> </li>
</ul> </ul>

View File

@@ -303,6 +303,7 @@
} }
.alert-error { background: rgba(220, 80, 80, 0.15); color: #ff9b9b; } .alert-error { background: rgba(220, 80, 80, 0.15); color: #ff9b9b; }
.alert-success { background: rgba(80, 200, 120, 0.15); color: #a2e8b6; } .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 -------------------------------------- */ /* --- Slider fenetre de contexte -------------------------------------- */
.ctx-value { .ctx-value {