Verified Commit d78384f7 authored by Kourser's avatar Kourser
Browse files

M2 éprouvé à l'écran, et l'image qui ne se construisait plus

Trois défauts, dont un que j'avais annoncé comme vérifié sans l'avoir été.

L'image ne se construisait plus depuis le commit du relais
  J'avais contrôlé la composition avec « docker compose config » — qui valide
  la syntaxe — et présenté cela comme une vérification. Ce n'en était pas une :
  l'étape des dépendances n'installait pas celles du relais, et toute
  construction échouait sur « /app/apps/relais/node_modules : not found ». Le
  symptôme n'arrive qu'au déploiement, c'est-à-dire au plus mauvais moment.

  Un contrôle le rattrape désormais : tout paquet que l'image construit doit
  voir son package.json copié à l'étape qui installe. Avec, dans la foulée, ce
  qui vaut d'être tenu par autre chose que l'attention — la licence voyage dans
  l'image, le relais n'y monte aucun volume, et il ne démarre que sous profil.

Deux compteurs vides, invisibles au typage
  L'écran de crise affichait des cases vides à « Arbitrages attendus » et
  « Actions en retard ». Le serveur renvoie des listes, pas des nombres :
  String([]) vaut la chaîne vide, sans erreur, sans avertissement, sans rien.
  Les listes servent maintenant à la fois de compteur et de contenu — ce qui
  supprime au passage une requête que le téléphone faisait pour redemander ce
  qu'il venait de recevoir.

  Le chrono de crise — H+09:51 — s'affiche enfin, et la fraîcheur perd ses
  secondes : personne n'a besoin de savoir qu'un écran date de 38 secondes.

Éprouvé pour de bon, cette fois
  Sur simulateur, contre l'instance : liste des crises, cockpit, consignation
  arrivée au journal avec l'origine « mobile », l'appareil inscrit et aucune
  session.

  Puis la file hors ligne, en coupant réellement l'instance : entrée saisie
  sans réseau, application tuée, instance relancée, application rouverte — la
  file était toujours là, avec son texte et son heure. Remise ensuite, elle
  s'inscrit au journal avec ses deux heures, et l'écart s'affiche à l'écran :
  « 07:43:30 · saisi à 07:42:28 ». Soixante-deux secondes, exactement la durée
  pendant laquelle l'instance était éteinte.

755 garanties.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 6bdb8cb1
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ import { verifierAccessibilite } from "./verification/accessibilite.js";
import { verifierMobile } from "./verification/mobile.js";
import { verifierApplicationMobile } from "./verification/application-mobile.js";
import { verifierRelais } from "./verification/relais.js";
import { verifierComposition } from "./verification/composition.js";
import { verifierCanaux } from "./verification/canaux.js";
import { verifierContinuite } from "./verification/continuite.js";
import { verifierVeille } from "./verification/veille.js";
@@ -325,6 +326,9 @@ await verifierApplicationMobile(rapport);
// ── Le relais de heurtoir (EM-82 à EM-85) ─────────────────────────────────
await verifierRelais(rapport);

// ── L'image et la composition (EX-27) ─────────────────────────────────────
await verifierComposition(rapport);

// ── Canaux de diffusion et envois de masse (§6.11) ────────────────────────
await verifierCanaux(sql, app, rapport);

+58 −0
Original line number Diff line number Diff line
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { Rapport } from "./rapport.js";

/**
 * Contrôles sur la composition et l'image — Kastell.
 *
 * Un dépôt dont la suite est verte mais dont l'image ne se construit plus est
 * un dépôt qui ment. Ces contrôles ne remplacent pas une construction réelle —
 * rien ne la remplace — mais ils attrapent la classe de défauts qui a
 * effectivement mordu : ajouter un paquet au dépôt, le faire construire par
 * l'image, et oublier de déclarer ses dépendances à l'étape qui les installe.
 *
 * Le symptôme, lui, arrive tard et ailleurs : « failed to compute cache key :
 * /app/apps/relais/node_modules not found », au moment d'un déploiement.
 */

const racine = fileURLToPath(new URL("../../../../", import.meta.url));

