nextcloud : les applications viennent du depot du site, plus de l'app store

user_oidc et richdocuments epinglees (version, sha256), tenues par site-cache-01 dans le depot de binaires, installees par app_tierce.yml : site d'abord, amont en repli dit, somme puis signature (occ integrity:check-app), activation. P70 comprend les listes d'artefacts. L'app store a 77 ko/s avait arrete la reconstruction de Technolibre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-08 22:47:33 -04:00
parent 7a4c55bfc9
commit c7378ed7f7
9 changed files with 306 additions and 22 deletions

View file

@ -1,5 +1,73 @@
# CHANGELOG — Set-OPS
## 2026-10-08 (117) — Les applications de Nextcloud ne viennent plus de l'app store : le site les tient
**94 preuves.** La reconstruction de Technolibre sur `7a4c55b` (règle stricte, entrée (116)) s'est
arrêtée sur `collab-01` : « Could not download app user_oidc, it was not found on the
appstore », après deux minutes. Journal de Nextcloud, à la seconde : « Response from appstore
is invalid ». Mesuré depuis `collab-01` : le catalogue de l'app store (11,7 Mo) arrivait à
**77 ko/s**, un autre fournisseur à **18,8 Mo/s** ; Nextcloud abandonne et prend la réponse
tronquée pour invalide.
### Ce n'était pas une régression, c'était une dépendance jamais fermée
Mesurée le 2026-10-05 (reconstruction de Chezlepro observée à la frontière) et jamais écrite
ici : apt est local (6,2 Go servis par le cache du site, aucun `.deb` amont), mais trois
dépendances sortent encore sur Internet à chaque reconstruction — `pip` (PyPI) pour les
runners, `ansible-galaxy` sur un runner neuf, et `occ app:install user_oidc / richdocuments`
(app store de Nextcloud). C'est la troisième qui a mordu.
### Le mécanisme existait : le dépôt de binaires du site
Depuis le 2026-09-12, `site-cache-01` tient dans `/var/lib/setops/artefacts-directs`, et sert
sous `/setops-binaires` (port 3142, flux existant), les archives que les rôles téléchargent :
Forgejo, Keycloak, Nextcloud, oauth2-proxy — déclarées une seule fois, dans le rôle qui les
consomme. Les applications de Nextcloud n'y avaient jamais été ajoutées. (Une forge-registre
avait d'abord été proposée : le mécanisme existant la rend inutile.)
**Essai d'abord** : versions relevées sur Chezlepro (`user_oidc` 8.11.0, `richdocuments` 11.1.2,
sur Nextcloud 34.0.4) ; archives de publication GitHub en 0,7 et 1,2 s ; `occ
integrity:check-app --path` valide leur code hors ligne contre l'autorité de signature de
Nextcloud ; témoin : une ligne ajoutée à un fichier donne `INVALID_HASH`.
**Fait** :
- `serveur_nextcloud_apps` : chaque application, version, `nom`, `url`, SHA-256.
- `serveur_artefacts` : le site tient ces archives (il parcourt la liste, ne la recopie pas).
- `serveur_nextcloud/tasks/app_tierce.yml` : dépôt du site d'abord, amont en repli **dit à
voix haute** ; somme vérifiée dans les deux cas ; dépôt dans `apps/` ; `occ
integrity:check-app` ; activation. Une version installée qui diffère de l'épinglée
**arrête** (une mise à niveau se décide). Plus aucun `app:install`.
- **P70** comprend désormais les listes d'artefacts : un `dest` modèle attend chacun de ses
noms, tenus seulement si le site parcourt la liste.
**Éprouvé** : `test_apps_nextcloud.py` (dans `make test`) ; témoin sur l'ancien `oidc.yml`, deux
échecs. P70 : témoin, le site ne parcourant plus la liste, échec. Lint propre. `make verifier`
conforme, **94/94**.
**Reste** : PyPI et Galaxy, même chemin. Puis la preuve : reconstruire avec l'amont du cache du
site coupé (`serveur_artefacts_hors_ligne`), et constater que rien ne sort.
## 2026-10-08 (116) — Chezlepro reconstruite sur `7a4c55b`, d'une traite
**94 preuves.** `make reconstruire-locataire TENANT=OPS-Chezlepro` (46 min, journal
`OPS-Chezlepro/logs/reconstruction-20261008-182930.log`), sans reprise : le runner tire
`7a4c55b` ; 13/13 VM ; `valider` du premier coup (15 cibles) ; pare-feu 13/13, 0 critique.
**9 jeux** restaurés depuis l'instantané `avant-raser` de 18:29, tous encore au dépôt.
Témoins : aucun écart ; Nextcloud, annuaire, AC d'Icinga, web dorsal identiques ; Loki, aucun
morceau retiré ; historique d'Icinga, 777 lignes d'avant, 0 retirée. Demandé aux bases, sur
17:30–18:29 : Prometheus, 15 cibles toutes les 10 min ; Loki, 1 000 à 2 000 lignes par tranche
de 10 min jusqu'au rasage. Sondes de sauvegarde : OK, sauf `web-dorsal-01` (aucune
application déployée, rien à sauvegarder).
**La recette corrigée n'a pas eu à attendre** : le premier dépôt d'`obs-01` a relancé
Prometheus à 19:13:02, la recette l'a interrogé à 19:13:29 (27 s, plus qu'un intervalle de
relevé). Son attente reste prête pour un écart plus court, comme les 18 s de Technolibre.
**Release** : l'exploitant applique la règle stricte. Technolibre, reconstruite sur `2dce6ea`
(recette et bilan rejoués sur `9ca9a5f`), est reconstruite à nouveau sur `7a4c55b`, pour que
les deux locataires portent le même commit avant d'étiqueter.
## 2026-10-08 (115) — La sonde du dépôt jugeait le premier groupe d'instantanés, pas le plus récent
**94 preuves.** Question de l'exploitant : « on a aussi les sondes pour vérifier que les

