From 56d202a8bb663f2e84f4188c7c05198f60d4c86d Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sat, 12 Sep 2026 21:37:28 -0400 Subject: [PATCH] pools nommes comme les depots, et les cles sortent du poste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LE NOM D UN POOL EST CELUI DE SON DEPOT. Chezlepro-17 devient OPS-Chezlepro : le seed se lisait dans le nom, ce qui obligeait a connaitre le codage — et surtout le nom CHANGEAIT si l index changeait, ce que la renumerotation du site a montre le jour meme. Site-OPS ne derive de rien, et c est le point : les machines du genome ne dependent d aucun index, elles sont l infrastructure SUR laquelle les index vivent. Sans ce bloc elles restaient hors de tout pool. P69 — l amorcage d un tenant designe-t-il le site REEL ? dns_amorcage et artefacts_amorcage sont ecrits a la main, volontairement : au moment ou ils servent la machine ne resout aucun nom. Mais ils designent des machines DU SITE, et n ont pas suivi son renumerotage. La reconstruction du locataire s est arretee sur Failed to update apt cache, a quinze couches de sa cause. La preuve ne juge que les valeurs qui PRETENDENT designer le site : viser 9.9.9.9 est un choix, pas un oubli. LES CLES SORTENT DU POSTE, EN CLAIR, ET C EST RAISONNE. Support perdu : LUKS s en charge. Poste compromis : la seconde couche n aide pas, les originaux sont dans ~/.config sur ce meme poste. Elle coutait une phrase de passe stockee nulle part — le seul point que la procedure ne couvre pas. Option --support-chiffre explicite ; le defaut reste GPG, parce qu un support non chiffre est le cas le plus frequent. Et ma note qui disait les cles sorties depuis le 5 septembre etait fausse : le support ne portait que le depot hors site du 1er. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q --- AGENTS.md | 2 +- CHANGELOG.md | 54 ++++++++++++++++++++++++++ docs/audit/preuve-2026-09-12.md | 7 ++-- docs/devis-services.md | 2 +- scripts/devis_proxmox_pools.py | 25 +++++++++++- scripts/exporter_cles.py | 50 ++++++++++++++++++++++++ scripts/inventory_rules.py | 33 +++++++++++++--- scripts/prouver.py | 67 +++++++++++++++++++++++++++++++++ 8 files changed, 229 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0620321..67c56f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente ## Écrire, puis relire (D-68) `--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**. -C'est aussi ce que font les 68 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 69 preuves de `make prouver` : elles lisent le dépôt, sans le moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 84c51f4..fdb28a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # CHANGELOG — Set-OPS +## 2026-09-12 (4) — Pools nommes comme les depots, et les cles sortent du poste + +### Le nom d'un pool est celui de son depot + +`Chezlepro-17` devient `OPS-Chezlepro`. Le seed se lisait dans le nom, ce qui etait juste +mais obligeait a connaitre le codage — et surtout **le nom CHANGEAIT si l'index changeait** : +la renumerotation du site l'a montre le jour meme. Ce qu'on lit dans la console est +desormais ce qu'on tape dans un terminal. + +L'unicite ne vient plus de l'index mais du nom de dossier, deja unique par construction. + +**`Site-OPS` ne derive de rien, et c'est le point.** Les machines du genome — cache, forge, +AC, noms, depot, supervision — ne dependent d'aucun index : elles sont l'infrastructure SUR +laquelle les index vivent. Un nom invariable dit cela, et il reste le meme d'un hebergeur a +l'autre. Sans ce bloc, les sept restaient hors de tout pool : sept orphelines a cote de +trois flottes rangees. + + CONFORME : 4 pool(s) Proxmox, 41 VM placee(s), aucun nom ni VMID en collision. + +### P69 — l'amorcage d'un tenant designe-t-il le site REEL ? + +`dns_amorcage` et `artefacts_amorcage` sont ecrits A LA MAIN dans les intrants d'un tenant, +et c'est voulu : au moment ou ils servent, la premiere machine ne resout aucun nom. Une +adresse, pas un nom. + +Mais ces adresses designent des machines DU SITE. Le site a change d'index ; elles n'ont +pas suivi. La reconstruction du locataire s'est arretee sur `infra-pki-01` avec +« Failed to update apt cache » — a quinze couches de sa cause. + +La preuve ne juge que les valeurs qui PRETENDENT designer le site : un tenant peut +legitimement s'amorcer sur un resolveur public, et `OPS-Technolibre` comme `OPS-Patient0` +visent `9.9.9.9`. C'est un choix, pas un oubli. + +### Les cles sortent du poste, en clair, et c'est raisonne + +Les neuf fichiers (1 644 o) sont sur la cle USB LUKS. **En tar clair**, par +`--support-chiffre`, option ajoutee ce jour et explicite — le defaut reste GPG. + +Deux menaces, deux reponses : + + le support est perdu ou vole -> LUKS s'en charge + le poste est compromis -> la 2e couche n'aide pas : les originaux sont + dans ~/.config sur ce meme poste + +La couche GPG n'ajoutait donc presque rien, et coutait une phrase de passe STOCKEE NULLE +PART — le seul point que la procedure elle-meme ne couvre pas. On echangeait un risque de +divulgation deja couvert contre un risque de PERTE qui ne l'etait pas. + +Pourquoi ce n'est pas le defaut : un support non chiffre est le cas le plus frequent, et +le silence ne doit jamais pencher du cote de la divulgation. + +**Et la note qui disait les cles sorties depuis le 2026-09-05 etait fausse** : le support +ne portait que le depot hors site du 1er. Verifie avant d'ecrire, pas apres. + ## 2026-09-12 (3) — Trois reconstructions : trouver, verifier, prouver La premiere reconstruction avait trouve seize defauts. Deux tours de plus ont ete faits, et diff --git a/docs/audit/preuve-2026-09-12.md b/docs/audit/preuve-2026-09-12.md index 0950c1b..80c7fc1 100644 --- a/docs/audit/preuve-2026-09-12.md +++ b/docs/audit/preuve-2026-09-12.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (67 OK · 0 echec · 1 saute) +- **Verdict** : ✅ CONFORME (68 OK · 0 echec · 1 saute) ## Preuves @@ -40,7 +40,7 @@ | P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 49 groupe(s), 87 regle(s). | | P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). | | P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. | -| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. | +| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 4 pool(s) Proxmox, 41 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 62 scripts expliques et atteignables, 119 cibles make documentees, 68 roles avec README. | @@ -69,7 +69,7 @@ | P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. | | P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. | | P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. | -| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (68 preuves, 68 roles, 41 groupes). | +| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (69 preuves, 68 roles, 41 groupes). | | P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. | | P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). | | P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `904ece3` (publie le 2026-09-12). | @@ -81,6 +81,7 @@ | P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). | | P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 6 service(s) expose(s) portent le nom du plan (6 groupe(s) derive(s)). | | P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. | +| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. | ## Couverture des affirmations ✅ du registre diff --git a/docs/devis-services.md b/docs/devis-services.md index 5bfa0eb..ea58927 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met ## Le trou qu'il comble -`scripts/prouver.py` porte 68 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 69 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est cohérent **avec lui-même** — que les handlers existent, que les intrants ont un propriétaire, que rien n'est codé en dur. diff --git a/scripts/devis_proxmox_pools.py b/scripts/devis_proxmox_pools.py index 7c02d6b..326b9ab 100644 --- a/scripts/devis_proxmox_pools.py +++ b/scripts/devis_proxmox_pools.py @@ -42,7 +42,7 @@ import yaml RACINE = Path(__file__).resolve().parents[1] sys.path.insert(0, str(RACINE / "scripts")) -from inventory_rules import ( +from inventory_rules import (POOL_SITE, instance_courante, # noqa: E402 charger_serveurs, deriver_nomenclature, @@ -172,6 +172,29 @@ def main(argv: list[str]) -> int: return 0 devis = construire(decouvrir()) + # LE SITE EST UN POOL AUSSI (2026-09-12). Les machines du genome — cache, forge, AC, + # noms, depot, supervision — ne derivent d'aucun index : elles sont l'infrastructure + # SUR laquelle les index vivent. D'ou un nom invariable, `Site-OPS`, identique d'un + # hebergeur a l'autre. + # + # Sans ce bloc, elles restaient hors de tout pool : dans la console, sept machines + # sans appartenance a cote de trois flottes rangees — exactement le desordre que le + # reste du devis supprime. + try: + import underlay as _u + _plan = (_u.lire_plan_site("serveurs.yml") or {}).get("serveurs") or {} + _membres = sorted( + ({"nom": n, "vmid": int(v["vmid"]), "etat": str(v.get("etat", "actif"))} + for n, v in _plan.items() if str(v.get("vmid", "")).strip().isdigit()), + key=lambda m: m["vmid"]) + if _membres: + devis["pools"].insert(0, { + "pool": POOL_SITE, "tenant": "(site)", "index": None, + "membres": _membres, "sans_vmid": [], + }) + except Exception: + pass # aucun underlay monte : le site n'existe pas de ce point de vue + if args.verifier: blocs = devis["pools"] if not blocs: diff --git a/scripts/exporter_cles.py b/scripts/exporter_cles.py index 3263295..3de8451 100644 --- a/scripts/exporter_cles.py +++ b/scripts/exporter_cles.py @@ -174,6 +174,9 @@ def main() -> int: # phrase de passe et on ne risque pas d'ecraser ce qui est deja verifie. ap.add_argument("--compagnons", action="store_true", help="Deposer seulement le script de restauration et son mode d'emploi") + ap.add_argument("--support-chiffre", action="store_true", + help="le support est DEJA chiffre au repos (LUKS) : ecrire un tar " + "clair plutot qu'une archive GPG (voir la note dans le code)") ap.add_argument("--nom", default="setops-cles", help="Nom de base de l'archive (defaut: setops-cles)") args = ap.parse_args() @@ -220,6 +223,53 @@ def main() -> int: print(" RECENSEMENT SEUL — rien n'a ete ecrit.") return 0 + # LE SUPPORT EST DEJA CHIFFRE : LA SECONDE COUCHE PROTEGE DE QUOI ? (2026-09-12) + # + # La question posee par l'exploitant, et elle se tient. Deux menaces, deux reponses : + # + # Le support est PERDU ou VOLE — LUKS s'en charge. Sans la phrase de deverrouillage + # du volume, il n'y a rien a lire. + # + # Le POSTE est compromis — la seconde couche n'aide pas : les originaux sont dans + # `~/.config` sur ce meme poste. Qui tient le poste tient deja les cles ; la copie + # chiffree ne lui retire rien. + # + # La couche GPG n'ajoute donc presque rien, et coûte une phrase de passe STOCKEE + # NULLE PART — que la procedure elle-meme designe comme le seul point non couvert : + # perdue, l'archive est du bruit. On echange un risque de divulgation deja couvert + # contre un risque de PERTE qui ne l'est pas. + # + # `--support-chiffre` est donc un choix legitime, mais il DOIT etre explicite : le + # defaut reste l'archive GPG, parce qu'un support non chiffre est le cas le plus + # frequent et que le silence ne doit jamais pencher du cote de la divulgation. + if args.support_chiffre: + archive = dest / f"{args.nom}-{os.uname().nodename}.tar" + if archive.exists(): + raise SystemExit( + f"REFUS : {archive} existe deja.\n" + " On n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule.\n" + " La deplacer ou choisir un autre --nom.") + with tarfile.open(archive, "w") as tar: + for f, _ in fichiers: + tar.add(f, arcname=f.name) + archive.chmod(0o600) + # RELIRE CE QU'ON VIENT D'ECRIRE, comme pour l'archive chiffree : une sauvegarde + # qu'on n'a pas relue est une intention. + with tarfile.open(archive, "r") as tar: + noms = set(tar.getnames()) + manquants = [f.name for f, _ in fichiers if f.name not in noms] + if manquants: + archive.unlink(missing_ok=True) + raise SystemExit(f"ECHEC de la relecture — archive SUPPRIMEE. " + f"Manquent : {', '.join(manquants)}") + print(f" Ecrit EN CLAIR dans l'archive : {archive}") + print(f" {len(noms)} fichier(s) relus et retrouves.\n") + print(" LA PROTECTION EST CELLE DU SUPPORT. Ce tar est lisible par quiconque") + print(" monte ce volume deverrouille. Il n'a sa place que sur un support chiffre") + print(" au repos, et le volume doit etre DEMONTE quand on ne s'en sert pas.") + _deposer_compagnons(dest) + return 0 + archive = dest / f"{args.nom}-{os.uname().nodename}.tar.gpg" if archive.exists(): raise SystemExit( diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index c4becff..43c46ee 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -312,12 +312,35 @@ def pool_de(nom_instance: str, index: int) -> str: justement la frontiere que Set-OPS etablit. La flotte derivee a donc ses propres pools, nommes comme tout le reste : depuis le seed. - `index` est deja garanti unique dans la federation par P21 : le nom l'est donc - aussi, sans registre supplementaire. Forme : `Chezlepro-17`, `Technolibre-11`. + LE NOM DU POOL EST CELUI DU DEPOT (2026-09-12, decision de l'exploitant). C'etait + `Chezlepro-17` — le seed lu dans le nom. Juste, mais il obligeait a connaitre le + codage pour relier un pool a son depot, et il CHANGEAIT si l'index changeait : la + renumerotation du site l'a montre le jour meme. + + Un pool porte desormais le nom du depot, tel quel : `OPS-Chezlepro`, + `OPS-Technolibre`. Ce qu'on lit dans la console est ce qu'on tape dans un + terminal, et un renumerotage ne renomme plus rien. + + L'unicite ne vient plus de l'index mais du nom de dossier — qui est deja unique + par construction, deux depots freres ne pouvant porter le meme. `index` reste au + parametre : il ne sert plus au nom, il sert a refuser un appel incoherent. + + Les pools `Env.Tenant` anterieurs a Set-OPS (`Prod.Chezlepro`, `Lab.KBR`...) + restent l'ANCIEN MONDE : on n'y touche pas, et on n'y verse pas la flotte generee. """ - base = re.sub(r"^OPS-", "", nom_instance) - base = re.sub(r"[^A-Za-z0-9._-]", "", base) or "tenant" - return f"{base}-{int(index)}" + valider_index(index) + base = re.sub(r"[^A-Za-z0-9._-]", "", nom_instance) or "tenant" + return base + + +# LE POOL DU SITE NE DERIVE DE RIEN, ET C'EST LE POINT (2026-09-12). Les machines du +# genome — celles qui portent le cache, la forge, l'AC, les noms et le depot — ne +# dependent d'aucun index : elles sont l'infrastructure SUR laquelle les index vivent. +# Un nom invariable dit exactement cela, et il reste le meme d'un hebergeur a l'autre. +# +# A ne pas confondre avec le pool `Set-OPS` deja present sur le cluster de reference, +# qui appartient a l'ancien monde. +POOL_SITE = "Site-OPS" def est_groupe_operationnel(groupe: str) -> bool: diff --git a/scripts/prouver.py b/scripts/prouver.py index 52aa07d..27f3501 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -3278,6 +3278,71 @@ def preuve_cle_telechargee_non_vide() -> tuple[bool, str]: f"avant de s'en servir.") +def preuve_amorcage_suit_le_site() -> tuple[bool, str]: + """Les adresses d'amorcage d'un tenant designent-elles le site REEL ? + + UNE SECONDE LISTE QUI SUIT UNE PREMIERE (2026-09-12). `dns_amorcage` et + `artefacts_amorcage` sont ecrits A LA MAIN dans les intrants d'un tenant, et c'est + voulu : au moment ou ils servent, la premiere machine du tenant ne resout aucun nom. + Une adresse, pas un nom — la declaration le dit explicitement. + + Mais ces adresses designent des machines DU SITE. Quand le site a pris son propre + index et que ses zones sont passees de `10.0.3x` a `10.37.3x`, elles n'ont pas suivi : + la reconstruction du locataire s'est arretee sur `infra-pki-01`, apt muet, avec un + `resolv.conf` pointant une machine qui n'existait plus. + + Le message ne disait pas « adresse perimee ». Il disait « Failed to update apt cache », + a quinze couches de sa cause. + + CE QUE CETTE PREUVE NE FAIT PAS : juger une adresse qui ne designe pas le site. Un + tenant peut legitimement s'amorcer sur un resolveur public — `OPS-Technolibre` et + `OPS-Patient0` visent `9.9.9.9`, et c'est un choix, pas un oubli. On ne verifie que + les valeurs qui PRETENDENT designer une machine du site : meme troisieme octet de + zone, autre deuxieme octet. Une adresse etrangere au site n'est pas notre affaire. + """ + try: + sys.path.insert(0, str(RACINE / "scripts")) + import underlay as _u + if not _u.charger(): + return True, "Aucun underlay monte : aucun site a suivre." + attendus = { + "dns_amorcage": _u.adresses_site_portant("serveur_resolveur"), + "artefacts_amorcage": _u.adresses_site_portant("serveur_artefacts"), + } + except Exception as e: + return True, f"Site illisible ({type(e).__name__}) : rien a comparer." + if not any(attendus.values()): + return True, "Le site ne porte ni resolveur ni cache : rien a suivre." + + # Les troisiemes octets des zones du site — la signature d'une adresse « du site ». + octets_site = {a.split(".")[2] for v in attendus.values() for a in v if a.count(".") == 3} + + fautes, verifies = [], 0 + for intrants in sorted((RACINE.parent).glob("OPS-*/inventories/*/group_vars/all/10-intrants.yml")): + try: + d = yaml.safe_load(intrants.read_text(encoding="utf-8")) or {} + except Exception: + continue + for cle, bons in attendus.items(): + brut = str(d.get(cle) or "").strip() + if not brut or not bons: + continue + adr = brut.split(":")[0].split(",")[0].strip() + if adr.count(".") != 3 or adr.split(".")[2] not in octets_site: + continue # ne pretend pas designer le site : pas notre affaire + verifies += 1 + if adr not in bons: + fautes.append(f"{intrants.parents[3].name} : `{cle}` vise {adr}, " + f"le site porte {', '.join(bons)}") + if not verifies: + return True, "Aucun tenant ne s'amorce sur une adresse du site." + if fautes: + return False, ("Adresse d'amorcage perimee — la premiere machine du tenant ne " + "resoudra rien, et l'erreur parlera d'apt :\n - " + + "\n - ".join(fautes)) + return True, (f"{verifies} adresse(s) d'amorcage designent bien une machine du site.") + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -3434,6 +3499,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_nom_public_vient_du_plan}, {"id": "P68", "titre": "Cle de depot telechargee : mesuree avant d'etre utilisee", "refs": [], "func": preuve_cle_telechargee_non_vide}, + {"id": "P69", "titre": "Amorcage d'un tenant : l'adresse designe le site REEL", + "refs": [], "func": preuve_amorcage_suit_le_site}, ]