export async function verifierComposition(r: Rapport): Promise<void> {
  r.titre("Image et composition");

  const dockerfile = await readFile(`${racine}docker/Dockerfile`, "utf8");
  const compose = await readFile(`${racine}docker/compose.yaml`, "utf8");

  // ── Tout paquet construit doit avoir été installé ───────────────────────
  const construits = [...dockerfile.matchAll(/pnpm --filter @kastell\/([a-z-]+) build/g)]
    .map((m) => m[1]!);
  r.verifie("EX-27", `${construits.length} paquets construits par l'image`,
    construits.length >= 3, construits.join(", "));

  const manquants = construits.filter((nom) => {
    const dossier = nom === "shared" ? `packages/${nom}` : `apps/${nom}`;
    return !new RegExp(`COPY\\s+${dossier}/package\\.json`).test(dockerfile);
  });
  r.verifie("EX-27",
    "chaque paquet construit voit son package.json copié à l'étape des dépendances",
    manquants.length === 0, manquants.join(", "));

  // ── Ce que l'étape d'exécution embarque ─────────────────────────────────
  r.verifie("EX-27", "l'image embarque le serveur et le relais",
    /COPY --from=build[^\n]*apps\/api\/dist/.test(dockerfile)
      && /COPY --from=build[^\n]*apps\/relais\/dist/.test(dockerfile));

  // Section 4 de l'AGPL : la licence voyage avec le programme.
  r.verifie("EB-17", "la licence voyage dans l'image",
    /COPY[^\n]*LICENSE/.test(dockerfile));

  // ── Le relais ne conserve rien, jusque dans la composition ──────────────
  //
  // Un volume monté sur ce service contredirait la promesse du §15 aussi
  // sûrement qu'une base de données dans son code.
  const bloc = compose.slice(compose.indexOf("  relais:"), compose.indexOf("volumes:\n  base:"));
  r.verifie("EM-83", "le service de relais ne monte aucun volume",
    bloc.length > 0 && !/^\s+volumes:/m.test(bloc));
  r.verifie("EM-82", "il ne démarre que sous un profil, jamais par défaut",
    /profiles:\s*\[[^\]]*mobile/.test(bloc));
}
+22 −11
Original line number Diff line number Diff line
@@ -23,11 +23,18 @@ export interface Crise {
}

export interface Cockpit {
  crise: Crise & { chrono: unknown };
  arbitrages_en_attente: number | string;
  actions_en_retard: number | string;
  actions_ouvertes: number | string;
  entrees_main_courante: number | string;
  crise: Crise & { chrono: string };
  /**
   * Des listes, et non des compteurs — c'est ce que le serveur renvoie, et
   * s'en tenir à un « nombre » ici affichait des cases vides à l'écran :
   * String([]) vaut la chaîne vide, sans erreur et sans avertissement.
   */
  arbitrages_en_attente: { id: string; objet: string; depuis: string }[];
  actions_en_retard: {
    id: string; intitule: string; responsable: string | null; echeance: string | null;
  }[];
  actions_ouvertes: number;
  entrees_main_courante: number;
}