View file

@ -320,6 +320,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_temoins.py
python3 scripts/tests/test_genome_colis.py
python3 scripts/tests/test_verifier_depot.py
python3 scripts/tests/test_apps_nextcloud.py
python3 scripts/tests/test_adressage_derive.py
python3 scripts/tests/test_gui_intrants.py
python3 scripts/tests/test_runbooks.py

View file

@ -214,7 +214,9 @@
mode: "0644"
force: false
timeout: 60
loop: "{{ serveur_artefacts_directs }}"
# Les applications de Nextcloud (2026-10-08) : declarees par `serveur_nextcloud`, lues
# telles quelles — meme `nom`, meme `url`, rien de recopie ici.
loop: "{{ serveur_artefacts_directs + (serveur_nextcloud_apps | default([])) }}"
loop_control:
label: "{{ item.nom }}"
register: serveur_artefacts_directs_tires

View file

@ -74,6 +74,31 @@ serveur_nextcloud_http_port: 80
# IP de l'edge → trusted_proxies (pour voir la vraie IP client, pas throttler l'edge).
serveur_nextcloud_edge_ip: "{{ hostvars[(groups['serveur_nginx'] | default([]) | first) | default('')].ansible_host | default('') }}"
# --- LES APPLICATIONS NE VIENNENT PLUS DE L'APP STORE (2026-10-08) -------------------
#
# `occ app:install` les tirait d'apps.nextcloud.com a chaque construction. Le 2026-10-08,
# l'app store servait son catalogue (11,7 Mo) a 77 ko/s — 18,8 Mo/s ailleurs depuis la meme
# machine : Nextcloud a abandonne, et la reconstruction de Technolibre s'est arretee. Le
# site tient desormais ces archives dans son depot de binaires, comme celle de Nextcloud
# (`serveur_artefacts`), et le role les installe sans l'app store.
#
# EPINGLEES, VERIFIEES DEUX FOIS : la somme de l'archive (ci-dessous), puis la signature de
# son code (`occ integrity:check-app`, contre l'autorite de signature de Nextcloud, hors
# ligne ; eprouve : une ligne ajoutee a un fichier donne INVALID_HASH). Versions relevees
# sur Chezlepro, ou elles tournent sur Nextcloud 34.0.4. `nom` et `url` portent la version
# en toutes lettres : `test_apps_nextcloud.py` verifie qu'elles disent la meme.
serveur_nextcloud_apps:
- id: user_oidc
version: "8.11.0"
nom: "nextcloud-app-user_oidc-8.11.0.tar.gz"
url: "https://github.com/nextcloud-releases/user_oidc/releases/download/v8.11.0/user_oidc-v8.11.0.tar.gz"
sha256: "887920504c2f0e0d055abd66225ee7b66d756f50fe5a3739779d6b2ce108633f"
- id: richdocuments
version: "11.1.2"
nom: "nextcloud-app-richdocuments-11.1.2.tar.gz"
url: "https://github.com/nextcloud-releases/richdocuments/releases/download/v11.1.2/richdocuments-v11.1.2.tar.gz"
sha256: "96766a94cf417a716e47ed89c6ff2411c54954caa916afcdddc4d9668dffb557"
# --- SSO OIDC (Keycloak) — app user_oidc ---
serveur_nextcloud_oidc_actif: true
serveur_nextcloud_oidc_nom: "{{ organisation | default('Organisation') }}"

