diff --git a/CHANGELOG.md b/CHANGELOG.md index b56fc9a..002de42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # CHANGELOG — Set-OPS +## 2026-10-07 (105) — Les releases et le wiki n'allaient que là où on les poussait à la main + +**94 preuves.** La forge du site fait autorité (D-81), mais deux choses n'y arrivaient +jamais d'elles-mêmes : les étiquettes de release, et le wiki. + +### Les releases manquaient à la forge du site + +`v2026.10.07` poussée sur eregion, la question suivante a été : et la forge du site ? Mesure +(`git ls-remote --tags`) : elle portait `v2026.10.04`, pas `v2026.08.21` ni `v2026.10.07`. Le +colis de `make publier` (`genome_colis.py`) ne transporte que `refs/heads/`, et +`publier.py` ne poussait que la branche vers eregion. `v2026.10.04` y avait été portée par un +geste à part, que rien ne gardait. Les deux étiquettes manquantes ont été poussées par +`origin` (rebond SSH du poste), et relues identiques des deux côtés. + +**Fait** : +- `genome_colis.py` : le colis emporte toutes les étiquettes (`bundle create … --tags`) ; le + runner les récupère et les pousse avec la branche, **sans forcer** : une étiquette déjà + présente sous un autre objet est refusée, des deux côtés. Une release ne bouge pas. +- `genome_pousser.yml` : la forge est relue par son API (`git/refs/tags`), et doit porter + toutes les étiquettes du poste. +- `publier.py` pousse aussi les étiquettes vers eregion. + +**Option écartée** : faire d'eregion un miroir poussé par la forge du site. Mesuré : eregion +porte deux branches de contribution non fusionnées (`essai_integration`, 4 commits ; +`fix_client_pki`, demande de fusion n° 5 ouverte), qu'un miroir effacerait. L'exploitant +garde les deux forges poussées depuis le poste, par prudence envers les contributions. + +**Éprouvé** : `test_genome_colis.py` (dans `make test`), sur de vrais dépôts git jetables : +sans étiquette, la tête passe ; une release annotée arrive sur la forge, même objet ; une +étiquette déplacée est refusée, la forge garde l'ancienne. Témoin : sur l'ancien +`genome_colis.py`, le test échoue dès « la release voyage jusqu'à la forge ». `make +verifier` conforme, **94/94**. + +**Pas encore éprouvé** : la relecture par l'API de la forge (`git/refs/tags`). Le premier +`make publier` qui suivra le dira : « N etiquette(s) du poste, toutes sur la forge ». + +### Le wiki de la forge du site était vide + +`make wiki-publier` publie vers UNE forge (`WIKI_REMOTE`) ; son témoin +(`docs/audit/wiki-publie.yml`) montrait la dernière publication, le 2026-09-29, vers eregion +seulement. Rien n'avait jamais publié vers la forge du site. Sa branche de wiki demandée à +l'API de Forgejo (`wiki_branch: main`), le wiki est publié sur les deux forges ; relus : +même arbre (`b3a70ba`), 96 pages, 8 figures, des deux côtés. Le témoin ne retient qu'une +forge (la dernière publiée, eregion) : P60 ne voit pas l'autre. + ## 2026-10-07 (104) — L'historique de supervision traverse la reconstruction : les deux locataires, sur `2cbfa2d` **94 preuves.** Les deux locataires rasés et reconstruits par le site qui les nomme, sur le diff --git a/Makefile b/Makefile index 4fe5158..938bd2e 100644 --- a/Makefile +++ b/Makefile @@ -318,6 +318,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_raser.py python3 scripts/tests/test_raser_resultat.py python3 scripts/tests/test_temoins.py + python3 scripts/tests/test_genome_colis.py python3 scripts/tests/test_adressage_derive.py python3 scripts/tests/test_gui_intrants.py python3 scripts/tests/test_runbooks.py diff --git a/docs/audit/wiki-publie.yml b/docs/audit/wiki-publie.yml index 7154c68..489ec04 100644 --- a/docs/audit/wiki-publie.yml +++ b/docs/audit/wiki-publie.yml @@ -1,5 +1,5 @@ --- # Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main. remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git -source: 3fe7e3c -date: 2026-09-29 +source: e9215a2 +date: 2026-10-07 diff --git a/playbooks/maintenance/genome_pousser.yml b/playbooks/maintenance/genome_pousser.yml index d0880a0..2309dc2 100644 --- a/playbooks/maintenance/genome_pousser.yml +++ b/playbooks/maintenance/genome_pousser.yml @@ -160,6 +160,7 @@ loop: "{{ genome_a_pousser }}" loop_control: label: "{{ item.dest }}" + register: genome_emballe changed_when: true - name: Porter les colis jusqu'au runner @@ -255,6 +256,45 @@ label: "{{ item.depot }}" index_var: idx + # LES RELEASES AUSSI (2026-10-07). Le colis ne portait que la branche : `v2026.08.21` et + # `v2026.10.07` manquaient a la forge du site. Il porte desormais les etiquettes ; on + # demande a la forge si elle a TOUTES celles du poste. 404 = aucune etiquette sur ce + # depot, ce qui est juste quand le poste n'en a pas non plus. + - name: Demander à la forge les étiquettes qu'elle porte + ansible.builtin.uri: + url: >- + https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation + | default('genome') }}/{{ item.depot }}/git/refs/tags + validate_certs: false + status_code: [200, 404] + loop: "{{ genome_a_pousser }}" + loop_control: + label: "{{ item.depot }}" + register: genome_forge_etiquettes + retries: 4 + delay: 3 + until: genome_forge_etiquettes is succeeded + changed_when: false + + - name: La forge porte-t-elle toutes les releases du poste ? + vars: + genome_etiquettes_poste: "{{ (genome_emballe.results[idx].stdout | from_json).etiquettes | default([]) }}" + genome_etiquettes_forge: >- + {{ (genome_forge_etiquettes.results[idx].json | default([]) if genome_forge_etiquettes.results[idx].status == 200 else []) + | map(attribute='ref') | map('regex_replace', '^refs/tags/', '') | list }} + ansible.builtin.assert: + that: + - genome_etiquettes_poste | difference(genome_etiquettes_forge) | length == 0 + fail_msg: >- + {{ item.depot }} : la forge n'a pas + {{ genome_etiquettes_poste | difference(genome_etiquettes_forge) | join(', ') }}. + success_msg: >- + {{ item.depot }} : {{ genome_etiquettes_poste | length }} etiquette(s) du poste, toutes sur la forge. + loop: "{{ genome_a_pousser }}" + loop_control: + label: "{{ item.depot }}" + index_var: idx + # LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20). # # C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le diff --git a/scripts/genome_colis.py b/scripts/genome_colis.py index f1db9b1..1e1ecd2 100644 --- a/scripts/genome_colis.py +++ b/scripts/genome_colis.py @@ -28,6 +28,13 @@ DEUX GESTES, DEUX COTES : genome_colis.py etat Des deux cotes. Ce que cette copie porte, pour le confronter a la forge. +LES ETIQUETTES VOYAGENT AVEC LA BRANCHE (2026-10-07). Le colis ne portait que `main` : les +releases n'arrivaient jamais sur la forge du site, qui fait pourtant autorite (D-81). +`v2026.08.21` et `v2026.10.07` y manquaient, et il a fallu les pousser a la main. Le colis +emporte desormais toutes les etiquettes du poste ; le runner les recoit et les pousse. Une +etiquette qui existe deja SOUS UN AUTRE OBJET est refusee, des deux cotes : une release ne +bouge pas. + `--ff-only` est le garde-fou. Si les deux histoires ont diverge, on s'arrete plutot que de fabriquer une fusion que personne n'a decidee — et le message de git nomme alors exactement ce qu'il faut reconcilier. @@ -55,13 +62,18 @@ def _git(depot: Path, *args: str, env: dict | None = None) -> str: return (r.stdout or "").strip() +def etiquettes(depot: Path) -> list[str]: + return sorted(e for e in _git(depot, "tag", "-l").splitlines() if e) + + def emballer(depot: Path, colis: Path, branche: str) -> int: if not (depot / ".git").exists(): raise SystemExit(f"{depot} n'est pas un depot git — rien a emballer.") colis.parent.mkdir(parents=True, exist_ok=True) - _git(depot, "bundle", "create", str(colis), branche) + _git(depot, "bundle", "create", str(colis), branche, "--tags") print(json.dumps({"depot": str(depot), "colis": str(colis), - "tete": _git(depot, "log", "-1", "--format=%H")})) + "tete": _git(depot, "log", "-1", "--format=%H"), + "etiquettes": etiquettes(depot)})) return 0 @@ -101,6 +113,10 @@ def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None, f"refs/heads/{branche}:refs/remotes/colis/{branche}") _git(depot, "merge", "--ff-only", f"refs/remotes/colis/{branche}") apres = _git(depot, "log", "-1", "--format=%H") + # Sans `--force` : une etiquette deja presente sous un autre objet fait echouer la + # recuperation (« would clobber existing tag ») — c'est voulu. + if any("refs/tags/" in l for l in _git(depot, "bundle", "list-heads", str(colis)).splitlines()): + _git(depot, "fetch", str(colis), "refs/tags/*:refs/tags/*") env = None if cle: @@ -114,14 +130,15 @@ def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None, # seule l'ECRITURE s'authentifie, et seulement le temps de cette commande. r = subprocess.run( ["git", "-C", str(depot), "-c", f"remote.origin.pushurl={pushurl}", - "push", "origin", branche], + "push", "origin", branche, "refs/tags/*:refs/tags/*"], capture_output=True, text=True, env=env) sortie = ((r.stdout or "") + (r.stderr or "")).strip() if r.returncode != 0: raise SystemExit(f"la poussee a echoue :\n {sortie}") print(json.dumps({"avant": avant, "apres": apres, - "pousse": f"{branche} -> {branche}" in sortie, "sortie": sortie[:400]})) + "pousse": f"{branche} -> {branche}" in sortie, + "etiquettes": etiquettes(depot), "sortie": sortie[:400]})) return 0 diff --git a/scripts/publier.py b/scripts/publier.py index 66c6f64..9ea5a8d 100644 --- a/scripts/publier.py +++ b/scripts/publier.py @@ -16,9 +16,11 @@ liste que `genome-pousser` — lue, pas recopiee) : demande de fusion acceptee, par exemple) : publier sans les integrer les ecraserait sur la forge du site ; 2. signaler ce qui n'est pas committe — cela ne part pas ; - 3. pousser sur eregion ; -puis `make genome-pousser`, puis `make genome-etat` : la forge du site doit porter la meme -tete que le poste, sinon on le dit. + 3. pousser sur eregion, la branche ET les etiquettes (2026-10-07 : les releases n'allaient + nulle part d'elles-memes ; une etiquette deplacee est refusee, une release ne bouge pas) ; +puis `make genome-pousser`, qui porte aussi les etiquettes et verifie que la forge les a, +puis `make genome-etat` : la forge du site doit porter la meme tete que le poste, sinon on +le dit. make publier tous les depots du genome make publier DEPOT=ops-chezlepro @@ -81,7 +83,7 @@ def main() -> int: if sale: print(f" ~ {d['depot']:<17} {len(sale)} changement(s) NON committe(s) — ils ne partent pas") avance = git(depot, "rev-list", "--count", f"{distant}..HEAD").stdout.strip() or "?" - r = git(depot, "push", "-q", "eregion", branche) + r = git(depot, "push", "-q", "eregion", branche, "refs/tags/*:refs/tags/*") if r.returncode != 0: refus.append(d["depot"]) print(f" !! {d['depot']:<17} push refuse : {r.stderr.strip()[:120]}") diff --git a/scripts/tests/test_genome_colis.py b/scripts/tests/test_genome_colis.py new file mode 100644 index 0000000..56e31a5 --- /dev/null +++ b/scripts/tests/test_genome_colis.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""`genome_colis.py` : le colis porte la branche ET les releases jusqu'à la forge du site. + +POURQUOI (2026-10-07). Le colis ne portait que `main`. Les étiquettes de release +n'arrivaient donc jamais sur la forge du site, qui fait autorité (D-81) : `v2026.08.21` et +`v2026.10.07` y manquaient, et il a fallu les pousser à la main. + +Sur de vrais dépôts git, dans un répertoire jetable (aucun réseau) : + - un poste, une forge nue, un runner cloné depuis la forge ; + - le poste fait un commit et étiquette une release ; `emballer`, puis `recevoir` sur le + runner, avec la forge pour `pushurl` ; + - la forge porte la nouvelle tête ET l'étiquette ; un dépôt sans étiquette passe aussi ; + - une étiquette déplacée sur le poste est REFUSÉE : une release ne bouge pas. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[2] +COLIS = RACINE / "scripts" / "genome_colis.py" +ENV = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"} + +ECHECS: list[str] = [] + + +def verifier(cond: bool, msg: str) -> None: + print(("OK " if cond else "ECHEC ") + msg) + if not cond: + ECHECS.append(msg) + + +def git(*args: str, cwd: Path | None = None) -> str: + r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, env=ENV) + if r.returncode != 0: + raise RuntimeError(f"git {' '.join(args)} : {r.stderr.strip()}") + return r.stdout.strip() + + +def colis(*args: str) -> subprocess.CompletedProcess: + return subprocess.run([sys.executable, str(COLIS), *args], capture_output=True, text=True, env=ENV) + + +def monter(d: Path) -> tuple[Path, Path, Path]: + forge, poste, runner = d / "forge.git", d / "poste", d / "runner" + git("init", "-q", "--bare", "-b", "main", str(forge)) + git("init", "-q", "-b", "main", str(poste)) + (poste / "a").write_text("1") + git("add", "a", cwd=poste) + git("commit", "-q", "-m", "un", cwd=poste) + git("push", "-q", str(forge), "main", cwd=poste) + git("clone", "-q", str(forge), str(runner)) + return forge, poste, runner + + +def porter(poste: Path, runner: Path, forge: Path, d: Path) -> subprocess.CompletedProcess: + paquet = d / "x.bundle" + e = colis("emballer", str(poste), str(paquet)) + if e.returncode != 0: + return e + return colis("recevoir", str(runner), str(paquet), str(forge)) + + +def main() -> int: + with tempfile.TemporaryDirectory() as d: + d = Path(d) + forge, poste, runner = monter(d) + + # Un depot sans etiquette : rien ne change pour lui. + (poste / "a").write_text("2") + git("commit", "-q", "-am", "deux", cwd=poste) + r = porter(poste, runner, forge, d) + verifier(r.returncode == 0 and git("rev-parse", "main", cwd=forge) == git("rev-parse", "HEAD", cwd=poste), + f"sans etiquette : la forge porte la tete du poste ({r.stderr.strip()[-160:]})") + + # Une release annotee. + (poste / "a").write_text("3") + git("commit", "-q", "-am", "trois", cwd=poste) + git("tag", "-a", "v1", "-m", "release v1", cwd=poste) + r = porter(poste, runner, forge, d) + recu = json.loads(r.stdout) if r.returncode == 0 else {} + verifier(r.returncode == 0 and "v1" in git("tag", "-l", cwd=forge).split(), + f"la release voyage jusqu'a la forge ({r.stderr.strip()[-160:]})") + verifier(git("rev-parse", "v1", cwd=forge) == git("rev-parse", "v1", cwd=poste), + "la forge porte le MEME objet d'etiquette que le poste (annotee, pas recreee)") + verifier(recu.get("etiquettes") == ["v1"], f"le runner dit les etiquettes qu'il porte ({recu.get('etiquettes')})") + e = json.loads(colis("emballer", str(poste), str(d / "y.bundle")).stdout) + verifier(e.get("etiquettes") == ["v1"], "l'emballage dit les etiquettes du poste") + + # Une release deplacee sur le poste : refusee, la forge garde l'ancienne. + ancienne = git("rev-parse", "v1", cwd=forge) + (poste / "a").write_text("4") + git("commit", "-q", "-am", "quatre", cwd=poste) + git("tag", "-f", "-a", "v1", "-m", "v1 deplacee", cwd=poste) + r = porter(poste, runner, forge, d) + verifier(r.returncode != 0 and git("rev-parse", "v1", cwd=forge) == ancienne, + f"une etiquette deplacee est refusee, la forge garde la release ({r.stderr.strip()[-120:]})") + + if ECHECS: + print(f"\n{len(ECHECS)} echec(s).") + return 1 + print("\nLe colis porte la branche et les releases ; une release ne bouge pas.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())