export interface LigneMainCourante {
@@ -70,11 +77,15 @@ export const decisions = (o: string, i: Identite, crise: string) =>
  lire<{ decisions: Decision[] }>(o, i, `/api/crises/${crise}/decisions`);

/**
 * Les actions en retard, et elles seules.
 * Les actions en retard viennent du cockpit, qui les porte déjà.
 *
 * Une requête de moins sur un téléphone n'est pas une micro-optimisation : le
 * §13 demande moins de 2 % de batterie par heure en veille et une ouverture à
 * froid sous trois secondes. Redemander au serveur ce qu'il vient d'envoyer
 * coûte les deux.
 *
 * Le §2 ne promet pas la liste des actions : il promet « les actions en
 * retard ». Sur cinq pouces, une liste complète de trente actions ne se lit
 * pas ; ce qui se lit, c'est ce qui déborde.
 * Le §2 ne promet d'ailleurs pas la liste des actions : il promet « les
 * actions en retard ». Sur cinq pouces, trente actions ne se lisent pas ; ce
 * qui se lit, c'est ce qui déborde.
 */
export const actionsEnRetard = (o: string, i: Identite, crise: string) =>
  lire<{ actions: Action[] }>(o, i, `/api/crises/${crise}/actions?enRetard=true`);
export type ActionEnRetard = Cockpit["actions_en_retard"][number];
+15 −13
Original line number Diff line number Diff line
@@ -37,7 +37,6 @@ export function Crise({ instance, identite, crise, surConsigner, surRetour }: {
  const [cockpit, setCockpit] = useState<api.Cockpit | null>(null);
  const [entrees, setEntrees] = useState<api.LigneMainCourante[]>([]);
  const [decisions, setDecisions] = useState<api.Decision[]>([]);
  const [retards, setRetards] = useState<api.Action[]>([]);
  const [enFile, setEnFile] = useState<file.Entree[]>([]);
  const [vuA, setVuA] = useState<string | null>(null);
  const [erreur, setErreur] = useState<string | null>(null);
@@ -49,16 +48,14 @@ export function Crise({ instance, identite, crise, surConsigner, surRetour }: {
    // La file se lit toujours, réseau ou pas : c'est ce qui est chez soi.
    setEnFile(await file.lister(instance.id));
    try {
      const [c, mc, d, a] = await Promise.all([
      const [c, mc, d] = await Promise.all([
        api.cockpit(instance.origine, identite, crise.id),
        api.mainCourante(instance.origine, identite, crise.id),
        api.decisions(instance.origine, identite, crise.id),
        api.actionsEnRetard(instance.origine, identite, crise.id),
      ]);
      setCockpit(c);
      setEntrees(mc.entrees.slice(-12).reverse());
      setDecisions(d.decisions.filter((x) => x.retenue !== null || x.arbitrage_requis).slice(0, 8));
      setRetards(a.actions);
      setVuA(new Date().toISOString());
    } catch (e) {
      setErreur(
@@ -119,19 +116,24 @@ export function Crise({ instance, identite, crise, surConsigner, surRetour }: {
        <Titre>{crise.intitule}</Titre>
        <SousTitre>
          {t("Déclenchée le {date}", { date: f.dateHeure(crise.declenchee_at) })}
          {vuA && !perime ? ` · ${t("à jour à {heure}", { heure: f.heure(vuA) })}` : ""}
          {vuA && !perime
            ? ` · ${t("à jour à {heure}", { heure: f.heureCourte(vuA) })}`
            : ""}
        </SousTitre>
      </View>

      {erreur ? <Paragraphe ton="alerte">{erreur}</Paragraphe> : null}

      {cockpit ? (
        <Encart teinte={Number(cockpit.actions_en_retard) > 0 ? p.alerte : p.bordure}>
        <Encart teinte={cockpit.actions_en_retard.length > 0 ? p.alerte : p.bordure}>
          {/* Le temps que la cellule compte réellement : H+04:12. */}
          <Ligne libelle={t("Depuis le déclenchement")} valeur={cockpit.crise.chrono} />
          <Ligne libelle={t("Arbitrages attendus")}
            valeur={String(cockpit.arbitrages_en_attente)}
            ton={Number(cockpit.arbitrages_en_attente) > 0 ? p.arbitrage : p.texte} />
          <Ligne libelle={t("Actions en retard")} valeur={String(cockpit.actions_en_retard)}
            ton={Number(cockpit.actions_en_retard) > 0 ? p.alerte : p.texte} />
            valeur={String(cockpit.arbitrages_en_attente.length)}
            ton={cockpit.arbitrages_en_attente.length > 0 ? p.arbitrage : p.texte} />
          <Ligne libelle={t("Actions en retard")}
            valeur={String(cockpit.actions_en_retard.length)}
            ton={cockpit.actions_en_retard.length > 0 ? p.alerte : p.texte} />
          <Ligne libelle={t("Actions ouvertes")} valeur={String(cockpit.actions_ouvertes)} />
          <Ligne libelle={t("Entrées au journal")} valeur={String(cockpit.entrees_main_courante)} />
        </Encart>
@@ -160,11 +162,11 @@ export function Crise({ instance, identite, crise, surConsigner, surRetour }: {
      <Bouton titre={t("Consigner")} sur={surConsigner}
        indication={t("Écrit une entrée à la main courante. Fonctionne sans réseau.")} />

      {retards.length > 0 ? (
      {cockpit && cockpit.actions_en_retard.length > 0 ? (
        <Encart teinte={p.alerte}>
          <SousTitre>{t("Actions en retard")}</SousTitre>
          {retards.slice(0, 8).map((a) => (
            <Ligne key={a.id} libelle={a.responsable_libelle ?? t("non affectée")}
          {cockpit.actions_en_retard.slice(0, 8).map((a) => (
            <Ligne key={a.id} libelle={a.responsable ?? t("non affectée")}
              valeur={a.intitule} />
          ))}
        </Encart>
+1 −0
Original line number Diff line number Diff line
@@ -171,6 +171,7 @@ export const EN: Record<string, string> = {
  "à jour à {heure}": "current as of {heure}",
  "Ces chiffres datent de {minutes} minutes. L'instance n'a pas répondu depuis.":
    "These figures are {minutes} minutes old. The instance has not answered since.",
  "Depuis le déclenchement": "Since it started",
  "Arbitrages attendus": "Decisions awaiting arbitration",
  "Actions en retard": "Overdue actions",
  "Actions ouvertes": "Open actions",
Loading