View file

@ -0,0 +1,125 @@
---
# UNE APPLICATION NEXTCLOUD, SANS L'APP STORE (2026-10-08) — voir `serveur_nextcloud_apps`.
#
# entree : serveur_nextcloud_app_id (user_oidc, richdocuments)
#
# Le depot du SITE d'abord, l'amont ensuite (meme patron que l'archive de Nextcloud,
# `installer.yml`) ; la somme verifiee dans les deux cas ; puis la signature du code ; puis
# l'activation. Une version installee qui differe de l'epinglee ARRETE : une mise a niveau
# d'application est une decision (et un `occ upgrade`), pas un effet de bord.
- name: "Application Nextcloud — l'application epinglee ({{ serveur_nextcloud_app_id }})"
ansible.builtin.set_fact:
serveur_nextcloud_app: >-
{{ serveur_nextcloud_apps | selectattr('id', 'equalto', serveur_nextcloud_app_id) | first }}
- name: "Application Nextcloud — deja presente ? ({{ serveur_nextcloud_app_id }})"
ansible.builtin.slurp:
src: "{{ serveur_nextcloud_racine }}/apps/{{ serveur_nextcloud_app.id }}/appinfo/info.xml"
register: serveur_nextcloud_app_info
failed_when: false
- name: "Application Nextcloud — la version presente est-elle l'epinglee ? ({{ serveur_nextcloud_app_id }})"
vars:
presente: >-
{{ (serveur_nextcloud_app_info.content | b64decode
| regex_search('<version>([^<]+)</version>', '\\1') | default([''], true) | first)
if serveur_nextcloud_app_info.content is defined else '' }}
ansible.builtin.assert:
that:
- presente | length == 0 or presente == serveur_nextcloud_app.version
fail_msg: >-
{{ serveur_nextcloud_app.id }} {{ presente }} est installee, {{ serveur_nextcloud_app.version }}
est epinglee. Une mise a niveau d'application se decide (et passe par `occ upgrade`) :
ajuster `serveur_nextcloud_apps`, ou mettre a niveau a la main.
quiet: true
- name: "Application Nextcloud — l'obtenir et la deposer ({{ serveur_nextcloud_app_id }})"
when: serveur_nextcloud_app_info.content is not defined
block:
- name: "Application Nextcloud — le cache du controleur ({{ serveur_nextcloud_app_id }})"
ansible.builtin.file:
path: "{{ serveur_nextcloud_cache_local }}"
state: directory
mode: "0700"
delegate_to: localhost
become: false
# `failed_when: false` : le depot est une COMMODITE ; sans lui, on retombe sur l'amont.
# La somme est verifiee ici aussi : un fichier altere chez le site ne passe pas.
- name: "Application Nextcloud — au depot du site, avant de sortir sur Internet ({{ serveur_nextcloud_app_id }})"
ansible.builtin.get_url:
url: "{{ setops_depot_binaires }}/{{ serveur_nextcloud_app.nom }}"
dest: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
checksum: "sha256:{{ serveur_nextcloud_app.sha256 }}"
mode: "0644"
timeout: 30
delegate_to: localhost
become: false
register: serveur_nextcloud_app_site
failed_when: false
when: setops_depot_binaires | default('') | length > 0
- name: "Application Nextcloud — dans le cache du controleur ? ({{ serveur_nextcloud_app_id }})"
ansible.builtin.stat:
path: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
checksum_algorithm: sha256
register: serveur_nextcloud_app_cache
delegate_to: localhost
become: false
# ON LE DIT, FORT : une reconstruction qui sort sur Internet doit se voir.
- name: "Application Nextcloud — absente du depot du site : prise en amont ({{ serveur_nextcloud_app_id }})"
ansible.builtin.debug:
msg: >-
{{ serveur_nextcloud_app.nom }} n'est pas au depot du site
({{ setops_depot_binaires | default('aucun depot declare') }}) : prise sur Internet.
when: not (serveur_nextcloud_app_cache.stat.exists
and serveur_nextcloud_app_cache.stat.checksum == serveur_nextcloud_app.sha256)
- name: "Application Nextcloud — en amont (repli) ({{ serveur_nextcloud_app_id }})"
ansible.builtin.get_url:
url: "{{ serveur_nextcloud_app.url }}"
dest: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
checksum: "sha256:{{ serveur_nextcloud_app.sha256 }}"
mode: "0644"
timeout: 60
delegate_to: localhost
become: false
when: not (serveur_nextcloud_app_cache.stat.exists
and serveur_nextcloud_app_cache.stat.checksum == serveur_nextcloud_app.sha256)
- name: "Application Nextcloud — deposer le code dans apps/ ({{ serveur_nextcloud_app_id }})"
ansible.builtin.unarchive:
src: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
dest: "{{ serveur_nextcloud_racine }}/apps"
owner: "{{ serveur_nextcloud_utilisateur }}"
group: "{{ serveur_nextcloud_utilisateur }}"
creates: "{{ serveur_nextcloud_racine }}/apps/{{ serveur_nextcloud_app.id }}/appinfo/info.xml"
- name: "Application Nextcloud — signature du code, puis activation ({{ serveur_nextcloud_app_id }})"
become: true
become_user: "{{ serveur_nextcloud_utilisateur }}"
environment:
PGSSLMODE: "{{ serveur_nextcloud_pg_sslmode }}"
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
when: not ansible_check_mode
block:
# Contre l'autorite de signature de Nextcloud, sans reseau. Rien a dire = intact.
- name: "Application Nextcloud — le code est-il celui que Nextcloud a signe ? ({{ serveur_nextcloud_app_id }})"
ansible.builtin.command:
argv: ["php", "{{ serveur_nextcloud_racine }}/occ", "integrity:check-app", "{{ serveur_nextcloud_app.id }}"]
register: serveur_nextcloud_app_integrite
changed_when: false
failed_when: serveur_nextcloud_app_integrite.rc != 0 or serveur_nextcloud_app_integrite.stdout | trim | length > 0
- name: "Application Nextcloud — activer ({{ serveur_nextcloud_app_id }})"
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -eo pipefail
OCC="php {{ serveur_nextcloud_racine }}/occ"
APP="{{ serveur_nextcloud_app.id }}"
if $OCC app:list --enabled | grep -qw "$APP"; then echo SETOPS_OK
else $OCC app:enable "$APP" && echo SETOPS_CHANGED; fi
register: serveur_nextcloud_app_active
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_app_active.stdout"

