From a43571e84af907ddeeb91edf2acf0e841d2216f7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 17 Sep 2026 08:10:51 +0000
Subject: [PATCH 3/6] =?UTF-8?q?Le=20suivi=20des=20mises=20=C3=A0=20jour=20?=
=?UTF-8?q?mesure=20l'image=20publi=C3=A9e,=20et=20le=20bouton=20dit=20ce?=
=?UTF-8?q?=20qu'il=20a=20fait?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
L'écran comparait la révision installée au dernier commit de `main` sur
GitHub. Or ce que l'`updater` installe, c'est une image publiée sur le
registre, pas un commit. Les deux diffèrent pendant les dix minutes de
construction — l'écran annonçait alors une mise à jour que le bouton ne
pouvait pas installer — et pour toujours si la publication échouait.
La vérification interroge donc le registre, par le protocole Registry v2 que
parlent GHCR, le Docker Hub et les registres auto-hébergés : une requête, un
défi d'authentification, un jeton anonyme pour les images publiques. La
révision lue est celle que l'`updater` installerait, à la seconde près.
Ce déplacement corrige un défaut visible en production : l'API publique de
GitHub n'accorde que 60 requêtes par heure et par adresse IP, quota partagé
avec tout ce qui sort de la même machine. Une fois épuisé — ce qui est arrivé
pendant la vérification de ce correctif — l'écran affichait « État inconnu »,
n'annonçait plus aucune version, et faisait disparaître le bouton
d'installation pendant une heure. Une association deux versions en retard
n'avait alors aucun moyen de l'apprendre ni d'y remédier. Le dépôt Git reste
consulté, mais en appoint : il ne sert plus qu'à signaler une version
fusionnée dont l'image n'est pas encore publiée, et son échec ne retire plus
rien à l'écran.
Le bouton est désormais offert dès que l'`updater` peut être sollicité, et
non plus seulement quand une mise à jour est connue : c'est précisément quand
l'état est incertain qu'on veut pouvoir forcer un contrôle.
Il rend compte de ce qui s'est réellement passé. La requête de déclenchement
meurt avec le conteneur qu'elle fait remplacer : elle ne peut rien rapporter.
L'écran interroge donc l'application jusqu'à son retour, puis compare les
révisions — installée, inchangée, ou pas revenue au bout de quatre minutes.
Au passage, une coupure en plein vol n'est plus reconnue au seul délai
dépassé : une connexion réinitialisée ou fermée net est le cas le plus
fréquent, et elle était rapportée comme « service injoignable » alors que la
mise à jour venait de partir.
Enfin, « Mise à jour automatique : activée » n'était qu'une relecture de la
configuration. Une sonde interroge l'`updater` : s'il ne répond pas, l'écran
le dit et donne la commande qui le relève, au lieu de promettre une mise à
jour automatique qui n'arriverait jamais. Le dialogue de confirmation rappelle
le repère de la version en place, pour qu'un retour en arrière reste possible
après coup.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD
---
compose.yaml | 7 +
src/components/update-status-card.tsx | 451 +++++++++++++++++++-------
src/lib/services/registry.ts | 276 ++++++++++++++++
src/lib/services/updates.ts | 255 ++++++++++-----
4 files changed, 802 insertions(+), 187 deletions(-)
create mode 100644 src/lib/services/registry.ts
diff --git a/compose.yaml b/compose.yaml
index 33fd298..893cc86 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -34,6 +34,13 @@ x-app-environment: &app-environment
UPDATE_REPOSITORY: ${UPDATE_REPOSITORY:-flocom/APEL-manager}
UPDATE_CHANNEL: ${UPDATE_CHANNEL:-main}
UPDATE_CHECK_ENABLED: ${UPDATE_CHECK_ENABLED:-true}
+ # L'image réellement déployée, celle que l'`updater` surveille. C'est elle que
+ # l'application interroge dans le registre pour savoir s'il y a quelque chose
+ # à installer : comparer au dernier commit du dépôt reviendrait à annoncer une
+ # mise à jour pendant les dix minutes de construction, et indéfiniment si la
+ # publication échoue. Même valeur par défaut que la clé `image` des services,
+ # pour qu'on ne puisse pas surveiller une autre image que celle qui tourne.
+ APEL_IMAGE: ${APEL_IMAGE:-ghcr.io/flocom/apel-manager:latest}
services:
db:
diff --git a/src/components/update-status-card.tsx b/src/components/update-status-card.tsx
index ba44237..beee516 100644
--- a/src/components/update-status-card.tsx
+++ b/src/components/update-status-card.tsx
@@ -2,13 +2,15 @@
import {
CircleAlert,
+ Clock,
DownloadCloud,
+ PlugZap,
RefreshCw,
RotateCw,
Rocket,
ShieldCheck,
} from "lucide-react";
-import { useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { useToast } from "@/components/toast";
@@ -16,6 +18,10 @@ import { Badge, Button, Card } from "@/components/ui";
import { formatLongDateTime } from "@/lib/dates";
import type { UpdateStatus } from "@/lib/services/updates";
+/** Durée pendant laquelle on attend le retour de l'application après un déclenchement. */
+const SURVEILLANCE_MS = 4 * 60 * 1000;
+const INTERVALLE_MS = 3000;
+
function formatDate(value: string | null) {
if (!value) return null;
const date = new Date(value);
@@ -64,23 +70,74 @@ function Line({ label, value }: { label: string; value: string }) {
);
}
+/** Encart d'information, neutre ou d'alerte selon le ton. */
+function Encart({
+ ton,
+ icone: Icone,
+ titre,
+ children,
+}: {
+ ton: "neutre" | "attention" | "alerte";
+ icone: typeof CircleAlert;
+ titre: string;
+ children: React.ReactNode;
+}) {
+ const teintes = {
+ neutre: "border-slate-200 bg-slate-50 text-slate-500",
+ attention: "border-amber-200 bg-amber-50 text-amber-700",
+ alerte: "border-coral-300 bg-coral-50 text-coral-700",
+ }[ton];
+ const [bordure, fond, encre] = teintes.split(" ");
+ return (
+
+
+
+
{titre}
+
{children}
+
+
+ );
+}
+
export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
const toast = useToast();
const [current, setCurrent] = useState(status);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [confirmApply, setConfirmApply] = useState(false);
+ /** Compte rendu de ce qui s'est réellement passé après un déclenchement. */
+ const [suivi, setSuivi] = useState<
+ | null
+ | { etat: "attente"; message: string }
+ | { etat: "installee" | "inchangee" | "perdue"; message: string }
+ >(null);
+ const vivant = useRef(true);
+
+ useEffect(() => {
+ vivant.current = true;
+ return () => {
+ vivant.current = false;
+ };
+ }, []);
+
+ // L'écran est rendu côté serveur : quand la page se rafraîchit, la prop
+ // apporte un état plus récent que celui gardé en mémoire ici.
+ useEffect(() => {
+ setCurrent(status);
+ }, [status]);
+
+ const recharger = useCallback(async () => {
+ const response = await fetch("/api/updates?refresh=1", {
+ cache: "no-store",
+ });
+ if (!response.ok) throw new Error(`réponse ${response.status}`);
+ return (await response.json()) as UpdateStatus;
+ }, []);
async function check() {
setChecking(true);
try {
- const response = await fetch("/api/updates?refresh=1", {
- cache: "no-store",
- });
- if (!response.ok) {
- throw new Error("Vérification impossible.");
- }
- const refreshed = (await response.json()) as UpdateStatus;
+ const refreshed = await recharger();
setCurrent(refreshed);
// Un contrôle qui n'a pas abouti ne doit pas s'annoncer comme terminé :
// l'état affiché vient alors de la dernière réponse connue.
@@ -103,52 +160,131 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
}
}
- const REDEMARRAGE =
- "Mise à jour lancée. L’application redémarre, rechargez la page dans un instant.";
+ /**
+ * Après un déclenchement, on ne devine plus : on regarde. L'application est
+ * interrogée jusqu'à ce qu'elle réponde de nouveau, puis sa révision est
+ * comparée à celle d'avant. C'est la seule façon de distinguer « installée »
+ * de « rien n'a bougé » — la requête de déclenchement, elle, meurt avec le
+ * conteneur qu'elle vient de faire remplacer.
+ */
+ const surveiller = useCallback(
+ async (revisionAvant: string) => {
+ const limite = Date.now() + SURVEILLANCE_MS;
+ let tombee = false;
+
+ setSuivi({
+ etat: "attente",
+ message:
+ "Mise à jour lancée. L’application redémarre : cet écran vous dira ce qui a été installé.",
+ });
+
+ while (Date.now() < limite) {
+ await new Promise((r) => setTimeout(r, INTERVALLE_MS));
+ if (!vivant.current) return;
+ try {
+ const frais = await recharger();
+ if (!vivant.current) return;
+ setCurrent(frais);
+
+ if (frais.current.revision !== revisionAvant) {
+ setSuivi({
+ etat: "installee",
+ message: `Mise à jour installée : l’application tourne maintenant en ${frais.current.version} (${frais.current.shortRevision}).`,
+ });
+ return;
+ }
+ if (tombee) {
+ setSuivi({
+ etat: "inchangee",
+ message:
+ "L’application est revenue, toujours dans la même version : le service de mise à jour n’avait rien de plus récent à installer.",
+ });
+ return;
+ }
+ } catch {
+ // L'application ne répond plus : c'est le remplacement en cours.
+ tombee = true;
+ if (vivant.current) {
+ setSuivi({
+ etat: "attente",
+ message:
+ "L’application redémarre… cet écran se met à jour tout seul dès qu’elle répond.",
+ });
+ }
+ }
+ }
+
+ if (!vivant.current) return;
+ setSuivi({
+ etat: tombee ? "perdue" : "inchangee",
+ message: tombee
+ ? "L’application n’est pas revenue au bout de quatre minutes. Vérifiez sur le serveur : « docker compose ps » puis « docker compose logs app »."
+ : "Le service de mise à jour n’a rien installé : aucune version plus récente n’est publiée.",
+ });
+ },
+ [recharger],
+ );
async function applyNow() {
setConfirmApply(false);
setApplying(true);
+ const revisionAvant = current.current.revision;
try {
const response = await fetch("/api/updates/apply", { method: "POST" });
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as {
error?: string;
} | null;
- if (payload?.error) throw new Error(payload.error);
- // Réponse sans explication : c'est le proxy qui parle, pas
- // l'application. Une passerelle en défaut pendant que le conteneur est
- // justement remplacé annonce la mise à jour, elle ne l'infirme pas.
- if (response.status >= 502 && response.status <= 504) {
- toast(REDEMARRAGE);
+ // Réponse sans explication entre 502 et 504 : c'est le proxy qui parle,
+ // pas l'application. Une passerelle en défaut pendant que le conteneur
+ // est justement remplacé annonce la mise à jour, elle ne l'infirme pas.
+ if (!payload?.error && response.status >= 502 && response.status <= 504) {
+ await surveiller(revisionAvant);
return;
}
throw new Error(
- `Mise à jour impossible (réponse ${response.status} du serveur).`,
+ payload?.error ??
+ `Mise à jour impossible (réponse ${response.status} du serveur).`,
);
}
const { outcome } = (await response.json()) as {
- outcome: "started" | "restarting";
+ outcome: "no-update" | "restarting";
};
- toast(
- outcome === "restarting"
- ? REDEMARRAGE
- : "Contrôle effectué : aucune version plus récente à installer.",
- );
+ if (outcome === "restarting") {
+ await surveiller(revisionAvant);
+ return;
+ }
+ // L'updater a répondu sans nous interrompre : rien n'était à installer.
+ // On revérifie tout de même, la version publiée a pu changer depuis.
+ setSuivi({
+ etat: "inchangee",
+ message:
+ "Contrôle effectué : le service de mise à jour n’a trouvé aucune version plus récente à installer.",
+ });
+ try {
+ setCurrent(await recharger());
+ } catch {
+ // Sans conséquence : l'écran garde l'état qu'il avait.
+ }
} catch (error) {
// Le conteneur peut disparaître avant de répondre : la requête échoue
// alors côté navigateur alors que la mise à jour est bel et bien partie.
- toast(
- error instanceof TypeError ? REDEMARRAGE : (error as Error).message,
- error instanceof TypeError ? "success" : "error",
- );
+ if (error instanceof TypeError) {
+ await surveiller(revisionAvant);
+ return;
+ }
+ setSuivi(null);
+ toast((error as Error).message, "error");
} finally {
- setApplying(false);
+ if (vivant.current) setApplying(false);
}
}
const buildTime = formatDate(current.current.buildTime);
const checkedAt = formatDate(current.checkedAt);
+ const { autoUpdate } = current;
+ const injoignable =
+ autoUpdate.enabled && autoUpdate.reachability === "unreachable";
return (
@@ -171,22 +307,15 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
{current.current.development && (
-
-
-
-
- Exécution hors image publiée
-
-
- Aucune version n'est estampillée : la comparaison avec la
- version publiée n'est pas possible. C'est le cas en
- développement local ou après une construction manuelle.
-
-
-
+
+ Aucune version n'est estampillée : la comparaison avec la
+ version publiée n'est pas possible. C'est le cas en
+ développement local ou après une construction manuelle.
+
)}
@@ -198,72 +327,128 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
{current.latest && (
)}
+ {/* Ce que le déclenchement a réellement produit : constaté, pas supposé. */}
+ {suivi && (
+
+
{suivi.message}
+ {suivi.etat === "installee" && (
+
+ )}
+
+ )}
+
+ {injoignable && (
+
+ Rien ne sera installé automatiquement tant qu'il est arrêté.
+ Sur le serveur :{" "}
+
+ docker compose ps updater
+ {" "}
+ puis{" "}
+
+ docker compose up -d updater
+
+ .
+
+ )}
+
{current.state === "outdated" && current.latest && (
-
-
-
-
- Une version plus récente est publiée
-
-
- {current.autoUpdate.enabled
- ? `Elle sera installée automatiquement lors du prochain contrôle (${formatInterval(
- current.autoUpdate.pollIntervalSeconds,
- )}), sans intervention.`
- : "La mise à jour automatique est désactivée : lancez « docker compose pull && docker compose up -d » sur le serveur."}
-
- L’installation immédiate n’est pas disponible : le jeton
- qui autorise cette page à demander un redémarrage au
- service de mise à jour n’a pas encore été généré. Il l’est
- tout seul au démarrage ; relancez la pile sur le serveur
- avec{" "}
-
- docker compose pull && docker compose up -d
-
- .
-
- )}
-
-
+
+ {autoUpdate.enabled && !injoignable
+ ? `Elle sera installée automatiquement lors du prochain contrôle (${formatInterval(
+ autoUpdate.pollIntervalSeconds,
+ )}), sans intervention.`
+ : "La mise à jour automatique n’assure pas l’installation : lancez « docker compose pull && docker compose up -d » sur le serveur."}
+
+ )}
+
+ {/* Fusionné mais pas encore publié : ni « à jour » ni installable. */}
+ {current.pending && (
+
+ Le commit{" "}
+
+ {current.pending.shortRevision}
+ {" "}
+ est fusionné mais son image n'est pas encore publiée. La
+ construction dure une dizaine de minutes ; au-delà, vérifiez qu'elle
+ n'a pas échoué.
+
)}
{current.error && (
-
+ {/* Disponible dès que l'updater peut être sollicité : c'est
+ précisément quand l'état est inconnu qu'on veut pouvoir forcer
+ le contrôle à la main. */}
+ {autoUpdate.canTriggerNow && (
+
+ )}
+ {autoUpdate.enabled && !autoUpdate.canTriggerNow && (
+
+ L’installation immédiate n’est pas disponible : le jeton qui
+ autorise cette page à demander un redémarrage au service de mise
+ à jour n’a pas encore été généré. Il l’est tout seul au
+ démarrage ; relancez la pile sur le serveur avec{" "}
+
+ docker compose pull && docker compose up -d
+
+ .
+
+ )}
+
+
setConfirmApply(false)}
diff --git a/src/lib/services/registry.ts b/src/lib/services/registry.ts
new file mode 100644
index 0000000..c35a350
--- /dev/null
+++ b/src/lib/services/registry.ts
@@ -0,0 +1,276 @@
+import "server-only";
+
+/**
+ * Lecture de l'image publiée dans le registre.
+ *
+ * C'est l'image — pas le dernier commit — que l'`updater` installe. Interroger
+ * GitHub revenait à mesurer autre chose que ce qui allait se produire : entre
+ * la fusion et la fin de la construction multi-architecture il s'écoule une
+ * dizaine de minutes pendant lesquelles l'écran annonçait une mise à jour que
+ * personne ne pouvait installer, et si la construction échouait, il l'annonçait
+ * indéfiniment.
+ *
+ * Le protocole est celui de l'API Registry v2, telle que la parlent GHCR, le
+ * Docker Hub et les registres auto-hébergés : une première requête sans jeton,
+ * un en-tête `WWW-Authenticate` qui indique où en demander un, puis la même
+ * requête avec. Les images publiques n'exigent aucun identifiant.
+ */
+
+const MANIFEST_TYPES = [
+ "application/vnd.oci.image.index.v1+json",
+ "application/vnd.docker.distribution.manifest.list.v2+json",
+ "application/vnd.oci.image.manifest.v1+json",
+ "application/vnd.docker.distribution.manifest.v2+json",
+].join(", ");
+
+const REQUEST_TIMEOUT_MS = 8000;
+
+/** Le registre auquel s'adresse le Docker Hub quand aucun hôte n'est indiqué. */
+const DOCKER_HUB = "registry-1.docker.io";
+
+export interface PublishedImage {
+ /** SHA du commit ayant produit l'image, tel que gravé par la publication. */
+ revision: string;
+ /** Nom de version lisible (« main-34 »), quand l'image en porte un. */
+ version: string | null;
+ /** Date de construction déclarée par l'image. */
+ createdAt: string | null;
+ reference: string;
+}
+
+export type RegistryCheck =
+ | { ok: true; image: PublishedImage }
+ | { ok: false; error: string };
+
+interface ImageReference {
+ registry: string;
+ repository: string;
+ tag: string;
+}
+
+/**
+ * Découpe « ghcr.io/flocom/apel-manager:latest ».
+ *
+ * Un premier segment n'est un hôte que s'il contient un point, deux-points, ou
+ * vaut « localhost » : sans cette règle, « flocom/apel-manager » verrait
+ * « flocom » pris pour un registre. C'est la convention de Docker.
+ */
+export function parseImageReference(reference: string): ImageReference | null {
+ const brut = reference.trim();
+ if (!brut || brut.includes("@")) return null;
+
+ const segments = brut.split("/");
+ const premier = segments[0];
+ const aUnHote =
+ segments.length > 1 &&
+ (premier.includes(".") || premier.includes(":") || premier === "localhost");
+
+ const registry = aUnHote ? premier : DOCKER_HUB;
+ let chemin = aUnHote ? segments.slice(1).join("/") : brut;
+ // Le Docker Hub range les images sans espace de noms sous « library ».
+ if (!aUnHote && !chemin.includes("/")) chemin = `library/${chemin}`;
+
+ const separateur = chemin.lastIndexOf(":");
+ const tag = separateur === -1 ? "latest" : chemin.slice(separateur + 1);
+ const repository = separateur === -1 ? chemin : chemin.slice(0, separateur);
+
+ if (!repository || !tag) return null;
+ return { registry, repository, tag };
+}
+
+/** Analyse l'en-tête `WWW-Authenticate` d'un registre v2. */
+function defiAuthentification(header: string | null) {
+ if (!header || !/^bearer/i.test(header)) return null;
+ const champs = Object.fromEntries(
+ [...header.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]),
+ );
+ return champs.realm ? champs : null;
+}
+
+export class RegistryClient {
+ private token: string | null = null;
+
+ constructor(private readonly image: ImageReference) {}
+
+ private get base() {
+ return `https://${this.image.registry}/v2/${this.image.repository}`;
+ }
+
+ /**
+ * Requête authentifiée si nécessaire. Le jeton n'est demandé qu'après un
+ * premier refus, ce qui laisse fonctionner les registres qui n'en réclament
+ * pas, et il n'est demandé qu'une fois par vérification.
+ */
+ private async get(url: string, accept?: string): Promise {
+ const envoyer = () => {
+ const headers: Record = {
+ "User-Agent": "apel-manager-update-check",
+ };
+ if (accept) headers.Accept = accept;
+ if (this.token) headers.Authorization = `Bearer ${this.token}`;
+ return fetch(url, {
+ headers,
+ cache: "no-store",
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ };
+
+ const premiere = await envoyer();
+ if (premiere.status !== 401) return premiere;
+
+ const defi = defiAuthentification(premiere.headers.get("www-authenticate"));
+ if (!defi) return premiere;
+
+ const demande = new URL(defi.realm);
+ if (defi.service) demande.searchParams.set("service", defi.service);
+ demande.searchParams.set(
+ "scope",
+ defi.scope ?? `repository:${this.image.repository}:pull`,
+ );
+
+ const reponse = await fetch(demande, {
+ headers: { Accept: "application/json" },
+ cache: "no-store",
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ if (!reponse.ok) return premiere;
+
+ const charge = (await reponse.json()) as {
+ token?: string;
+ access_token?: string;
+ };
+ this.token = charge.token ?? charge.access_token ?? null;
+ if (!this.token) return premiere;
+
+ return envoyer();
+ }
+
+ /**
+ * Résout le manifeste d'une plateforme concrète. Une image multi-architecture
+ * publie un index dont les entrées d'attestation annoncent une plateforme
+ * « unknown » : les retenir mènerait à un blob qui n'est pas une
+ * configuration d'image.
+ */
+ private async manifestePlateforme(corps: {
+ manifests?: {
+ digest: string;
+ platform?: { architecture?: string; os?: string };
+ }[];
+ config?: { digest: string };
+ }) {
+ if (corps.config) return corps;
+ const entrees = (corps.manifests ?? []).filter(
+ (m) =>
+ m.platform?.architecture &&
+ m.platform.architecture !== "unknown" &&
+ m.platform.os !== "unknown",
+ );
+ if (entrees.length === 0) return null;
+ const choisi =
+ entrees.find(
+ (m) =>
+ m.platform?.os === "linux" && m.platform?.architecture === "amd64",
+ ) ?? entrees[0];
+
+ const reponse = await this.get(
+ `${this.base}/manifests/${choisi.digest}`,
+ MANIFEST_TYPES,
+ );
+ if (!reponse.ok) return null;
+ return (await reponse.json()) as { config?: { digest: string } };
+ }
+
+ async lireImagePubliee(): Promise {
+ const reponse = await this.get(
+ `${this.base}/manifests/${this.image.tag}`,
+ MANIFEST_TYPES,
+ );
+ if (!reponse.ok) {
+ throw new Error(`le registre a répondu ${reponse.status}`);
+ }
+
+ const manifeste = await this.manifestePlateforme(await reponse.json());
+ if (!manifeste?.config) return null;
+
+ // Le blob de configuration part vers un stockage signé : `fetch` suit la
+ // redirection et retire l'en-tête d'autorisation, ce qui est exactement ce
+ // qu'attend l'URL signée.
+ const config = await this.get(`${this.base}/blobs/${manifeste.config.digest}`);
+ if (!config.ok) {
+ throw new Error(`le registre a répondu ${config.status} sur la configuration`);
+ }
+
+ const corps = (await config.json()) as {
+ config?: { Labels?: Record; Env?: string[] };
+ created?: string;
+ };
+ const labels = corps.config?.Labels ?? {};
+ const env = Object.fromEntries(
+ (corps.config?.Env ?? [])
+ .filter((entree) => entree.startsWith("APP_"))
+ .map((entree) => {
+ const coupe = entree.indexOf("=");
+ return [entree.slice(0, coupe), entree.slice(coupe + 1)];
+ }),
+ );
+
+ const revision =
+ labels["org.opencontainers.image.revision"]?.trim() ||
+ env.APP_REVISION?.trim() ||
+ "";
+ if (!revision) return null;
+
+ return {
+ revision,
+ version:
+ env.APP_VERSION?.trim() ||
+ labels["org.opencontainers.image.version"]?.trim() ||
+ null,
+ createdAt:
+ env.APP_BUILD_TIME?.trim() ||
+ labels["org.opencontainers.image.created"]?.trim() ||
+ corps.created ||
+ null,
+ reference: `${this.image.registry}/${this.image.repository}:${this.image.tag}`,
+ };
+ }
+}
+
+/**
+ * Révision de l'image publiée, ou l'explication de l'échec. Ne lève jamais :
+ * l'écran des mises à jour doit rester lisible quand le registre ne répond pas.
+ */
+export async function checkPublishedImage(
+ reference: string,
+): Promise {
+ const image = parseImageReference(reference);
+ if (!image) {
+ return {
+ ok: false,
+ error: `Référence d'image illisible : « ${reference} ».`,
+ };
+ }
+
+ try {
+ const publiee = await new RegistryClient(image).lireImagePubliee();
+ if (!publiee) {
+ return {
+ ok: false,
+ error:
+ "L'image publiée ne porte pas de révision : impossible de la comparer à la version installée.",
+ };
+ }
+ return { ok: true, image: publiee };
+ } catch (error) {
+ if (error instanceof Error && error.name === "TimeoutError") {
+ return { ok: false, error: "Le registre n'a pas répondu à temps." };
+ }
+ return {
+ ok: false,
+ error:
+ error instanceof Error
+ ? `Registre injoignable (${error.message}).`
+ : "Registre injoignable.",
+ };
+ }
+}
diff --git a/src/lib/services/updates.ts b/src/lib/services/updates.ts
index 5a192d8..12dbd51 100644
--- a/src/lib/services/updates.ts
+++ b/src/lib/services/updates.ts
@@ -1,7 +1,7 @@
import "server-only";
import { HttpError } from "@/lib/auth/guards";
-import { formatTimeOfDay } from "@/lib/dates";
+import { checkPublishedImage } from "@/lib/services/registry";
import { getRuntimeVersion, shortRevision } from "@/lib/version";
/**
@@ -10,18 +10,32 @@ import { getRuntimeVersion, shortRevision } from "@/lib/version";
* `scheduler` dès qu'une nouvelle version est disponible. Les migrations sont
* ensuite appliquées par l'entrypoint au démarrage.
*
- * Ce service ne sert donc qu'à *rendre visible* l'état : version en cours,
- * dernière version publiée en amont, et cadence de la surveillance.
+ * Ce service rend cet état visible — et il le mesure là où il se décide : dans
+ * le registre. C'est l'image publiée que l'`updater` installe, pas le dernier
+ * commit du dépôt. Les deux diffèrent pendant toute la construction, et pour
+ * toujours si elle échoue.
+ *
+ * Le dépôt Git reste consulté, mais seulement pour distinguer « à jour » de
+ * « une version est fusionnée, son image n'est pas encore publiée ». Cette
+ * consultation est facultative : son échec n'empêche jamais de connaître l'état
+ * réel ni de déclencher une mise à jour.
*/
const REPOSITORY =
process.env.UPDATE_REPOSITORY?.trim() || "flocom/APEL-manager";
const CHANNEL = process.env.UPDATE_CHANNEL?.trim() || "main";
const CHECK_ENABLED = process.env.UPDATE_CHECK_ENABLED?.trim() !== "false";
-const CACHE_TTL_MS = 30 * 60 * 1000;
+/** Image réellement déployée, celle que l'`updater` surveille. */
+const IMAGE =
+ process.env.UPDATE_IMAGE?.trim() ||
+ process.env.APEL_IMAGE?.trim() ||
+ "ghcr.io/flocom/apel-manager:latest";
+const CACHE_TTL_MS = 10 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 5000;
/** Une recréation de conteneur dépasse largement le délai d'une vérification. */
const TRIGGER_TIMEOUT_MS = 20_000;
+/** Sonde de présence de l'`updater` : il répond sur son réseau, ou pas. */
+const PROBE_TIMEOUT_MS = 2500;
/** Cadence de surveillance du conteneur `updater`, en secondes. */
function pollIntervalSeconds() {
@@ -65,11 +79,15 @@ export type UpdateState =
| "unknown"
| "disabled";
+/** Ce que l'application sait de l'`updater`, par observation et non par déduction. */
+export type UpdaterReachability = "reachable" | "unreachable" | "not-configured";
+
export interface UpdateStatus {
current: ReturnType;
latest: {
revision: string;
shortRevision: string;
+ version: string | null;
committedAt: string | null;
url: string;
} | null;
@@ -79,9 +97,21 @@ export interface UpdateStatus {
pollIntervalSeconds: number;
/** Vrai quand l'`updater` accepte un déclenchement immédiat. */
canTriggerNow: boolean;
+ /** Résultat de la sonde : l'`updater` a-t-il répondu ? */
+ reachability: UpdaterReachability;
};
+ /**
+ * Version fusionnée dont l'image n'est pas encore publiée. Renseignée
+ * seulement quand le dépôt a pu être consulté et qu'il devance le registre.
+ */
+ pending: {
+ shortRevision: string;
+ committedAt: string | null;
+ url: string;
+ } | null;
repository: string;
channel: string;
+ image: string;
checkedAt: string | null;
error: string | null;
}
@@ -98,6 +128,8 @@ interface CachedCheck extends CheckResult {
fetchedAt: number;
/** Dernière réponse exploitable : c'est elle que l'écran doit dater. */
succeededAt: number | null;
+ /** Dernier commit connu de la branche, pour situer une image en retard. */
+ head: { revision: string; committedAt: string | null; url: string } | null;
}
let cache: CachedCheck | null = null;
@@ -108,26 +140,27 @@ let cache: CachedCheck | null = null;
* Une fois épuisé, elle répond 403 ou 429 en annonçant l'heure de remise à
* zéro : la retenir évite d'insister pour rien.
*/
-function rateLimitError(response: Response): CheckResult | null {
+function rateLimitRetryAfter(response: Response): number | null {
if (response.status !== 403 && response.status !== 429) return null;
if (response.headers.get("x-ratelimit-remaining") !== "0") return null;
-
const reset = Number(response.headers.get("x-ratelimit-reset")) * 1000;
- const known = Number.isFinite(reset) && reset > Date.now();
- return {
- latest: null,
- error: known
- ? `Quota de l'API GitHub épuisé pour cette adresse IP (60 requêtes par heure sans authentification). Prochaine vérification possible à ${formatTimeOfDay(new Date(reset))}.`
- : "Quota de l'API GitHub épuisé pour cette adresse IP (60 requêtes par heure sans authentification).",
- retryAfter: known ? reset : Date.now() + 15 * 60 * 1000,
- };
+ return Number.isFinite(reset) && reset > Date.now()
+ ? reset
+ : Date.now() + 15 * 60 * 1000;
}
-async function fetchLatestCommit(): Promise {
+/**
+ * Dernier commit de la branche suivie. Consultation d'appoint : elle ne sert
+ * qu'à dire qu'une version est fusionnée sans être encore publiée, et son
+ * échec ne remonte jamais comme une erreur de l'écran.
+ */
+async function fetchHeadCommit(): Promise<{
+ head: CachedCheck["head"];
+ retryAfter: number | null;
+}> {
const url = `https://api.github.com/repos/${REPOSITORY}/commits/${encodeURIComponent(
CHANNEL,
)}`;
-
try {
const response = await fetch(url, {
headers: {
@@ -138,113 +171,160 @@ async function fetchLatestCommit(): Promise {
cache: "no-store",
});
- const limited = rateLimitError(response);
- if (limited) return limited;
-
- if (!response.ok) {
- return {
- latest: null,
- error: `GitHub a répondu ${response.status}.`,
- retryAfter: null,
- };
- }
+ const retryAfter = rateLimitRetryAfter(response);
+ if (retryAfter) return { head: null, retryAfter };
+ if (!response.ok) return { head: null, retryAfter: null };
const payload = (await response.json()) as {
sha?: string;
html_url?: string;
commit?: { committer?: { date?: string } };
};
-
- if (!payload.sha) {
- return {
- latest: null,
- error: "Réponse GitHub inattendue.",
- retryAfter: null,
- };
- }
+ if (!payload.sha) return { head: null, retryAfter: null };
return {
- latest: {
+ head: {
revision: payload.sha,
- shortRevision: shortRevision(payload.sha),
committedAt: payload.commit?.committer?.date ?? null,
url:
payload.html_url ??
`https://github.com/${REPOSITORY}/commit/${payload.sha}`,
},
- error: null,
- retryAfter: null,
- };
- } catch (error) {
- return {
- latest: null,
- error:
- error instanceof Error && error.name === "TimeoutError"
- ? "GitHub n'a pas répondu à temps."
- : "Vérification impossible (réseau indisponible ?).",
retryAfter: null,
};
+ } catch {
+ return { head: null, retryAfter: null };
+ }
+}
+
+/** Interroge le registre, puis le dépôt en complément. */
+async function runCheck(precedent: CachedCheck | null): Promise {
+ const registre = await checkPublishedImage(IMAGE);
+
+ const bloque =
+ precedent?.retryAfter != null && Date.now() < precedent.retryAfter;
+ const commit = bloque
+ ? { head: precedent?.head ?? null, retryAfter: precedent.retryAfter }
+ : await fetchHeadCommit();
+
+ const latest: UpdateStatus["latest"] = registre.ok
+ ? {
+ revision: registre.image.revision,
+ shortRevision: shortRevision(registre.image.revision),
+ version: registre.image.version,
+ committedAt: registre.image.createdAt,
+ url: `https://github.com/${REPOSITORY}/commit/${registre.image.revision}`,
+ }
+ : null;
+
+ return {
+ // Un échec ne doit pas effacer la dernière réponse connue : sans elle
+ // l'écran retomberait sur « État inconnu » alors qu'il sait encore quelle
+ // version est publiée. L'erreur est affichée à côté, pas à la place.
+ latest: latest ?? precedent?.latest ?? null,
+ error: registre.ok ? null : registre.error,
+ retryAfter: commit.retryAfter,
+ head: commit.head ?? precedent?.head ?? null,
+ fetchedAt: Date.now(),
+ succeededAt: latest ? Date.now() : precedent?.succeededAt ?? null,
+ };
+}
+
+/**
+ * Vérifie que l'`updater` est là. « Activée » sans cette sonde n'était qu'une
+ * relecture de la configuration : un `updater` arrêté laissait l'écran
+ * promettre une mise à jour automatique qui n'arrivait jamais.
+ *
+ * La sonde interroge la racine, que l'API de Watchtower n'expose pas : une
+ * réponse HTTP — fût-elle 404 — prouve que le service écoute, sans risquer de
+ * déclencher quoi que ce soit.
+ */
+async function probeUpdater(): Promise {
+ if (!autoUpdateEnabled()) return "not-configured";
+ try {
+ await fetch(`${watchtowerUrl()}/`, {
+ cache: "no-store",
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
+ });
+ return "reachable";
+ } catch {
+ return "unreachable";
}
}
/**
- * Compare la révision embarquée à la dernière révision publiée. Le résultat est
- * mis en cache une demi-heure pour ne pas épuiser le quota anonyme de l'API
- * GitHub, `force` permet une vérification immédiate depuis l'interface.
+ * Compare la révision embarquée à celle de l'image publiée. Le résultat est
+ * mis en cache dix minutes, `force` permet une vérification immédiate depuis
+ * l'interface.
*/
export async function getUpdateStatus(
{ force = false }: { force?: boolean } = {},
): Promise {
const current = getRuntimeVersion();
const enabled = autoUpdateEnabled();
- const autoUpdate = {
- enabled,
- pollIntervalSeconds: pollIntervalSeconds(),
- canTriggerNow: enabled && watchtowerToken().length > 0,
- };
if (!CHECK_ENABLED) {
return {
current,
latest: null,
state: "disabled",
- autoUpdate,
+ autoUpdate: {
+ enabled,
+ pollIntervalSeconds: pollIntervalSeconds(),
+ canTriggerNow: enabled && watchtowerToken().length > 0,
+ reachability: await probeUpdater(),
+ },
+ pending: null,
repository: REPOSITORY,
channel: CHANNEL,
+ image: IMAGE,
checkedAt: null,
error: null,
};
}
const fresh = cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS;
- const blocked = cache?.retryAfter != null && Date.now() < cache.retryAfter;
- if ((force || !fresh) && !blocked) {
- const result = await fetchLatestCommit();
- // Un échec ne doit pas effacer la dernière réponse connue : sans elle
- // l'écran retomberait sur « État inconnu » alors qu'il sait encore quelle
- // version est publiée. L'erreur est affichée à côté, pas à la place.
- cache = {
- ...result,
- latest: result.latest ?? cache?.latest ?? null,
- fetchedAt: Date.now(),
- succeededAt: result.latest ? Date.now() : cache?.succeededAt ?? null,
- };
- }
+ const [checked, reachability] = await Promise.all([
+ force || !fresh ? runCheck(cache) : Promise.resolve(cache!),
+ probeUpdater(),
+ ]);
+ cache = checked;
- const checked = cache!;
let state: UpdateState = "unknown";
if (checked.latest && current.revision) {
state =
checked.latest.revision === current.revision ? "up-to-date" : "outdated";
}
+ // Une version fusionnée dont l'image n'est pas encore publiée : ni « à jour »
+ // ni installable. Le dire évite qu'on attende une mise à jour qui n'existe
+ // pas encore, ou qu'on ignore une publication en échec.
+ const pending =
+ checked.head &&
+ checked.latest &&
+ checked.head.revision !== checked.latest.revision &&
+ checked.head.revision !== current.revision
+ ? {
+ shortRevision: shortRevision(checked.head.revision),
+ committedAt: checked.head.committedAt,
+ url: checked.head.url,
+ }
+ : null;
+
return {
current,
latest: checked.latest,
state,
- autoUpdate,
+ autoUpdate: {
+ enabled,
+ pollIntervalSeconds: pollIntervalSeconds(),
+ canTriggerNow: enabled && watchtowerToken().length > 0,
+ reachability,
+ },
+ pending,
repository: REPOSITORY,
channel: CHANNEL,
+ image: IMAGE,
checkedAt:
checked.succeededAt === null
? null
@@ -253,7 +333,7 @@ export async function getUpdateStatus(
};
}
-export type TriggerOutcome = "started" | "restarting";
+export type TriggerOutcome = "no-update" | "restarting";
/**
* `fetch` masque la panne réelle derrière « fetch failed » et range l'erreur
@@ -268,6 +348,30 @@ function networkCause(error: unknown): string {
return error instanceof Error ? error.message : "cause inconnue";
}
+/**
+ * Une coupure en plein vol est le signe attendu que l'`updater` remplace le
+ * conteneur qui l'interroge : la requête meurt avec lui. Le délai dépassé n'est
+ * qu'une des formes que prend cette coupure — la connexion peut aussi être
+ * réinitialisée ou fermée net, selon le moment où le processus reçoit son
+ * signal d'arrêt. Ne reconnaître que le délai dépassé faisait passer une mise à
+ * jour en cours pour un service injoignable.
+ */
+function estCoupureEnVol(error: unknown): boolean {
+ if (!(error instanceof Error)) return false;
+ if (error.name === "TimeoutError" || error.name === "AbortError") return true;
+ const cause = error.cause;
+ const code =
+ cause instanceof Error ? (cause as NodeJS.ErrnoException).code : undefined;
+ return (
+ code === "ECONNRESET" ||
+ code === "UND_ERR_SOCKET" ||
+ code === "EPIPE" ||
+ /socket hang up|other side closed|terminated/i.test(
+ cause instanceof Error ? cause.message : "",
+ )
+ );
+}
+
/**
* Demande à l'`updater` de contrôler l'image immédiatement au lieu d'attendre
* son prochain passage.
@@ -296,10 +400,7 @@ export async function triggerUpdateNow(): Promise {
cache: "no-store",
});
} catch (error) {
- if (error instanceof Error && error.name === "TimeoutError") {
- // La coupure attendue quand le conteneur est en train d'être remplacé.
- return "restarting";
- }
+ if (estCoupureEnVol(error)) return "restarting";
// Toute autre panne réseau est un vrai défaut de configuration : la dire
// plutôt que de laisser remonter « une erreur serveur est survenue ». La
// cause système distingue le nom introuvable (conteneur absent ou sur un
@@ -322,6 +423,6 @@ export async function triggerUpdateNow(): Promise {
`Le service de mise à jour a répondu ${response.status}.`,
);
}
- // L'updater a répondu : aucune image plus récente à installer.
- return "started";
+ // L'updater a répondu sans nous interrompre : il n'avait rien à installer.
+ return "no-update";
}
From ba10fa0ef221bd5e3978687ca00175e927cfb455 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 17 Sep 2026 08:13:22 +0000
Subject: [PATCH 4/6] Un registre qui refuse n'est pas un registre injoignable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
« Registre injoignable » sur un 404 envoyait chercher une panne de réseau là
où c'est le nom de l'image qui est faux. Les trois cas sont maintenant
distingués : image ou étiquette introuvable, accès refusé — ce que répond
aussi GHCR pour une image publique qui n'existe pas —, et registre
réellement injoignable.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD
---
src/lib/services/registry.ts | 37 ++++++++++++++++++++++++++++++++++--
1 file changed, 35 insertions(+), 2 deletions(-)
diff --git a/src/lib/services/registry.ts b/src/lib/services/registry.ts
index c35a350..0daec88 100644
--- a/src/lib/services/registry.ts
+++ b/src/lib/services/registry.ts
@@ -42,6 +42,17 @@ export type RegistryCheck =
| { ok: true; image: PublishedImage }
| { ok: false; error: string };
+/** Erreur portant le code renvoyé par le registre, pour pouvoir l'expliquer. */
+class RegistryError extends Error {
+ constructor(
+ message: string,
+ readonly status?: number,
+ ) {
+ super(message);
+ this.name = "RegistryError";
+ }
+}
+
interface ImageReference {
registry: string;
repository: string;
@@ -186,7 +197,7 @@ export class RegistryClient {
MANIFEST_TYPES,
);
if (!reponse.ok) {
- throw new Error(`le registre a répondu ${reponse.status}`);
+ throw new RegistryError("manifeste refusé", reponse.status);
}
const manifeste = await this.manifestePlateforme(await reponse.json());
@@ -197,7 +208,7 @@ export class RegistryClient {
// qu'attend l'URL signée.
const config = await this.get(`${this.base}/blobs/${manifeste.config.digest}`);
if (!config.ok) {
- throw new Error(`le registre a répondu ${config.status} sur la configuration`);
+ throw new RegistryError("configuration refusée", config.status);
}
const corps = (await config.json()) as {
@@ -265,6 +276,28 @@ export async function checkPublishedImage(
if (error instanceof Error && error.name === "TimeoutError") {
return { ok: false, error: "Le registre n'a pas répondu à temps." };
}
+ // Un registre qui répond 404 ou 401 n'est pas injoignable : il est joint,
+ // et il refuse. Les confondre enverrait chercher une panne de réseau là où
+ // c'est le nom de l'image qui est faux.
+ if (error instanceof RegistryError && error.status) {
+ const cible = `${image.registry}/${image.repository}:${image.tag}`;
+ if (error.status === 404) {
+ return {
+ ok: false,
+ error: `Aucune image « ${cible} » dans le registre. Vérifiez le nom et l'étiquette dans APEL_IMAGE.`,
+ };
+ }
+ if (error.status === 401 || error.status === 403) {
+ return {
+ ok: false,
+ error: `Le registre refuse l'accès à « ${cible} ». Une image privée ne peut pas être consultée sans identifiants ; une image publique répond ainsi lorsqu'elle n'existe pas.`,
+ };
+ }
+ return {
+ ok: false,
+ error: `Le registre a répondu ${error.status} pour « ${cible} ».`,
+ };
+ }
return {
ok: false,
error:
From f8e54acddb8f7c5486e31c218fa12d228902a498 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 17 Sep 2026 08:50:00 +0000
Subject: [PATCH 5/6] =?UTF-8?q?Un=20audit=20contradictoire=20du=20suivi=20?=
=?UTF-8?q?des=20mises=20=C3=A0=20jour,=20et=20ses=20correctifs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Un défaut introduit par le correctif précédent, et le plus grave du lot : la
surveillance qui suit un déclenchement rechargeait l'état complet toutes les
trois secondes pendant quatre minutes. Chacune de ces lectures interroge le
registre et le dépôt : quatre-vingts contrôles externes pour une seule mise à
jour, de quoi épuiser en une minute le quota horaire de l'API GitHub et
marteler le registre. Elle interroge désormais le contrôle de santé, qui ne
consulte rien à l'extérieur et atteste en prime que la base répond — une
application dont les migrations échouent n'est pas « revenue ». Une seule
lecture complète subsiste, celle qui remet la carte à jour. Mesuré sur une
coupure de vingt-cinq secondes : six appels locaux, un appel externe.
La consultation du dépôt quitte le chemin critique du rendu. Elle n'alimente
qu'un encart d'appoint, et le rendu de la page Configuration tout entière en
dépendait : registre, puis GitHub, puis sonde, en file. Elle part maintenant
en arrière-plan et sert au rendu suivant. Premier rendu, cache froid, updater
absent : 678 ms au lieu d'une quinzaine de secondes dans le pire cas.
Trois affirmations corrigées, toutes signalées comme trompeuses :
— « Active, contrôle toutes les heures » devient « Le service répond ·
contrôle prévu toutes les heures » : la sonde prouve qu'il répond, pas
qu'il contrôle.
— Un « À jour » calculé sur une lecture antérieure le dit maintenant, au lieu
de faire passer une réponse d'il y a trois semaines pour celle de l'instant.
— « Mise à jour automatique : désactivée » s'affichait alors que l'updater
tournait, lorsqu'il avait été lancé par « docker compose --profile
autoupdate up » sans que COMPOSE_PROFILES n'en garde trace. C'est la sonde
qui atteste désormais sa présence, le profil déclaré ne servant que de
repli.
L'encart d'une version en cours de publication donne la date du commit : il
demandait de juger de l'ancienneté d'une construction sans la fournir.
Enfin, dans l'entrypoint, un fichier de jeton présent mais vide n'était jamais
réécrit — `ln` échoue sur une cible existante — et l'application repartait
sans jeton, donc sans bouton d'installation immédiate, pendant que l'écran
conseillait une manœuvre sans effet.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD
---
docker/entrypoint.sh | 8 +++
src/components/update-status-card.tsx | 80 ++++++++++++++++++---------
src/lib/services/updates.ts | 80 +++++++++++++++++++++------
3 files changed, 126 insertions(+), 42 deletions(-)
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index f1562cc..5326b8c 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -91,6 +91,14 @@ load_or_create_secret CRON_SECRET cron-secret 32
# aux secrets ci-dessus, le remplacer n'invalide rien — une valeur imposée dans
# le `.env` prend simplement la place de celle qui a été générée.
updater_token_path="$CONFIG_DIR/updater-token"
+# Un fichier présent mais vide — interruption au premier démarrage, volume
+# restauré à moitié — n'était jamais réécrit : `ln` échouait sur la cible
+# existante et l'application repartait avec un jeton vide, donc sans bouton
+# d'installation immédiate, et le conseil affiché (« relancez la pile ») ne
+# corrigeait rien puisque le fichier restait là.
+if [ -e "$updater_token_path" ] && [ ! -s "$updater_token_path" ]; then
+ rm -f "$updater_token_path"
+fi
if [ -n "${WATCHTOWER_HTTP_API_TOKEN:-}" ]; then
if [ "$(sed -n '1p' "$updater_token_path" 2>/dev/null || true)" != "$WATCHTOWER_HTTP_API_TOKEN" ]; then
mv "$(write_temp_file "$updater_token_path" "$WATCHTOWER_HTTP_API_TOKEN")" \
diff --git a/src/components/update-status-card.tsx b/src/components/update-status-card.tsx
index beee516..d5db93a 100644
--- a/src/components/update-status-card.tsx
+++ b/src/components/update-status-card.tsx
@@ -175,32 +175,24 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
setSuivi({
etat: "attente",
message:
- "Mise à jour lancée. L’application redémarre : cet écran vous dira ce qui a été installé.",
+ "Demande envoyée. Si une version plus récente existe, l’application redémarre — cet écran dira ce qui a été installé.",
});
while (Date.now() < limite) {
await new Promise((r) => setTimeout(r, INTERVALLE_MS));
if (!vivant.current) return;
- try {
- const frais = await recharger();
- if (!vivant.current) return;
- setCurrent(frais);
- if (frais.current.revision !== revisionAvant) {
- setSuivi({
- etat: "installee",
- message: `Mise à jour installée : l’application tourne maintenant en ${frais.current.version} (${frais.current.shortRevision}).`,
- });
- return;
- }
- if (tombee) {
- setSuivi({
- etat: "inchangee",
- message:
- "L’application est revenue, toujours dans la même version : le service de mise à jour n’avait rien de plus récent à installer.",
- });
- return;
- }
+ // On interroge le contrôle de santé, et non l'état des mises à jour :
+ // il répond sans consulter ni registre ni dépôt. Rafraîchir l'état
+ // complet toutes les trois secondes épuiserait en une minute le quota
+ // horaire de l'API GitHub et martèlerait le registre pour rien. Ce
+ // point d'entrée atteste en outre que la base répond : une application
+ // dont les migrations échouent n'est pas « revenue ».
+ let sante: { revision: string | null; version: string } | null = null;
+ try {
+ const reponse = await fetch("/api/health", { cache: "no-store" });
+ if (!reponse.ok) throw new Error(String(reponse.status));
+ sante = (await reponse.json()) as { revision: string | null; version: string };
} catch {
// L'application ne répond plus : c'est le remplacement en cours.
tombee = true;
@@ -211,6 +203,31 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
"L’application redémarre… cet écran se met à jour tout seul dès qu’elle répond.",
});
}
+ continue;
+ }
+
+ if (!vivant.current) return;
+ if (sante.revision && sante.revision !== revisionAvant) {
+ // Une seule lecture complète, celle qui remet la carte à jour.
+ try {
+ setCurrent(await recharger());
+ } catch {
+ // Sans conséquence : le compte rendu ci-dessous suffit.
+ }
+ if (!vivant.current) return;
+ setSuivi({
+ etat: "installee",
+ message: `Mise à jour installée : l’application tourne maintenant en ${sante.version} (${sante.revision}).`,
+ });
+ return;
+ }
+ if (tombee) {
+ setSuivi({
+ etat: "inchangee",
+ message:
+ "L’application est revenue, toujours dans la même version : le service de mise à jour n’avait rien de plus récent à installer.",
+ });
+ return;
}
}
@@ -228,7 +245,7 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
async function applyNow() {
setConfirmApply(false);
setApplying(true);
- const revisionAvant = current.current.revision;
+ const revisionAvant = current.current.shortRevision;
try {
const response = await fetch("/api/updates/apply", { method: "POST" });
if (!response.ok) {
@@ -340,7 +357,7 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
!autoUpdate.enabled
? "Désactivée"
: autoUpdate.reachability === "reachable"
- ? `Active, contrôle ${formatInterval(
+ ? `Le service répond · contrôle prévu ${formatInterval(
autoUpdate.pollIntervalSeconds,
)}`
: "Activée, mais le service ne répond pas"
@@ -441,9 +458,22 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
>
{current.pending.shortRevision}
{" "}
- est fusionné mais son image n'est pas encore publiée. La
- construction dure une dizaine de minutes ; au-delà, vérifiez qu'elle
- n'a pas échoué.
+ {current.pending.committedAt
+ ? ` a été fusionné le ${formatDate(current.pending.committedAt)} `
+ : " est fusionné "}
+ mais son image n'est pas encore publiée. La construction dure une
+ dizaine de minutes ; au-delà, vérifiez qu'elle n'a pas échoué.
+
+ )}
+
+ {current.stale && (
+
+ Le registre n’a pas répondu cette fois : l’état ci-dessus repose sur
+ la dernière lecture réussie, pas sur celle de maintenant.
)}
diff --git a/src/lib/services/updates.ts b/src/lib/services/updates.ts
index 12dbd51..a9c8bf0 100644
--- a/src/lib/services/updates.ts
+++ b/src/lib/services/updates.ts
@@ -32,6 +32,8 @@ const IMAGE =
"ghcr.io/flocom/apel-manager:latest";
const CACHE_TTL_MS = 10 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 5000;
+/** Au-delà, la consultation du dépôt est abandonnée : elle n'est qu'un appoint. */
+const HEAD_TTL_MS = 10 * 60 * 1000;
/** Une recréation de conteneur dépasse largement le délai d'une vérification. */
const TRIGGER_TIMEOUT_MS = 20_000;
/** Sonde de présence de l'`updater` : il répond sur son réseau, ou pas. */
@@ -109,6 +111,12 @@ export interface UpdateStatus {
committedAt: string | null;
url: string;
} | null;
+ /**
+ * Vrai quand l'état repose sur une réponse antérieure, le registre n'ayant
+ * pas répondu cette fois. Un « À jour » calculé sur une lecture d'il y a
+ * trois semaines n'a pas la même valeur qu'un « À jour » de l'instant.
+ */
+ stale: boolean;
repository: string;
channel: string;
image: string;
@@ -130,9 +138,13 @@ interface CachedCheck extends CheckResult {
succeededAt: number | null;
/** Dernier commit connu de la branche, pour situer une image en retard. */
head: { revision: string; committedAt: string | null; url: string } | null;
+ /** Dernière tentative de lecture du dépôt, réussie ou non. */
+ headFetchedAt: number;
}
let cache: CachedCheck | null = null;
+/** Évite d'empiler les consultations du dépôt lancées en arrière-plan. */
+let headEnCours = false;
/**
* L'API publique de GitHub tolère 60 requêtes par heure et par adresse IP sans
@@ -197,16 +209,38 @@ async function fetchHeadCommit(): Promise<{
}
}
-/** Interroge le registre, puis le dépôt en complément. */
+/**
+ * Consultation du dépôt, lancée sans être attendue. Elle n'alimente qu'un
+ * encart d'appoint : la faire attendre par le rendu ferait dépendre l'écran
+ * Configuration tout entier de la disponibilité de GitHub, et d'un quota de
+ * soixante requêtes par heure partagé avec toute la machine.
+ */
+function rafraichirHeadEnArrierePlan() {
+ if (headEnCours) return;
+ const maintenant = Date.now();
+ if (cache && maintenant - cache.headFetchedAt < HEAD_TTL_MS) return;
+ if (cache?.retryAfter != null && maintenant < cache.retryAfter) return;
+
+ headEnCours = true;
+ void fetchHeadCommit()
+ .then(({ head, retryAfter }) => {
+ if (!cache) return;
+ cache.headFetchedAt = Date.now();
+ cache.retryAfter = retryAfter;
+ if (head) cache.head = head;
+ })
+ .catch(() => {
+ if (cache) cache.headFetchedAt = Date.now();
+ })
+ .finally(() => {
+ headEnCours = false;
+ });
+}
+
+/** Interroge le registre. Lui seul décide de l'état, lui seul est attendu. */
async function runCheck(precedent: CachedCheck | null): Promise {
const registre = await checkPublishedImage(IMAGE);
- const bloque =
- precedent?.retryAfter != null && Date.now() < precedent.retryAfter;
- const commit = bloque
- ? { head: precedent?.head ?? null, retryAfter: precedent.retryAfter }
- : await fetchHeadCommit();
-
const latest: UpdateStatus["latest"] = registre.ok
? {
revision: registre.image.revision,
@@ -220,11 +254,13 @@ async function runCheck(precedent: CachedCheck | null): Promise {
return {
// Un échec ne doit pas effacer la dernière réponse connue : sans elle
// l'écran retomberait sur « État inconnu » alors qu'il sait encore quelle
- // version est publiée. L'erreur est affichée à côté, pas à la place.
+ // version est publiée. L'erreur est affichée à côté, pas à la place — et
+ // `stale` dit que ce qu'on lit n'est plus de première main.
latest: latest ?? precedent?.latest ?? null,
error: registre.ok ? null : registre.error,
- retryAfter: commit.retryAfter,
- head: commit.head ?? precedent?.head ?? null,
+ retryAfter: precedent?.retryAfter ?? null,
+ head: precedent?.head ?? null,
+ headFetchedAt: precedent?.headFetchedAt ?? 0,
fetchedAt: Date.now(),
succeededAt: latest ? Date.now() : precedent?.succeededAt ?? null,
};
@@ -240,7 +276,6 @@ async function runCheck(precedent: CachedCheck | null): Promise {
* déclencher quoi que ce soit.
*/
async function probeUpdater(): Promise {
- if (!autoUpdateEnabled()) return "not-configured";
try {
await fetch(`${watchtowerUrl()}/`, {
cache: "no-store",
@@ -248,7 +283,11 @@ async function probeUpdater(): Promise {
});
return "reachable";
} catch {
- return "unreachable";
+ // La sonde tourne même quand le profil n'est pas déclaré : `docker compose
+ // --profile autoupdate up` active le service en ligne de commande sans que
+ // COMPOSE_PROFILES n'en garde trace, et l'écran annonçait alors
+ // « Désactivée » pendant que l'updater faisait son travail.
+ return autoUpdateEnabled() ? "unreachable" : "not-configured";
}
}
@@ -264,17 +303,20 @@ export async function getUpdateStatus(
const enabled = autoUpdateEnabled();
if (!CHECK_ENABLED) {
+ const reachability = await probeUpdater();
+ const actif = enabled || reachability === "reachable";
return {
current,
latest: null,
state: "disabled",
autoUpdate: {
- enabled,
+ enabled: actif,
pollIntervalSeconds: pollIntervalSeconds(),
- canTriggerNow: enabled && watchtowerToken().length > 0,
- reachability: await probeUpdater(),
+ canTriggerNow: actif && watchtowerToken().length > 0,
+ reachability,
},
pending: null,
+ stale: false,
repository: REPOSITORY,
channel: CHANNEL,
image: IMAGE,
@@ -289,6 +331,8 @@ export async function getUpdateStatus(
probeUpdater(),
]);
cache = checked;
+ // Lancée seulement maintenant : elle écrit dans le cache déjà en place.
+ rafraichirHeadEnArrierePlan();
let state: UpdateState = "unknown";
if (checked.latest && current.revision) {
@@ -311,17 +355,19 @@ export async function getUpdateStatus(
}
: null;
+ const actif = enabled || reachability === "reachable";
return {
current,
latest: checked.latest,
state,
autoUpdate: {
- enabled,
+ enabled: actif,
pollIntervalSeconds: pollIntervalSeconds(),
- canTriggerNow: enabled && watchtowerToken().length > 0,
+ canTriggerNow: actif && watchtowerToken().length > 0,
reachability,
},
pending,
+ stale: Boolean(checked.error && checked.latest),
repository: REPOSITORY,
channel: CHANNEL,
image: IMAGE,
From fbbe2d0032393bc46d9edd482ee523e9582bddfe Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 17 Sep 2026 09:05:05 +0000
Subject: [PATCH 6/6] =?UTF-8?q?Une=20migration=20refus=C3=A9e=20ne=20met?=
=?UTF-8?q?=20plus=20l'application=20par=20terre?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Le seul chemin qui transformait une mise à jour automatique en panne totale.
Une migration valide en local mais refusée par les données réelles — un index
unique sur une colonne qui contient des doublons en production — partait sur
les instances dans l'heure. `migrate.mjs` sortait en 1 sans distinguer une
erreur SQL d'une base injoignable ; l'entrypoint rejouait donc trente fois la
même migration en annonçant « PostgreSQL indisponible », un message faux qui
envoyait chercher une panne de base qui se portait très bien, puis abandonnait
au bout de quatre minutes — et `restart: unless-stopped` recommençait. Boucle
sans fin, site en erreur de passerelle, et l'écran Configuration, seule
interface de diagnostic, inaccessible puisque servi par l'application tombée.
Quatre gestes, du plus préventif au plus curatif.
Les migrations sont éprouvées avant toute publication. Un job applique les
migrations sur un PostgreSQL vierge, puis les rejoue pour vérifier qu'elles
restent sans effet. Il gate la publication de l'image : rien ne part si elles
échouent. Il tourne aussi sur les propositions de changement, pour que le
défaut se voie avant la fusion et non après.
`migrate.mjs` distingue les deux causes. Les codes de connexion — refus,
hôte inconnu, délai, « le système de base de données démarre » — sortent en 1
et méritent d'être réessayés. Tout le reste sort en 2 : le rejouer donnerait
trente fois le même refus.
L'entrypoint s'arrête sur un code 2, en une seconde au lieu de quatre minutes,
et écrit ce qu'il faut faire : que le schéma est intact — une migration
s'applique d'un bloc, donc s'annule d'un bloc —, la commande exacte du retour
en arrière, et l'avertissement qu'un « pull && up -d » réinstallerait la même
version fautive. Il retenait au passage un défaut de sa propre écriture :
`if cmd; then … fi` rend 0 quand la condition échoue sans branche `else`, si
bien que le code fatal n'était jamais vu.
La commande de retour n'est plus à deviner : l'entrypoint enregistre dans le
volume de configuration la révision du dernier démarrage réussi, lisible sans
l'application, et la publication pousse déjà pour chaque version une étiquette
immuable `sha-xxxxxxx`. La procédure est écrite dans docs/DOCKER.md, avec ses
deux pièges — `latest` désigne la version fautive, et figer `APEL_IMAGE` fige
aussi la mise à jour automatique.
Enfin, deux affirmations de l'écran rendues exactes : un « À jour » calculé
sur une lecture antérieure le dit et perd son vert, et cesse tout à fait de
valoir état passé vingt-quatre heures sans une seule lecture réussie du
registre ; et la réponse de l'updater est rapportée pour ce qu'elle est — il a
contrôlé le registre et rendu la main sans redémarrer l'application — au lieu
d'affirmer ce qu'elle ne dit pas.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD
---
.github/workflows/ci.yml | 42 +++++++++++++++++++++
.github/workflows/docker-publish.yml | 45 +++++++++++++++++++++++
docker/entrypoint.sh | 40 +++++++++++++++++++-
docs/DOCKER.md | 53 +++++++++++++++++++++++++--
scripts/migrate.mjs | 50 +++++++++++++++++++++++--
src/components/update-status-card.tsx | 16 +++++---
src/lib/services/updates.ts | 18 ++++++++-
7 files changed, 249 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 906791d..49d44fa 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,3 +36,45 @@ jobs:
- name: Lint
run: npm run lint
+
+ # Les migrations s'appliquent-elles sur une base vierge ? C'est la seule
+ # panne qui transforme une mise à jour automatique en panne totale : une
+ # migration invalide part sur toutes les instances, l'entrypoint réessaie
+ # trente fois, abandonne, et le conteneur tourne en boucle de redémarrage.
+ # Les éprouver ici coûte une minute et referme ce chemin.
+ migrations:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_DB: apel_manager
+ POSTGRES_USER: apel
+ POSTGRES_PASSWORD: apel-ci
+ options: >-
+ --health-cmd "pg_isready -U apel -d apel_manager"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 20
+ ports:
+ - 5432:5432
+ env:
+ DATABASE_URL: postgresql://apel:apel-ci@localhost:5432/apel_manager
+ steps:
+ - name: Récupérer les sources
+ uses: actions/checkout@v4
+
+ - name: Préparer Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Installer les dépendances
+ run: npm ci
+
+ - name: Appliquer les migrations sur une base vierge
+ run: node scripts/migrate.mjs
+
+ - name: Les rejouer, elles doivent rester sans effet
+ run: node scripts/migrate.mjs
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 817d944..0fd68eb 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -25,8 +25,53 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
+ # Les migrations s'appliquent-elles sur une base vierge ? C'est la seule
+ # panne qui transforme une mise à jour automatique en panne totale : une
+ # migration invalide part sur toutes les instances, l'entrypoint réessaie
+ # trente fois, abandonne, et le conteneur tourne en boucle de redémarrage.
+ # Les éprouver ici coûte une minute et referme ce chemin.
+ migrations:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_DB: apel_manager
+ POSTGRES_USER: apel
+ POSTGRES_PASSWORD: apel-ci
+ options: >-
+ --health-cmd "pg_isready -U apel -d apel_manager"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 20
+ ports:
+ - 5432:5432
+ env:
+ DATABASE_URL: postgresql://apel:apel-ci@localhost:5432/apel_manager
+ steps:
+ - name: Récupérer les sources
+ uses: actions/checkout@v4
+
+ - name: Préparer Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Installer les dépendances
+ run: npm ci
+
+ - name: Appliquer les migrations sur une base vierge
+ run: node scripts/migrate.mjs
+
+ - name: Les rejouer, elles doivent rester sans effet
+ run: node scripts/migrate.mjs
+
publish:
runs-on: ubuntu-latest
+ # Rien ne part tant que les migrations n'ont pas été éprouvées : une image
+ # publiée est installée toute seule sur les instances dans l'heure.
+ needs: migrations
permissions:
contents: read
packages: write
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index 5326b8c..bddb76d 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -129,9 +129,36 @@ if [ "${SKIP_MIGRATIONS:-0}" != "1" ]; then
attempt=1
maximum_attempts="${MIGRATION_MAX_ATTEMPTS:-30}"
- while ! node scripts/migrate.mjs; do
+ while true; do
+ # `if cmd; then …; fi` rend 0 quand la condition échoue sans branche
+ # `else` : relire $? après le bloc donnerait donc toujours 0 et le code
+ # fatal ne serait jamais vu. `|| status=$?` capture la vraie valeur, et
+ # protège la commande de `set -e`.
+ status=0
+ node scripts/migrate.mjs || status=$?
+ if [ "$status" -eq 0 ]; then
+ break
+ fi
+
+ # Code 2 : PostgreSQL a répondu et a refusé la migration. La rejouer
+ # trente fois donnerait trente fois le même refus, en annonçant une base
+ # indisponible qui se porte très bien — et en envoyant chercher la panne
+ # là où elle n'est pas. Mieux vaut s'arrêter en disant quoi faire.
+ if [ "$status" -eq 2 ]; then
+ echo "Cette version ne peut pas démarrer : sa migration est refusée par la base." >&2
+ echo "Le schéma n'a pas été modifié — la migration est appliquée d'un bloc, donc annulée d'un bloc." >&2
+ if [ -s "$CONFIG_DIR/last-good-revision" ]; then
+ echo "Pour revenir à la version qui tournait avant, sur le serveur :" >&2
+ echo " APEL_IMAGE=${UPDATE_IMAGE_REPOSITORY:-ghcr.io/flocom/apel-manager}:sha-$(sed -n '1p' "$CONFIG_DIR/last-good-revision" | cut -c1-7) docker compose up -d" >&2
+ else
+ echo "Revenez à la version précédente en fixant APEL_IMAGE sur son étiquette sha-xxxxxxx." >&2
+ fi
+ echo "Un « docker compose pull && docker compose up -d » réinstallerait la même version." >&2
+ exit 2
+ fi
+
if [ "$attempt" -ge "$maximum_attempts" ]; then
- echo "Échec des migrations après $attempt tentatives." >&2
+ echo "PostgreSQL est resté injoignable après $attempt tentatives." >&2
exit 1
fi
@@ -142,4 +169,13 @@ if [ "${SKIP_MIGRATIONS:-0}" != "1" ]; then
done
fi
+# Repère du retour en arrière. Écrit après les migrations, donc seulement par
+# une version qui a pu démarrer : quand la suivante échouera, l'étiquette à
+# réinstaller sera là, sur le disque, lisible sans l'application.
+if [ -n "${APP_REVISION:-}" ] && [ "${SKIP_MIGRATIONS:-0}" != "1" ]; then
+ printf '%s\n' "$APP_REVISION" > "$CONFIG_DIR/last-good-revision.tmp" 2>/dev/null &&
+ mv "$CONFIG_DIR/last-good-revision.tmp" "$CONFIG_DIR/last-good-revision" 2>/dev/null ||
+ true
+fi
+
exec "$@"
diff --git a/docs/DOCKER.md b/docs/DOCKER.md
index 8bf0f5c..0dae0dd 100644
--- a/docs/DOCKER.md
+++ b/docs/DOCKER.md
@@ -323,9 +323,56 @@ curl -s https://apel.example.org/api/health
{"status":"ok","database":"up","latencyMs":3,"version":"main-42","revision":"a1b2c3d", ...}
```
-Ces indicateurs reposent sur un appel à l'API publique de GitHub, mis en cache
-30 minutes. `UPDATE_CHECK_ENABLED="false"` supprime tout appel sortant : la
-mise à jour automatique continue de fonctionner, seul l'indicateur disparaît.
+Ces indicateurs reposent sur la lecture de l'image publiée dans le registre —
+celle que l'`updater` installerait — et non sur le dernier commit du dépôt :
+entre une fusion et la fin de la construction il s'écoule une dizaine de
+minutes, pendant lesquelles il n'y a rien à installer. Le résultat est mis en
+cache dix minutes. Le dépôt GitHub n'est consulté qu'en appoint, pour signaler
+une version fusionnée dont l'image n'est pas encore publiée ; cette
+consultation échoue sans conséquence. `UPDATE_CHECK_ENABLED="false"` supprime
+tout appel sortant : la mise à jour automatique continue de fonctionner, seul
+l'indicateur disparaît.
+
+L'écran indique aussi si le service `updater` répond réellement. Une mise à
+jour automatique annoncée « activée » alors que le conteneur est arrêté
+n'installerait jamais rien : la distinction est faite par une sonde, pas
+déduite de la configuration.
+
+### Revenir à la version précédente
+
+Chaque publication pousse, à côté de `latest`, une étiquette immuable
+`sha-xxxxxxx` reprenant les sept premiers caractères de la révision. C'est la
+porte de sortie quand une version ne démarre pas.
+
+L'application écrit dans le volume `app_config` la révision du dernier
+démarrage réussi. Pour la lire, même application arrêtée :
+
+```bash
+docker run --rm -v apel-manager_app_config:/config alpine \
+ cat /config/last-good-revision
+```
+
+Puis, dans `.env` :
+
+```env
+APEL_IMAGE="ghcr.io/flocom/apel-manager:sha-2ab04da"
+```
+
+```bash
+docker compose up -d
+```
+
+Deux points à connaître. D'abord, `docker compose pull && docker compose up -d`
+ne répare rien : `latest` désigne justement la version fautive, et la commande
+la réinstalle. Ensuite, tant qu'`APEL_IMAGE` désigne une étiquette `sha-`, la
+mise à jour automatique est figée — l'`updater` surveille une étiquette qui ne
+bouge plus. Remettez `APEL_IMAGE` à `latest` une fois le correctif publié.
+
+Une migration refusée par PostgreSQL n'abîme pas la base : elle est appliquée
+d'un seul bloc, donc annulée d'un seul bloc, et le schéma reste celui de la
+version précédente. Le conteneur s'arrête alors immédiatement en écrivant la
+commande de retour dans ses journaux (`docker compose logs app`), au lieu de
+réessayer en boucle.
### Précautions
diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs
index eecde7c..18264ee 100644
--- a/scripts/migrate.mjs
+++ b/scripts/migrate.mjs
@@ -30,6 +30,44 @@ const client = postgres(databaseUrl, {
idle_timeout: 5,
});
+/**
+ * Codes qui signifient « la base n'est pas encore joignable ». Eux seuls
+ * justifient de réessayer : au démarrage d'une pile, PostgreSQL met quelques
+ * secondes à accepter les connexions.
+ *
+ * Tout le reste — une erreur SQL, un index unique refusé par des doublons
+ * réels, une contrainte violée par les données en place — se reproduira à
+ * l'identique trente fois de suite. Les confondre faisait afficher
+ * « PostgreSQL indisponible » pendant quatre minutes pour une migration
+ * fautive, et envoyait chercher une panne de base qui n'existait pas.
+ */
+const CODES_CONNEXION = new Set([
+ "ECONNREFUSED",
+ "ENOTFOUND",
+ "ETIMEDOUT",
+ "EHOSTUNREACH",
+ "ENETUNREACH",
+ "ECONNRESET",
+ "EPIPE",
+ "CONNECT_TIMEOUT",
+ "CONNECTION_CLOSED",
+ "CONNECTION_ENDED",
+ "CONNECTION_DESTROYED",
+ // PostgreSQL démarre, accepte la connexion et la refuse aussitôt.
+ "57P03",
+]);
+
+function estPanneDeConnexion(error) {
+ for (let cause = error; cause; cause = cause.cause) {
+ if (cause.code && CODES_CONNEXION.has(String(cause.code))) return true;
+ }
+ return false;
+}
+
+/** Code 2 : inutile de réessayer. Code 1 : la base n'est pas encore là. */
+const SORTIE_FATALE = 2;
+const SORTIE_REESSAYABLE = 1;
+
let migrationFailed = false;
try {
@@ -38,14 +76,20 @@ try {
console.log("Migrations PostgreSQL appliquées.");
} catch (error) {
migrationFailed = true;
- process.exitCode = 1;
- console.error("Échec des migrations PostgreSQL.");
+ const reessayable = estPanneDeConnexion(error);
+ process.exitCode = reessayable ? SORTIE_REESSAYABLE : SORTIE_FATALE;
+ console.error(
+ reessayable
+ ? "PostgreSQL n'est pas joignable pour appliquer les migrations."
+ : "Migration refusée par PostgreSQL : cette version ne peut pas démarrer.",
+ );
console.error(error instanceof Error ? error.message : error);
} finally {
try {
await client.end({ timeout: 5 });
} catch (error) {
- process.exitCode = 1;
+ // Ne pas écraser un diagnostic fatal par un simple défaut de fermeture.
+ process.exitCode = process.exitCode || SORTIE_REESSAYABLE;
console.error("Impossible de fermer proprement la connexion PostgreSQL.");
if (!migrationFailed) {
console.error(error instanceof Error ? error.message : error);
diff --git a/src/components/update-status-card.tsx b/src/components/update-status-card.tsx
index d5db93a..25adf00 100644
--- a/src/components/update-status-card.tsx
+++ b/src/components/update-status-card.tsx
@@ -40,11 +40,17 @@ function formatInterval(seconds: number) {
return `toutes les ${seconds} secondes`;
}
-function StateBadge({ state }: { state: UpdateStatus["state"] }) {
+function StateBadge({
+ state,
+ stale,
+}: {
+ state: UpdateStatus["state"];
+ stale: boolean;
+}) {
if (state === "up-to-date") {
return (
-
- À jour
+
+ {stale ? "À jour, d’après la dernière lecture" : "À jour"}
);
}
@@ -276,7 +282,7 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
setSuivi({
etat: "inchangee",
message:
- "Contrôle effectué : le service de mise à jour n’a trouvé aucune version plus récente à installer.",
+ "Le service de mise à jour a contrôlé le registre et rendu la main sans redémarrer l’application : rien n’a été installé.",
});
try {
setCurrent(await recharger());
@@ -319,7 +325,7 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
-
+
diff --git a/src/lib/services/updates.ts b/src/lib/services/updates.ts
index a9c8bf0..02b9a19 100644
--- a/src/lib/services/updates.ts
+++ b/src/lib/services/updates.ts
@@ -34,6 +34,14 @@ const CACHE_TTL_MS = 10 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 5000;
/** Au-delà, la consultation du dépôt est abandonnée : elle n'est qu'un appoint. */
const HEAD_TTL_MS = 10 * 60 * 1000;
+/**
+ * Passé ce délai sans une seule lecture réussie du registre, la dernière
+ * réponse connue cesse de valoir état. Garder le cache indéfiniment évite un
+ * « État inconnu » sur un délai dépassé de cinq secondes, mais afficherait un
+ * « À jour » vieux de trois semaines avec le même aplomb qu'un « À jour » de
+ * l'instant.
+ */
+const STALE_MAX_MS = 24 * 60 * 60 * 1000;
/** Une recréation de conteneur dépasse largement le délai d'une vérification. */
const TRIGGER_TIMEOUT_MS = 20_000;
/** Sonde de présence de l'`updater` : il répond sur son réseau, ou pas. */
@@ -334,8 +342,12 @@ export async function getUpdateStatus(
// Lancée seulement maintenant : elle écrit dans le cache déjà en place.
rafraichirHeadEnArrierePlan();
+ const perime =
+ checked.succeededAt === null ||
+ Date.now() - checked.succeededAt > STALE_MAX_MS;
+
let state: UpdateState = "unknown";
- if (checked.latest && current.revision) {
+ if (checked.latest && current.revision && !perime) {
state =
checked.latest.revision === current.revision ? "up-to-date" : "outdated";
}
@@ -469,6 +481,8 @@ export async function triggerUpdateNow(): Promise {
`Le service de mise à jour a répondu ${response.status}.`,
);
}
- // L'updater a répondu sans nous interrompre : il n'avait rien à installer.
+ // L'updater a rendu la main sans que le conteneur soit remplacé : rien n'a
+ // été installé ici. Ce qu'il a fait d'autre — mettre à jour le planificateur,
+ // par exemple — n'est pas dans sa réponse, qui n'a pas de corps exploitable.
return "no-update";
}