View file

@ -1,4 +1,10 @@
---
# L'APPLICATION VIENT DU DEPOT DU SITE, PLUS DE L'APP STORE (2026-10-08) : `app_tierce.yml`.
- name: Installer l'app richdocuments (depot du site, somme, signature, activation)
ansible.builtin.include_tasks: app_tierce.yml
vars:
serveur_nextcloud_app_id: richdocuments
- name: Collabora / Office (occ, en www-data avec TLS PG)
become: true
become_user: "{{ serveur_nextcloud_utilisateur }}"
@ -7,16 +13,6 @@
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
when: not ansible_check_mode
block:
- name: Installer + activer l'app richdocuments (idempotent)
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -eo pipefail
OCC="php {{ serveur_nextcloud_racine }}/occ"
if $OCC app:list | grep -qw richdocuments; then echo SETOPS_OK; else $OCC app:install richdocuments && echo SETOPS_CHANGED; fi
register: serveur_nextcloud_office_app
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_office_app.stdout"
- name: URL serveur -> Collabora (locale, rapide)
ansible.builtin.command:
argv: ["php", "{{ serveur_nextcloud_racine }}/occ", "config:app:set", "richdocuments", "wopi_url", "--value={{ serveur_nextcloud_wopi_url }}"]

View file

@ -1,4 +1,10 @@
---
# L'APPLICATION VIENT DU DEPOT DU SITE, PLUS DE L'APP STORE (2026-10-08) : `app_tierce.yml`.
- name: Installer l'app user_oidc (depot du site, somme, signature, activation)
ansible.builtin.include_tasks: app_tierce.yml
vars:
serveur_nextcloud_app_id: user_oidc
- name: SSO OIDC (occ, en www-data avec TLS PG)
become: true
become_user: "{{ serveur_nextcloud_utilisateur }}"
@ -7,16 +13,6 @@
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
when: not ansible_check_mode
block:
- name: Installer + activer l'app user_oidc (idempotent)
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -eo pipefail
OCC="php {{ serveur_nextcloud_racine }}/occ"
if $OCC app:list | grep -qw user_oidc; then echo SETOPS_OK; else $OCC app:install user_oidc && echo SETOPS_CHANGED; fi
register: serveur_nextcloud_oidc_app
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_oidc_app.stdout"
- name: Ne plus annoncer le formulaire de connexion locale
# Masquage, pas fermeture : `?direct=1` reste le chemin de secours documente par
# l'amont. Voir serveur_nextcloud_connexion_locale dans defaults/main.yml.

View file

@ -3882,12 +3882,29 @@ def preuve_depot_binaires_complet() -> tuple[bool, str]:
if not tenus:
return False, "Le depot de binaires ne declare aucun artefact (serveur_artefacts_directs vide)."
# LES LISTES D'ARTEFACTS (2026-10-08). Les applications de Nextcloud sont declarees en
# LISTE par leur role (`serveur_nextcloud_apps`) ; leur `dest` est donc un modele
# (`{{ serveur_nextcloud_app.nom }}`), et le site les tient en parcourant cette liste.
# Un modele attend CHACUN des noms de sa liste ; ces noms ne sont tenus que si la tache
# du site parcourt bien la liste — sans quoi le depot ne les tiendrait pas.
import yaml as _yaml
listes = {"serveur_nextcloud_app.nom": ("serveur_nextcloud", "serveur_nextcloud_apps")}
taches_site = (roles_dir / "serveur_artefacts" / "tasks" / "main.yml").read_text(encoding="utf-8")
noms_de: dict[str, list[str]] = {}
for modele, (role_liste, liste) in listes.items():
d = _yaml.safe_load((roles_dir / role_liste / "defaults" / "main.yml").read_text(encoding="utf-8")) or {}
noms_de[modele] = [str(a["nom"]) for a in d.get(liste) or []]
if liste in taches_site:
tenus |= set(noms_de[modele])
attendus: dict[str, str] = {}
for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()):
for tf in sorted((roles_dir / role / "tasks").glob("*.yml")) if (roles_dir / role / "tasks").is_dir() else []:
texte = tf.read_text(encoding="utf-8")
for dest in _re.findall(r'^\s*dest:\s*"\{\{\s*\w+_cache_local\s*\}\}/([^"]+)"', texte, _re.M):
attendus[dest] = f"{role}/tasks/{tf.name}"
m = _re.fullmatch(r'\{\{\s*([\w.]+)\s*\}\}', dest)
for nom in (noms_de.get(m.group(1), [dest]) if m else [dest]):
attendus[nom] = f"{role}/tasks/{tf.name}"
manquants = sorted(f"{nom} ({ou})" for nom, ou in attendus.items() if nom not in tenus)
if manquants:

View file

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Les applications Nextcloud viennent du depot du SITE, epinglees — plus de l'app store.
POURQUOI (2026-10-08). `occ app:install` tirait `user_oidc` et `richdocuments`
d'apps.nextcloud.com a chaque construction ; l'app store servant son catalogue a 77 ko/s,
la reconstruction de Technolibre s'est arretee. Ce test garde la declaration coherente : une
version qui differerait entre `nom` et `url` ferait tenir au site un fichier que personne ne
demande, et la divergence se lirait « le depot sert un fichier », pas « le mauvais ».
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[2]
ECHECS: list[str] = []
def verifier(cond: bool, msg: str) -> None:
print(("OK " if cond else "ECHEC ") + msg)
if not cond:
ECHECS.append(msg)
def main() -> int:
apps = yaml.safe_load((RACINE / "roles/serveur_nextcloud/defaults/main.yml").read_text())["serveur_nextcloud_apps"]
verifier(len({a["id"] for a in apps}) == len(apps) and len(apps) > 0, f"{len(apps)} application(s), identifiants uniques")
for a in apps:
v, i = a["version"], a["id"]
verifier(f"-{i}-{v}." in a["nom"] and a["nom"].startswith("nextcloud-app-"), f"{i} : le nom porte l'id et la version ({a['nom']})")
verifier(f"/{i}/" in a["url"] and f"v{v}" in a["url"], f"{i} : l'url porte l'id et la version")
verifier(re.fullmatch(r"[0-9a-f]{64}", a["sha256"]) is not None, f"{i} : sha256 bien formee")
taches = (RACINE / "roles/serveur_artefacts/tasks/main.yml").read_text()
verifier("serveur_nextcloud_apps" in taches, "le site tient les applications declarees par serveur_nextcloud")
# Les commandes, pas les commentaires : `restaurer.yml` raconte encore pourquoi
# `occ app:install` refusait une base restauree trop tot.
roles = "".join(l for p in (RACINE / "roles/serveur_nextcloud/tasks").glob("*.yml")
for l in p.read_text().splitlines(keepends=True) if not l.lstrip().startswith("#"))
verifier("app:install" not in roles, "plus aucune tache ne tire une application de l'app store (`app:install`)")
for f, app in (("oidc.yml", "user_oidc"), ("collabora.yml", "richdocuments")):
t = (RACINE / "roles/serveur_nextcloud/tasks" / f).read_text()
verifier(f"serveur_nextcloud_app_id: {app}" in t, f"{f} installe {app} par app_tierce.yml")
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1
print("\nLes applications Nextcloud sont epinglees, tenues par le site, installees sans l'app store.")
return 0
if __name__ == "__main__":
sys.exit(main())