Réplication de nœuds : réseau et DNS par l'API, multipath, chrony, sysctl par SSH
Deuxième mode de réplication, séparé de la réplication cluster vers cluster
(sélecteur « Clusters (API) | Nœuds (fichiers) » en haut de page) : un profil
réplique la configuration système d'un nœud maître vers des nœuds cibles.
- réseau par /nodes/{node}/network (interfaces.new puis rechargement), adresses
et passerelles propres à chaque nœud, mapping des noms d'interfaces par cible ;
DNS par /nodes/{node}/dns ; multipath.conf, wwids (ajout seul), chrony,
sysctl.d, interfaces.d (hors sdn) par SSH
- découverte des nœuds (/cluster/status), accès SSH par nœud (mot de passe du
cluster ou propre), clé d'hôte épinglée, contrôle du nom du nœud
- dry-run avec diff des fichiers ; apply (admin) avec contrôle SHA-256,
sauvegarde et restore.sh sur le nœud, écriture atomique, rechargements,
restauration si un rechargement échoue, timer systemd de restauration si le
nœud est injoignable après le rechargement du réseau, confirmation tapée
- vue Nœuds et profils, journal séparé par mode, démo avec nœuds simulés
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dc1a7ff40e
commit
1e667ce8b9
24 files changed
+4973
-65
No files matched your search
@@ -31,6 +31,40 @@ avec un avertissement), firewall, storage, pools, ACL, jobs, comptes `@pam` et a
|
||||
|
||||
Détail des routes, schémas et accès, avec les sources : [docs/api-pve.md](docs/api-pve.md).
|
||||
|
||||
## Réplication de nœuds
|
||||
|
||||
Deuxième mode, séparé du premier dans l'interface (sélecteur « Clusters (API) | Nœuds
|
||||
(fichiers) » en haut de page) : un **profil** réplique la configuration système d'un
|
||||
**nœud maître** vers des **nœuds cibles**, du même cluster ou d'un autre.
|
||||
|
||||
| Élément | Accès | Rechargement | Particularités |
|
||||
|---|---|---|---|
|
||||
| `network` | API `/nodes/{node}/network` (écrit `interfaces.new`) | `PUT /nodes/{node}/network` (ifreload -a) | `cidr`, `cidr6`, `gateway`, `gateway6` en `override` : chaque nœud garde ses adresses ; mapping des noms d'interfaces par cible (eno1=enp65s0f0, VLAN suivis) ; interface physique absente = bloquant ; options que l'API n'écrit pas (bond-miimon, post-up…) signalées, non répliquées |
|
||||
| `dns` | API `/nodes/{node}/dns` | aucun | domaine de recherche obligatoire pour l'API |
|
||||
| `multipath` | SSH `/etc/multipath.conf` | `multipathd reconfigure` | |
|
||||
| `wwids` | SSH `/etc/multipath/wwids` | `multipathd reconfigure` | ajout seul des WWID du maître |
|
||||
| `chrony` | SSH `/etc/chrony/chrony.conf`, `sources.d/*.sources` | `systemctl restart chrony` | |
|
||||
| `sysctl` | SSH `/etc/sysctl.d/*.conf` | `sysctl --system` | |
|
||||
| `interfaces-d` | SSH `/etc/network/interfaces.d/*` | `ifreload -a` | `interfaces.d/sdn` (généré par le SDN) jamais touché |
|
||||
|
||||
Jamais répliqués : `/etc/pve`, corosync, hostname, machine-id, clés SSH, IQN iSCSI, `/etc/frr`.
|
||||
|
||||
- **Accès** : « Découvrir les nœuds » lit `/cluster/status` de chaque cluster (adresse de
|
||||
chaque nœud). SSH en root sur chaque nœud, avec le mot de passe de son cluster ou le sien ;
|
||||
clé d'hôte épinglée au premier test, qui vérifie aussi que le nœud répond bien sous son nom.
|
||||
- **Règles** : politiques par champ (réseau, DNS), lignes protégées par expression régulière
|
||||
pour les fichiers (gardées de la cible), valeurs propres à une cible (`vmbr1 cidr=…`).
|
||||
- **Apply** (administrateurs), un nœud après l'autre, arrêt au premier échec : un script SSH
|
||||
vérifie que chaque fichier est celui du dry-run (SHA-256), sauvegarde dans
|
||||
`/var/lib/pve-confsync/backup/<date>/` avec un `restore.sh`, écrit de façon atomique
|
||||
(fichier temporaire puis `mv`, droits et propriétaire gardés) ; l'API écrit les interfaces
|
||||
et le DNS ; les rechargements suivent. Un rechargement en échec restaure les sauvegardes.
|
||||
- **Filet réseau** : avant de recharger le réseau, un timer systemd est armé sur le nœud
|
||||
(`pve-confsync-restore-<date>`) : sans nouvelle connexion SSH et API dans les 120 s, le nœud
|
||||
relance `restore.sh` et `ifreload -a` de lui-même. Confirmation tapée (nom du nœud, ou du
|
||||
profil en global) dès qu'un plan recharge le réseau. Refus si la cible a déjà des
|
||||
changements réseau en attente (`interfaces.new`).
|
||||
|
||||
## Fonctionnement
|
||||
|
||||
```
|
||||
@@ -152,6 +186,7 @@ internal/model/ objets génériques, valeurs canoniques
|
||||
internal/adapter/ un adaptateur par type : routes, champs calculés, locaux, immuables
|
||||
internal/config/ configuration en mémoire : clusters, politiques, mappings, sélection
|
||||
internal/engine/ lecture, plan, apply (verrou SDN, rollback), vérification
|
||||
internal/nodesync/ réplication de nœuds : réseau et DNS (API), fichiers (SSH), diff, sauvegarde, restauration
|
||||
docs/api-pve.md routes et schémas vérifiés dans les sources PVE
|
||||
```
|
||||
|
||||
|
||||
@@ -104,6 +104,37 @@ Champs (schémas : [VlanPlugin.pm](https://github.com/proxmox/pve-network/blob/c
|
||||
**À valider sur un vrai cluster** : script d'écriture du shadow, forme réelle de
|
||||
`/access/users?full=1`, mise à jour d'un utilisateur avec champs vidés.
|
||||
|
||||
## Réplication de nœuds (vérifié le 2026-10-23, pve-manager f643ce4b, pve-common master)
|
||||
|
||||
| Méthode | Chemin | Usage |
|
||||
|---|---|---|
|
||||
| GET | `/nodes/{node}/network` ([Network.pm](https://github.com/proxmox/pve-manager/blob/f643ce4bceb99e8b23094cd04075405281eb3f27/PVE/API2/Network.pm)) | interfaces, lues dans `interfaces.new` s'il existe ; `lo` retiré |
|
||||
| POST | `/nodes/{node}/network` | création (`iface`, `type` + champs) dans `interfaces.new` |
|
||||
| PUT | `/nodes/{node}/network/{iface}` | mise à jour (`type` obligatoire, `delete=`) |
|
||||
| DELETE | `/nodes/{node}/network/{iface}` | suppression (prune) |
|
||||
| PUT | `/nodes/{node}/network` | renomme `interfaces.new` puis `ifreload -a` dans une tâche (UPID) |
|
||||
| DELETE | `/nodes/{node}/network` | annule les changements en attente (supprime `interfaces.new`) |
|
||||
| GET / PUT | `/nodes/{node}/dns` ([Nodes.pm](https://github.com/proxmox/pve-manager/blob/f643ce4bceb99e8b23094cd04075405281eb3f27/PVE/API2/Nodes.pm)) | `search` (obligatoire au PUT), `dns1` à `dns3` ; le PUT réécrit tous les serveurs |
|
||||
|
||||
Points d'attention :
|
||||
|
||||
- champs écrits (`$confdesc`) : `type`, `comments`, `comments6`, `autostart`, `bridge_vlan_aware`,
|
||||
`bridge_vids`, `bridge_ports`, `ovs_*`, `slaves`, `bond_mode`, `bond-primary`,
|
||||
`bond_xmit_hash_policy`, `vlan-raw-device`, `vlan-id`, `gateway`, `gateway6`, `cidr`, `cidr6`, `mtu`.
|
||||
Le GET renvoie aussi `address`/`netmask` (forme canonique : `cidr`), `bridge_stp`,
|
||||
`bridge_fd`, `bond_miimon` (écrits par défaut par PVE), `options` (lignes inconnues de
|
||||
l'API, jamais écrites), `active`, `exists`, `families`, `method`, `priority` : non répliqués.
|
||||
- le PUT d'une interface recalcule `method` et `families` à partir des paramètres reçus :
|
||||
sans `address`/`cidr`, une interface statique devient `manual`. pve-confsync envoie donc
|
||||
toute la configuration de l'interface à chaque mise à jour.
|
||||
- `interfaces.d/*` n'est pas lu par l'API ([Interfaces.pm](https://github.com/proxmox/pve-common/blob/master/src/PVE/Network/Interfaces.pm)) : répliqué en fichier par SSH.
|
||||
|
||||
Scripts SSH (première ligne `# confsync:<étape>`) : lecture (`stat`, `base64 -w0`),
|
||||
préparation (contrôle SHA-256, `cp -p` vers la sauvegarde, `restore.sh`, écriture par
|
||||
`mktemp` + `mv`), restauration, `systemd-run --on-active=120s` / `systemctl stop <unité>.timer`,
|
||||
rechargements. Le script de préparation est testé tel quel avec le shell local
|
||||
(`internal/nodesync/script_test.go`) ; **le reste est à valider sur les clusters de labo.**
|
||||
|
||||
## Droits
|
||||
|
||||
Accès en `root@pam` (API + SSH, même mot de passe), sans TFA. La référence (primaire)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Proposition : réplication de fichiers système nœud à nœud (SSH)
|
||||
|
||||
Statut : validée par Cédric le 2026-10-23 (liste v1, API dès que possible, identifiants
|
||||
SSH hérités du cluster avec surcharge par nœud), puis implémentée. Écart avec la
|
||||
proposition : `/etc/network/interfaces` passe par l'API PVE (`/nodes/{node}/network`) et
|
||||
non par fichier, et le DNS est ajouté par l'API (`/nodes/{node}/dns`). Le fonctionnement
|
||||
réel est décrit dans le README (« Réplication de nœuds »).
|
||||
|
||||
Deuxième mode de réplication, séparé de la réplication cluster à cluster (API) :
|
||||
un **nœud maître** et un ou plusieurs **nœuds cibles** (même cluster ou non),
|
||||
fichiers lus et écrits en SSH root. Même moteur : lecture, normalisation, diff,
|
||||
plan, apply, vérification.
|
||||
|
||||
Constat dans le code actuel : l'accès SSH est défini **par cluster, sur un seul
|
||||
hôte** (`SSHHost`, `internal/config/config.go:33`). Il faut le passer **par
|
||||
nœud** : nœuds découverts par l'API (`GET /cluster/status`, champ `ip`), identifiants
|
||||
root hérités du cluster et surchargeables par nœud, clé d'hôte épinglée par nœud
|
||||
(même TOFU que maintenant).
|
||||
|
||||
## 1. Fichiers candidats
|
||||
|
||||
Légende : 🟢 v1 recommandée, 🟡 v1 possible avec garde-fous, 🔴 exclu.
|
||||
|
||||
| Fichier | Intérêt | Risques | Propre au nœud (mapper / exclure) | Rechargement |
|
||||
|---|---|---|---|---|
|
||||
| 🟢 `/etc/network/interfaces` | bonds, VLAN, bridges, MTU identiques partout | **perte d'accès au nœud** ; noms de NIC différents d'un nœud à l'autre | `address`, `gateway`, `hwaddress`, IP de management, éventuellement noms de NIC (`nic1` maître ↔ `enp65s0f0` cible) | `ifreload -a` |
|
||||
| 🟢 `/etc/network/interfaces.d/*` | idem | **`interfaces.d/sdn` est généré par le SDN PVE** | exclure `sdn` d'office | `ifreload -a` |
|
||||
| 🟢 `/etc/multipath.conf` | `defaults`, `blacklist`, `devices` identiques sur tout le cluster | erreur de syntaxe = chemins perdus | rien en général | `multipathd reconfigure` |
|
||||
| 🟢 `/etc/multipath/wwids` | mêmes LUN vues par tous les nœuds | ajouter un WWID local (disque système) par erreur | mode additif seulement, jamais de suppression | `multipathd reconfigure` |
|
||||
| 🟡 `/etc/multipath/bindings` | alias `mpathX` identiques sur tous les nœuds (important si LVM partagé référence les noms) | renommer un alias en service | additif seulement, conflit d'alias = bloquant | `multipathd reconfigure` |
|
||||
| 🟢 `/etc/chrony/chrony.conf` (+ `sources.d`) | mêmes sources NTP | faible | rien | `systemctl restart chrony` |
|
||||
| 🟡 `/etc/systemd/timesyncd.conf` | si chrony absent | faible | rien | `systemctl restart systemd-timesyncd` |
|
||||
| 🟢 `/etc/sysctl.d/*.conf` | tuning réseau / mémoire homogène | réglage inadapté au matériel | rien ; ne jamais toucher les fichiers des paquets (`/usr/lib/sysctl.d`) | `sysctl --system` |
|
||||
| 🟡 `/etc/modprobe.d/*.conf` | options ZFS (`zfs_arc_max`), blacklist de modules | effet au reboot seulement ; ARC dépend de la RAM | `zfs_arc_max` à surcharger par nœud | `update-initramfs -u`, **redémarrage requis** (signalé, jamais fait) |
|
||||
| 🟡 `/etc/apt/sources.list.d/*.sources` | mêmes dépôts (enterprise / no-subscription / ceph) | dépôt enterprise sans abonnement sur la cible | rien | aucun (pas d'`apt update` automatique) |
|
||||
| 🟡 `/etc/lvm/lvm.conf` | `global_filter` pour iSCSI / multipath | **filtre faux = volumes invisibles** | rien en général | aucun ; vérification `pvs` avant / après |
|
||||
| 🟡 `/etc/iscsi/iscsid.conf` | timeouts, `node.startup` | **contient les secrets CHAP** | secrets chiffrés au repos, jamais affichés en clair dans le diff | `systemctl restart iscsid` (signalé) |
|
||||
| 🟡 `/etc/vzdump.conf` | défauts de sauvegarde par nœud | faible | `tmpdir` éventuellement | aucun |
|
||||
| 🟡 `/etc/postfix/main.cf` | relais SMTP des notifications | faible | `myhostname` | `postfix reload` |
|
||||
| 🔵 `/etc/resolv.conf` | DNS identiques | écrasé par DHCP / resolvconf | rien | aucun. **Existe aussi en API** (`/nodes/{node}/dns`), à préférer : à vérifier dans l'API viewer |
|
||||
| 🔵 `/etc/hosts` | résolution des nœuds entre eux | ligne du nœud lui-même ; corosync dépend du nom | ligne `127.0.1.1` et la ligne du nœud cible | aucun. **Existe aussi en API** (`/nodes/{node}/hosts`), à vérifier |
|
||||
| 🔴 `/etc/pve/*` (dont `corosync.conf`) | déjà partagé par pmxcfs dans un cluster | casser le cluster | | |
|
||||
| 🔴 `/etc/corosync/*`, `/etc/hostname`, `/etc/machine-id`, clés SSH d'hôte, `/etc/iscsi/initiatorname.iscsi` | | identité unique du nœud | | |
|
||||
| 🔴 `/etc/frr/*` | | géré par le SDN EVPN (hors périmètre) | | |
|
||||
| 🔴 `/etc/kernel/cmdline`, `/etc/default/grub` | | démarrage du nœud | | v2 éventuellement |
|
||||
|
||||
🔵 = je propose de passer par l'API PVE plutôt que par le fichier (déjà le modèle du reste de l'outil).
|
||||
|
||||
**Ma recommandation v1 :** `interfaces` (+ `interfaces.d` hors `sdn`), `multipath.conf`, `wwids`, `chrony.conf`, `sysctl.d`. Le reste en v1.1 une fois le mécanisme éprouvé.
|
||||
|
||||
## 2. Modèle
|
||||
|
||||
**Profil de réplication** : un nœud maître, des nœuds cibles, une liste de fichiers, et par fichier des règles.
|
||||
|
||||
**Règles par fichier**, deux niveaux :
|
||||
- **Adaptateur structuré** pour `interfaces` (stanzas `iface`) et `multipath.conf` (sections) : politique par champ comme pour l'API (sync / ignorer / surcharger par cible). Exemple : `iface vmbr0 / address` = surcharger, valeur par cible.
|
||||
- **Mode texte** pour les autres : diff ligne à ligne, avec des **lignes protégées** (regex) conservées telles quelles sur la cible, et des **variables** (`{{hostname}}`, `{{ip}}`, valeurs saisies par cible).
|
||||
- **Mapping de noms de NIC** par cible (`eno1` → `enp1s0f0`), appliqué avant le diff.
|
||||
|
||||
**Déroulé :**
|
||||
1. **Lecture** : `cat` + `sha256sum` + propriétaire et droits, sur le maître et chaque cible.
|
||||
2. **Diff** : diff unifié par fichier et par cible, après application des règles. Statut : à jour / écarts / bloquant (ex. NIC du maître absente sur la cible, alias multipath en conflit).
|
||||
3. **Dry-run** (par défaut) : rien n'est écrit, on affiche le fichier final exact et la commande de rechargement.
|
||||
4. **Apply**, après **confirmation explicite** (comme le SDN), **un nœud à la fois**, arrêt au premier échec :
|
||||
- le hash de la cible doit être celui lu au plan (sinon : modifié entre-temps, on refuse) ;
|
||||
- **sauvegarde** sur le nœud (`/var/lib/pve-confsync/backup/<chemin>.<horodatage>`) et en base (SQLite, historique) ;
|
||||
- **validation** avant écriture quand l'outil existe : `multipath -t` sur un fichier temporaire, contrôle de syntaxe ifupdown2 (`ifup --no-act`, option exacte à vérifier) ;
|
||||
- écriture **atomique** (fichier temporaire + `mv`), propriétaire et droits conservés ;
|
||||
- **rechargement** (colonne de droite du tableau), seulement si coché ;
|
||||
- **vérification** : relecture du hash, et pour le réseau, reconnexion SSH.
|
||||
5. **Filet de sécurité réseau** : avant `ifreload -a`, on programme sur le nœud une restauration automatique (`systemd-run --on-active=120s` qui remet la sauvegarde et relance `ifreload -a`). Si l'outil se reconnecte en SSH dans le délai, il annule la restauration. Sinon le nœud revient seul à l'ancienne config.
|
||||
6. **Suppression** : jamais par défaut. Un fichier absent du maître n'est pas supprimé sur la cible sauf option explicite (cohérent avec le mode additif du reste).
|
||||
|
||||
**Journal et audit** : chaque exécution (profil, nœud, fichier, hash avant / après, sauvegarde, sortie du rechargement) dans le journal existant, avec un type « Nœuds ».
|
||||
|
||||
**Droits** : opérateur = dry-run, admin = apply (le SSH root est le droit le plus fort de l'outil).
|
||||
|
||||
## 3. Maquette de l'interface
|
||||
|
||||
Un sélecteur en tête sépare les deux modes. Chaque mode garde les onglets Écarts / Profils (ou Clusters) / Journal.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ pve-confsync [ Clusters (API) | Nœuds (fichiers) ] 👤 ⏻ │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ [Écarts] [Profils] [Journal] Profil: [Réseau stockage ▾] 2 écarts │
|
||||
│ [Dry-run global] [Appliquer]│
|
||||
├────────────────────────────┬─────────────────────────────────────────┤
|
||||
│ MAÎTRE pve1 (lab-a) │ pve2 · /etc/network/interfaces ÉCARTS │
|
||||
│ │ │
|
||||
│ CIBLES │ auto bond0 │
|
||||
│ ┌────────────────────────┐ │ iface bond0 inet manual │
|
||||
│ │ pve2 (lab-a) ÉCARTS │ │ - bond-mode active-backup │
|
||||
│ │ interfaces ✎ 3 │ │ + bond-mode 802.3ad │
|
||||
│ │ multipath.conf ✓ │ │ + bond-xmit-hash-policy layer3+4 │
|
||||
│ │ chrony.conf ✓ │ │ iface vmbr0 inet static │
|
||||
│ └────────────────────────┘ │ address 10.0.0.12/24 🔒 surchargé │
|
||||
│ ┌────────────────────────┐ │ bridge-ports bond0 │
|
||||
│ │ pve3 (lab-a) À JOUR │ │ │
|
||||
│ └────────────────────────┘ │ Après écriture : ifreload -a ☑ │
|
||||
│ ┌────────────────────────┐ │ Restauration auto si perte SSH : 120 s │
|
||||
│ │ pve7 (lab-b) BLOQUANT │ │ │
|
||||
│ │ interfaces NIC eno2 │ │ [Dry-run] [Appliquer sur pve2] │
|
||||
│ │ absente, à mapper │ │ │
|
||||
│ └────────────────────────┘ │ │
|
||||
└────────────────────────────┴─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Onglet **Profils** : maître, cibles (nœuds découverts par cluster, cases à cocher), fichiers (cases parmi la liste du §1), règles par fichier (champs à surcharger, lignes protégées, mapping de NIC par cible).
|
||||
|
||||
Fenêtre de **confirmation** d'apply : liste des nœuds dans l'ordre, fichiers, commandes de rechargement, avertissements (« redémarrage requis », « accès réseau »), saisie du nom du nœud pour confirmer quand `interfaces` est concerné.
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
1. Liste v1 : celle recommandée ci-dessus, ou plus large ?
|
||||
2. `resolv.conf` et `hosts` par l'API plutôt que par fichier : d'accord ?
|
||||
3. Identifiants SSH : hérités du cluster avec surcharge par nœud, d'accord ?
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"pve-confsync/internal/adapter"
|
||||
"pve-confsync/internal/model"
|
||||
"pve-confsync/internal/nodesync"
|
||||
"pve-confsync/internal/pve"
|
||||
"pve-confsync/internal/pve/pvetest"
|
||||
"pve-confsync/internal/store"
|
||||
@@ -104,6 +106,45 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
|
||||
b.Shadow["alice"] = ref.Shadow["alice"]
|
||||
b.Shadow["bob"] = ref.Shadow["bob"]
|
||||
|
||||
// nodes of the node replication: pve1 (master) and pve2 in pve-rennes,
|
||||
// pve-a1 in site-a with other NIC names
|
||||
n1, n2, a1 := ref.AddNode("pve1"), ref.AddNode("pve2"), a.AddNode("pve-a1")
|
||||
n1.Net = map[string]map[string]any{
|
||||
"eno1": {"type": "eth", "mtu": "9000"},
|
||||
"eno2": {"type": "eth", "mtu": "9000"},
|
||||
"bond0": {"type": "bond", "slaves": "eno1 eno2", "bond_mode": "802.3ad", "bond_xmit_hash_policy": "layer3+4", "mtu": "9000"},
|
||||
"vmbr0": {"type": "bridge", "bridge_ports": "bond0", "bridge_vlan_aware": "1", "cidr": "10.0.0.11/24", "gateway": "10.0.0.1", "autostart": "1"},
|
||||
"bond0.50": {"type": "vlan", "mtu": "9000"},
|
||||
"vmbr1": {"type": "bridge", "bridge_ports": "bond0.50", "cidr": "10.5.0.11/24", "comments": "stockage iSCSI", "autostart": "1"},
|
||||
}
|
||||
n2.Net = map[string]map[string]any{
|
||||
"eno1": {"type": "eth", "mtu": "9000"},
|
||||
"eno2": {"type": "eth", "mtu": "9000"},
|
||||
"bond0": {"type": "bond", "slaves": "eno1 eno2", "bond_mode": "active-backup", "mtu": "9000"},
|
||||
"vmbr0": {"type": "bridge", "bridge_ports": "bond0", "cidr": "10.0.0.12/24", "gateway": "10.0.0.1", "autostart": "1"},
|
||||
}
|
||||
a1.Net = map[string]map[string]any{
|
||||
"enp65s0f0": {"type": "eth"},
|
||||
"enp65s0f1": {"type": "eth"},
|
||||
"bond0": {"type": "bond", "slaves": "enp65s0f0 enp65s0f1", "bond_mode": "802.3ad", "bond_xmit_hash_policy": "layer3+4"},
|
||||
"vmbr0": {"type": "bridge", "bridge_ports": "bond0", "bridge_vlan_aware": "1", "cidr": "192.168.10.21/24", "gateway": "192.168.10.1", "autostart": "1"},
|
||||
}
|
||||
n1.DNS = map[string]string{"search": "rennes.lab", "dns1": "10.0.0.53", "dns2": "10.0.0.54"}
|
||||
n2.DNS = map[string]string{"search": "rennes.lab", "dns1": "10.0.0.53"}
|
||||
a1.DNS = map[string]string{"search": "rennes.lab", "dns1": "10.0.0.53", "dns2": "10.0.0.54"}
|
||||
mp := "defaults {\n user_friendly_names yes\n find_multipaths no\n}\nblacklist {\n devnode \"^sda\"\n}\ndevices {\n device {\n vendor \"NETAPP\"\n product \"LUN.*\"\n path_grouping_policy group_by_prio\n no_path_retry queue\n }\n}\n"
|
||||
n1.Files["/etc/multipath.conf"] = pvetest.FakeFile{Content: mp}
|
||||
n2.Files["/etc/multipath.conf"] = pvetest.FakeFile{Content: strings.Replace(mp, "no_path_retry queue", "no_path_retry 12", 1)}
|
||||
n1.Files["/etc/multipath/wwids"] = pvetest.FakeFile{Content: "# Multipath wwids, Version : 1.0\n/3600a098038303053453f463045747a41/\n/3600a098038303053453f463045747a42/\n"}
|
||||
n2.Files["/etc/multipath/wwids"] = pvetest.FakeFile{Content: "# Multipath wwids, Version : 1.0\n/3600a098038303053453f463045747a41/\n"}
|
||||
n1.Files["/etc/chrony/chrony.conf"] = pvetest.FakeFile{Content: "server ntp1.rennes.lab iburst\nserver ntp2.rennes.lab iburst\ndriftfile /var/lib/chrony/chrony.drift\nmakestep 1.0 3\n"}
|
||||
n2.Files["/etc/chrony/chrony.conf"] = pvetest.FakeFile{Content: "pool 2.debian.pool.ntp.org iburst\ndriftfile /var/lib/chrony/chrony.drift\nmakestep 1.0 3\n"}
|
||||
a1.Files["/etc/chrony/chrony.conf"] = n1.Files["/etc/chrony/chrony.conf"]
|
||||
n1.Files["/etc/sysctl.d/90-network.conf"] = pvetest.FakeFile{Content: "net.core.rmem_max = 16777216\nnet.core.wmem_max = 16777216\n"}
|
||||
a1.Files["/etc/sysctl.d/90-network.conf"] = n1.Files["/etc/sysctl.d/90-network.conf"]
|
||||
a1.Files["/etc/sysctl.d/99-local.conf"] = pvetest.FakeFile{Content: "vm.swappiness = 10\n"}
|
||||
a1.Files["/etc/multipath.conf"] = n1.Files["/etc/multipath.conf"]
|
||||
|
||||
// legacy: PVE 8, refused by the version check
|
||||
old := pvetest.New("8.4.1")
|
||||
|
||||
@@ -140,6 +181,24 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
|
||||
st.SaveSettings(ctx, store.Settings{Kinds: model.Kinds,
|
||||
Policies: map[model.Kind]map[string]adapter.Policy{model.KindDatacenter: {"keyboard": adapter.Sync}}}),
|
||||
}
|
||||
node := func(cluster string, n *pvetest.Node) error {
|
||||
host, port := n.SSHAddr()
|
||||
if err := st.SaveNode(ctx, cluster, n.Name, host, port, "", false); err != nil {
|
||||
return err
|
||||
}
|
||||
return st.PinNode(ctx, cluster, n.Name, n.SSHHostKey(), nil)
|
||||
}
|
||||
steps = append(steps,
|
||||
st.SetNodes(ctx, "pve-rennes", []store.Discovered{{Name: "pve1", IP: "127.0.0.1"}, {Name: "pve2", IP: "127.0.0.1"}}),
|
||||
st.SetNodes(ctx, "site-a", []store.Discovered{{Name: "pve-a1", IP: "127.0.0.1"}}),
|
||||
node("pve-rennes", n1), node("pve-rennes", n2), node("site-a", a1),
|
||||
st.SaveProfile(ctx, "", nodesync.Profile{Name: "réseau et stockage", Master: "pve-rennes/pve1",
|
||||
Targets: []string{"pve-rennes/pve2", "site-a/pve-a1"},
|
||||
Items: []string{"network", "dns", "multipath", "wwids", "chrony", "sysctl"},
|
||||
Mapping: map[string]nodesync.TargetMap{
|
||||
"pve-rennes/pve2": {Values: map[string]map[string]string{"network/vmbr1": {"cidr": "10.5.0.12/24"}}},
|
||||
"site-a/pve-a1": {NICs: map[string]string{"eno1": "enp65s0f0", "eno2": "enp65s0f1"}},
|
||||
}}))
|
||||
for _, u := range []struct{ name, role string }{{"admin", store.RoleAdmin}, {"operateur", store.RoleOperator}, {"lecteur", store.RoleViewer}} {
|
||||
steps = append(steps, st.SaveUser(ctx, u.name, u.role, Password))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package nodesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pve-confsync/internal/model"
|
||||
)
|
||||
|
||||
// NodeInfo is what was read from one node.
|
||||
type NodeInfo struct {
|
||||
Key string `json:"name"` // "cluster/node", "name" like the cluster cards
|
||||
Cluster string `json:"cluster"`
|
||||
Node string `json:"node"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
ReadAt time.Time `json:"readAt"`
|
||||
Millis int64 `json:"ms"`
|
||||
}
|
||||
|
||||
// Field compares one field of an interface or of the DNS servers.
|
||||
type Field struct {
|
||||
Name string `json:"name"`
|
||||
Policy Policy `json:"policy"`
|
||||
Ref any `json:"ref,omitempty"` // master
|
||||
Mapping any `json:"mapping,omitempty"` // value of the target mapping
|
||||
Current any `json:"current,omitempty"` // target
|
||||
Target any `json:"target,omitempty"` // after the replication
|
||||
Change string `json:"change,omitempty"` // add, set, unset or empty
|
||||
}
|
||||
|
||||
// Row statuses (same as the cluster rows).
|
||||
const (
|
||||
StatusSame = "same"
|
||||
StatusCreate = "create"
|
||||
StatusUpdate = "update"
|
||||
StatusDelete = "delete"
|
||||
StatusExtra = "extra" // only on the target, kept
|
||||
StatusError = "error"
|
||||
)
|
||||
|
||||
// Row is one object of a target: an interface, the DNS servers or a file.
|
||||
type Row struct {
|
||||
Kind string `json:"kind"` // item id
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Status string `json:"status"`
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
Diff []Line `json:"diff,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// Target is the state of one target node.
|
||||
type Target struct {
|
||||
NodeInfo
|
||||
Plan Plan `json:"plan"`
|
||||
Digest string `json:"digest"`
|
||||
Rows []Row `json:"rows"`
|
||||
Counts map[string]int `json:"counts"`
|
||||
}
|
||||
|
||||
// State is a full comparison of the master with its targets.
|
||||
type State struct {
|
||||
At time.Time `json:"at"`
|
||||
Profile string `json:"profile"`
|
||||
Items []string `json:"items"`
|
||||
Prune bool `json:"prune"`
|
||||
Master NodeInfo `json:"reference"` // "reference" like the cluster state
|
||||
Targets []Target `json:"clients"`
|
||||
|
||||
prof *Profile
|
||||
master Snapshot
|
||||
targets map[string]Snapshot
|
||||
eps map[string]Endpoint
|
||||
}
|
||||
|
||||
// Analyze reads the master and the targets (in parallel) and compares them.
|
||||
// only limits the targets (empty: all). A node that cannot be read is
|
||||
// reported in its Error field, it does not stop the others.
|
||||
func Analyze(ctx context.Context, prof *Profile, eps map[string]Endpoint, only []string) *State {
|
||||
st := &State{At: time.Now().UTC(), Profile: prof.Name, Items: prof.Items, Prune: prof.Prune, Targets: []Target{},
|
||||
prof: prof, targets: map[string]Snapshot{}, eps: eps}
|
||||
st.Master, st.master = read(ctx, eps, prof.Master, prof.Items)
|
||||
want := map[string]bool{}
|
||||
for _, n := range only {
|
||||
want[n] = true
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
for _, key := range prof.Targets {
|
||||
if len(only) > 0 && !want[key] {
|
||||
continue
|
||||
}
|
||||
st.Targets = append(st.Targets, Target{NodeInfo: info(key)})
|
||||
idx := len(st.Targets) - 1
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ni, snap := read(ctx, eps, key, prof.Items)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
st.Targets[idx].NodeInfo = ni
|
||||
if ni.Error == "" {
|
||||
st.targets[key] = snap
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for i := range st.Targets {
|
||||
t := &st.Targets[i]
|
||||
t.Counts = map[string]int{}
|
||||
if t.Error != "" || st.Master.Error != "" {
|
||||
continue
|
||||
}
|
||||
t.Plan = MakePlan(prof, st.master, st.targets[t.Key])
|
||||
t.Digest = t.Plan.Digest()
|
||||
t.Rows = rows(prof, st.master, st.targets[t.Key], t.Plan)
|
||||
for _, r := range t.Rows {
|
||||
t.Counts[r.Status]++
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func info(key string) NodeInfo {
|
||||
ni := NodeInfo{Key: key}
|
||||
for i := len(key) - 1; i >= 0; i-- {
|
||||
if key[i] == '/' {
|
||||
ni.Cluster, ni.Node = key[:i], key[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
return ni
|
||||
}
|
||||
|
||||
func read(ctx context.Context, eps map[string]Endpoint, key string, items []string) (NodeInfo, Snapshot) {
|
||||
ni := info(key)
|
||||
ni.ReadAt = time.Now().UTC()
|
||||
start := time.Now()
|
||||
ep, ok := eps[key]
|
||||
if !ok {
|
||||
ni.Error = fmt.Sprintf("unknown node %s (discover the nodes of its cluster)", key)
|
||||
return ni, Snapshot{}
|
||||
}
|
||||
snap, err := Read(ctx, ep, items)
|
||||
if err != nil {
|
||||
ni.Error = err.Error()
|
||||
}
|
||||
ni.Version = snap.Version
|
||||
ni.Warnings = snap.Warnings
|
||||
ni.Millis = time.Since(start).Milliseconds()
|
||||
return ni, snap
|
||||
}
|
||||
|
||||
// rows describes every object of the master and of the target.
|
||||
func rows(prof *Profile, master, target Snapshot, plan Plan) []Row {
|
||||
changes := map[string]Change{}
|
||||
for _, c := range plan.Changes {
|
||||
changes[c.Key()] = c
|
||||
}
|
||||
errs := map[string][]string{}
|
||||
for _, e := range plan.Errors {
|
||||
if e.Key != "" {
|
||||
errs[e.Key] = append(errs[e.Key], e.Message)
|
||||
}
|
||||
}
|
||||
tm := prof.Mapping[target.Key]
|
||||
var out []Row
|
||||
seen := map[string]bool{}
|
||||
status := func(key string, inMaster bool) string {
|
||||
ch, has := changes[key]
|
||||
switch {
|
||||
case len(errs[key]) > 0:
|
||||
return StatusError
|
||||
case has:
|
||||
return string(ch.Op)
|
||||
case !inMaster:
|
||||
return StatusExtra
|
||||
}
|
||||
return StatusSame
|
||||
}
|
||||
addAPI := func(item, id string, ref, cur map[string]any) {
|
||||
key := item + "/" + id
|
||||
seen[key] = true
|
||||
ch := changes[key]
|
||||
r := Row{Kind: item, ID: id, Key: key, Status: status(key, ref != nil), Errors: errs[key]}
|
||||
names := map[string]bool{}
|
||||
for _, m := range []map[string]any{ref, cur, ch.Set} {
|
||||
for f := range m {
|
||||
names[f] = true
|
||||
}
|
||||
}
|
||||
for f := range tm.Values[key] {
|
||||
names[f] = true
|
||||
}
|
||||
list := make([]string, 0, len(names))
|
||||
for f := range names {
|
||||
list = append(list, f)
|
||||
}
|
||||
sort.Strings(list)
|
||||
unset := map[string]bool{}
|
||||
for _, f := range ch.Unset {
|
||||
unset[f] = true
|
||||
}
|
||||
for _, f := range list {
|
||||
fd := Field{Name: f, Policy: prof.Policy(item, f), Ref: ref[f], Current: cur[f]}
|
||||
if v, ok := tm.Values[key][f]; ok {
|
||||
fd.Mapping = v
|
||||
}
|
||||
switch {
|
||||
case ch.Op == OpDelete || unset[f]:
|
||||
fd.Change = "unset"
|
||||
case ch.Set[f] != nil:
|
||||
fd.Target = ch.Set[f]
|
||||
fd.Change = "set"
|
||||
if fd.Current == nil {
|
||||
fd.Change = "add"
|
||||
}
|
||||
case fd.Current != nil:
|
||||
fd.Target = fd.Current
|
||||
}
|
||||
r.Fields = append(r.Fields, fd)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
for _, id := range prof.Items {
|
||||
it, _ := ItemByID(id)
|
||||
switch it.Kind {
|
||||
case KindNetwork:
|
||||
cur := map[string]model.Object{}
|
||||
for _, o := range target.Network {
|
||||
cur[o.ID] = o
|
||||
}
|
||||
for _, m := range master.Network {
|
||||
tid := rename(m.ID, tm.NICs)
|
||||
addAPI("network", tid, m.Fields, cur[tid].Fields)
|
||||
}
|
||||
for _, o := range target.Network {
|
||||
if !seen["network/"+o.ID] {
|
||||
addAPI("network", o.ID, nil, o.Fields)
|
||||
}
|
||||
}
|
||||
case KindDNS:
|
||||
addAPI("dns", "dns", master.DNS, target.DNS)
|
||||
case KindFile:
|
||||
paths := map[string]bool{}
|
||||
for p := range master.Files[id] {
|
||||
paths[p] = true
|
||||
}
|
||||
for p := range target.Files[id] {
|
||||
paths[p] = true
|
||||
}
|
||||
if len(master.Files[id]) == 0 && !it.Glob {
|
||||
continue
|
||||
}
|
||||
for p := range paths {
|
||||
key := id + "/" + p
|
||||
_, inMaster := master.Files[id][p]
|
||||
r := Row{Kind: id, ID: p, Key: key, Status: status(key, inMaster), Errors: errs[key], Diff: changes[key].Diff}
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Kind != out[j].Kind {
|
||||
return itemRank(out[i].Kind) < itemRank(out[j].Kind)
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// TargetPlan is the plan of one target restricted to a selection of objects.
|
||||
type TargetPlan struct {
|
||||
Client string `json:"client"` // "client" like the cluster plans
|
||||
Plan Plan `json:"plan"`
|
||||
Digest string `json:"digest"`
|
||||
Network bool `json:"network"`
|
||||
Reloads []string `json:"reloads,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Plans returns the plans of the targets restricted to the given objects
|
||||
// (empty: everything), as a dry-run shows them and an apply checks them.
|
||||
func (st *State) Plans(keys []string) []TargetPlan {
|
||||
var out []TargetPlan
|
||||
for _, t := range st.Targets {
|
||||
tp := TargetPlan{Client: t.Key}
|
||||
switch {
|
||||
case st.Master.Error != "":
|
||||
tp.Error = "master: " + st.Master.Error
|
||||
case t.Error != "":
|
||||
tp.Error = t.Error
|
||||
default:
|
||||
tp.Plan = Filter(t.Plan, keys)
|
||||
tp.Digest = tp.Plan.Digest()
|
||||
tp.Network = tp.Plan.Network()
|
||||
tp.Reloads = tp.Plan.Reloads()
|
||||
}
|
||||
out = append(out, tp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Apply writes one plan on its target, then reads the target again and checks
|
||||
// that the selected objects are in line with the master.
|
||||
func (st *State) Apply(ctx context.Context, tp TargetPlan, keys []string, log func(Step)) error {
|
||||
ep, ok := st.eps[tp.Client]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown node %q", tp.Client)
|
||||
}
|
||||
if err := Apply(ctx, ep, tp.Plan, log); err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
err := st.verify(ctx, ep, keys)
|
||||
s := Step{At: start.UTC(), Action: "verify"}
|
||||
if err != nil {
|
||||
s.Error = err.Error()
|
||||
}
|
||||
log(s)
|
||||
return err
|
||||
}
|
||||
|
||||
func (st *State) verify(ctx context.Context, ep Endpoint, keys []string) error {
|
||||
snap, err := Read(ctx, ep, st.prof.Items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := Filter(MakePlan(st.prof, st.master, snap), keys)
|
||||
var errs []error
|
||||
for _, c := range p.Changes {
|
||||
errs = append(errs, fmt.Errorf("still different after apply: %s", c.Key()))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package nodesync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pve-confsync/internal/pve"
|
||||
)
|
||||
|
||||
// Step is the outcome of one action, for the execution journal.
|
||||
type Step struct {
|
||||
At time.Time `json:"at"`
|
||||
Action string `json:"action"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Timing of an apply; variables for the tests.
|
||||
var (
|
||||
// RestoreDelay: when the network was reloaded, the node restores its
|
||||
// previous configuration by itself after this delay unless pve-confsync
|
||||
// reaches it again over SSH and cancels the restore.
|
||||
RestoreDelay = 120 * time.Second
|
||||
// ProbeTries and ProbeWait: attempts to reach the node after the reload.
|
||||
ProbeTries = 6
|
||||
ProbeWait = 5 * time.Second
|
||||
TaskPoll = 2 * time.Second
|
||||
)
|
||||
|
||||
// BackupRoot is where the previous files are kept on the node, one folder
|
||||
// per apply, with the restore.sh script that puts them back.
|
||||
const BackupRoot = "/var/lib/pve-confsync/backup"
|
||||
|
||||
// Apply writes a plan on a target node:
|
||||
// 1. one SSH script checks that every file is still the one of the dry-run,
|
||||
// backs up the files (and /etc/network/interfaces, /etc/resolv.conf when
|
||||
// the API writes them), writes restore.sh, then writes the new files
|
||||
// atomically (temporary file then mv, mode and owner kept);
|
||||
// 2. the API writes the interfaces (in interfaces.new) and the DNS servers;
|
||||
// 3. when the network is reloaded, a systemd timer is armed on the node: it
|
||||
// runs restore.sh and ifreload -a after RestoreDelay;
|
||||
// 4. the reload commands run (sysctl, chrony, multipath, network last);
|
||||
// 5. the node must answer again over SSH and the API: the timer is stopped.
|
||||
//
|
||||
// A failure before the network reload restores the backups (and drops
|
||||
// interfaces.new); a reload failure restores them and runs the reloads again.
|
||||
func Apply(ctx context.Context, ep Endpoint, plan Plan, notify func(Step)) error {
|
||||
log := func(action string, err error) error {
|
||||
s := Step{At: time.Now().UTC(), Action: action}
|
||||
if err != nil {
|
||||
s.Error = err.Error()
|
||||
}
|
||||
if notify != nil {
|
||||
notify(s)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(plan.Errors) > 0 {
|
||||
return log("plan check", fmt.Errorf("plan has %d error(s), nothing applied", len(plan.Errors)))
|
||||
}
|
||||
if plan.Empty() {
|
||||
return nil
|
||||
}
|
||||
if ep.Shell == nil {
|
||||
return log("ssh", fmt.Errorf("%s: SSH host key not recorded yet, test the node first", ep.Key))
|
||||
}
|
||||
if _, err := NodeVersion(ctx, ep); err != nil {
|
||||
return log("version check", err)
|
||||
}
|
||||
stamp := time.Now().UTC().Format("20060102-150405")
|
||||
dir := BackupRoot + "/" + stamp
|
||||
unit := "pve-confsync-restore-" + stamp
|
||||
|
||||
var netAPI, dns bool
|
||||
for _, c := range plan.Changes {
|
||||
netAPI = netAPI || c.Item == "network"
|
||||
dns = dns || c.Item == "dns"
|
||||
}
|
||||
if _, err := ep.Shell.Run(ctx, prepareScript(dir), prepareInput(plan, netAPI, dns)); err != nil {
|
||||
return log("check, back up and write the files ("+dir+")", err)
|
||||
}
|
||||
log("check, back up and write the files ("+dir+")", nil)
|
||||
restore := func(cause error) error {
|
||||
_, rerr := ep.Shell.Run(ctx, restoreScript(dir), nil)
|
||||
log("restore the previous files", rerr)
|
||||
if rerr != nil {
|
||||
return errors.Join(cause, fmt.Errorf("restore failed, run sh %s/restore.sh on the node: %w", dir, rerr))
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
for _, c := range plan.Changes {
|
||||
if c.Item != "network" && c.Item != "dns" {
|
||||
continue
|
||||
}
|
||||
if err := log(describe(ep, c), writeAPI(ctx, ep, c)); err != nil {
|
||||
return restore(err)
|
||||
}
|
||||
}
|
||||
|
||||
network := plan.Network()
|
||||
if network {
|
||||
_, err := ep.Shell.Run(ctx, armScript(dir, unit), nil)
|
||||
if err := log(fmt.Sprintf("arm the automatic restore (%s, %s)", unit, RestoreDelay), err); err != nil {
|
||||
return restore(err)
|
||||
}
|
||||
}
|
||||
var done []string // reload commands already run
|
||||
for _, cmd := range plan.Reloads() {
|
||||
var err error
|
||||
action := cmd
|
||||
if cmd == "ifreload -a" && netAPI {
|
||||
action = "PUT /nodes/" + ep.Node + "/network (ifreload -a)"
|
||||
err = reloadNetworkAPI(ctx, ep)
|
||||
} else {
|
||||
_, err = ep.Shell.Run(ctx, reloadScript(cmd), nil)
|
||||
}
|
||||
done = append(done, cmd)
|
||||
if log("reload: "+action, err) == nil {
|
||||
continue
|
||||
}
|
||||
cause := fmt.Errorf("reload %q: %w", cmd, err)
|
||||
if network && !reachable(ctx, ep) {
|
||||
return log("node unreachable", fmt.Errorf("%w; the node is unreachable: it restores its previous configuration by itself within %s", cause, RestoreDelay))
|
||||
}
|
||||
// back to the previous files, which the reloads put in effect again
|
||||
err = restore(cause)
|
||||
for _, c := range done {
|
||||
_, rerr := ep.Shell.Run(ctx, reloadScript(c), nil)
|
||||
log("reload after restore: "+c, rerr)
|
||||
}
|
||||
if network {
|
||||
_, derr := ep.Shell.Run(ctx, disarmScript(unit), nil)
|
||||
log("stop the automatic restore", derr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if network {
|
||||
if !reachable(ctx, ep) {
|
||||
return log("reach the node again", fmt.Errorf("%s unreachable after the network reload: it restores its previous configuration by itself within %s", ep.Key, RestoreDelay))
|
||||
}
|
||||
log("reach the node again (SSH and API)", nil)
|
||||
_, err := ep.Shell.Run(ctx, disarmScript(unit), nil)
|
||||
if err := log("stop the automatic restore", err); err != nil {
|
||||
return fmt.Errorf("stop the automatic restore (systemctl stop %s.timer on the node): %w", unit, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func describe(ep Endpoint, c Change) string {
|
||||
if c.Item == "dns" {
|
||||
return "PUT /nodes/" + ep.Node + "/dns"
|
||||
}
|
||||
return fmt.Sprintf("%s %s/%s", c.Op, c.Item, c.ID)
|
||||
}
|
||||
|
||||
// reachable opens a new SSH connection and calls the API, a few times.
|
||||
func reachable(ctx context.Context, ep Endpoint) bool {
|
||||
for i := 0; i < ProbeTries; i++ {
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(ProbeWait):
|
||||
}
|
||||
}
|
||||
sh := ep.Shell
|
||||
if ep.Dial != nil {
|
||||
var err error
|
||||
if sh, err = ep.Dial(); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, err := sh.Run(ctx, "# confsync:probe\ntrue", nil); err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := NodeVersion(ctx, ep); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nodePath(ep Endpoint, rest string) string {
|
||||
return "nodes/" + url.PathEscape(ep.Node) + "/" + rest
|
||||
}
|
||||
|
||||
// writeAPI writes one interface (POST, PUT with every field, DELETE) or the
|
||||
// DNS servers (PUT with every server).
|
||||
func writeAPI(ctx context.Context, ep Endpoint, c Change) error {
|
||||
if c.Item == "dns" {
|
||||
f := url.Values{}
|
||||
for k, v := range c.Full {
|
||||
f.Set(k, pve.Str(v))
|
||||
}
|
||||
return ep.API.Put(ctx, nodePath(ep, "dns"), f, nil)
|
||||
}
|
||||
switch c.Op {
|
||||
case OpCreate:
|
||||
f := form(c.Full)
|
||||
f.Set("iface", c.ID)
|
||||
return ep.API.Post(ctx, nodePath(ep, "network"), f, nil)
|
||||
case OpUpdate:
|
||||
f := form(c.Full)
|
||||
if len(c.Unset) > 0 {
|
||||
f.Set("delete", strings.Join(c.Unset, ","))
|
||||
}
|
||||
return ep.API.Put(ctx, nodePath(ep, "network/"+url.PathEscape(c.ID)), f, nil)
|
||||
case OpDelete:
|
||||
return ep.API.Delete(ctx, nodePath(ep, "network/"+url.PathEscape(c.ID)), nil, nil)
|
||||
}
|
||||
return fmt.Errorf("unknown op %q", c.Op)
|
||||
}
|
||||
|
||||
func form(fields map[string]any) url.Values {
|
||||
f := url.Values{}
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
f.Set(k, pve.Str(fields[k]))
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// reloadNetworkAPI applies interfaces.new (PUT /nodes/{node}/network: rename
|
||||
// then ifreload -a in a task) and waits for the task.
|
||||
func reloadNetworkAPI(ctx context.Context, ep Endpoint) error {
|
||||
var upid string
|
||||
if err := ep.API.Put(ctx, nodePath(ep, "network"), url.Values{}, &upid); err != nil {
|
||||
return err
|
||||
}
|
||||
return ep.API.WaitTask(ctx, upid, TaskPoll)
|
||||
}
|
||||
|
||||
// ---- scripts run on the node (the first line is a marker for the fake node
|
||||
// of the tests) ----
|
||||
|
||||
// prepareInput is the plan given to prepareScript on its standard input:
|
||||
// "W <mode> <owner> <hash> <path>" then the content in base64 on one line,
|
||||
// "D - - <hash> <path>" (delete) or "B - - - <path>" (backup only).
|
||||
func prepareInput(plan Plan, netAPI, dns bool) []byte {
|
||||
var b bytes.Buffer
|
||||
if netAPI {
|
||||
b.WriteString("B - - - /etc/network/interfaces\nB - - - /etc/network/interfaces.new\n")
|
||||
}
|
||||
if dns {
|
||||
b.WriteString("B - - - /etc/resolv.conf\n")
|
||||
}
|
||||
for _, c := range plan.Changes {
|
||||
switch {
|
||||
case c.Item == "network" || c.Item == "dns":
|
||||
case c.Op == OpDelete:
|
||||
fmt.Fprintf(&b, "D - - %s %s\n", c.Hash, c.ID)
|
||||
default:
|
||||
fmt.Fprintf(&b, "W %s %s %s %s\n%s\n", c.Mode, c.Owner, c.Hash, c.ID, base64.StdEncoding.EncodeToString(c.Content))
|
||||
}
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func prepareScript(dir string) string { return prepareScriptIn(dir, "") }
|
||||
|
||||
// prepareScriptIn prefixes every path with root (empty on a node): the tests
|
||||
// run the script for real in a temporary folder.
|
||||
func prepareScriptIn(dir, root string) string {
|
||||
return `# confsync:prepare ` + dir + `
|
||||
set -u
|
||||
R='` + root + `'
|
||||
B="$R` + dir + `"
|
||||
umask 077
|
||||
mkdir -p "$B/files" || exit 1
|
||||
cat > "$B/plan" || exit 1
|
||||
sum() { if [ -e "$1" ]; then sha256sum < "$1" | cut -d' ' -f1; else echo absent; fi; }
|
||||
# 1. every file must be the one read by the dry-run
|
||||
while read -r op mode owner h p; do
|
||||
case "$op" in W|D) ;; *) continue ;; esac
|
||||
[ "$(sum "$R$p")" = "$h" ] || { echo "changed since the dry-run: $p" >&2; exit 3; }
|
||||
done < "$B/plan"
|
||||
# 2. backups and restore script
|
||||
: > "$B/restore.sh"
|
||||
while read -r op mode owner h p; do
|
||||
case "$op" in W|D|B) ;; *) continue ;; esac
|
||||
if [ -e "$R$p" ]; then
|
||||
mkdir -p "$B/files$(dirname "$p")" && cp -p "$R$p" "$B/files$p" || exit 1
|
||||
printf "cp -p '%s' '%s'\n" "$B/files$p" "$R$p" >> "$B/restore.sh"
|
||||
else
|
||||
printf "rm -f '%s'\n" "$R$p" >> "$B/restore.sh"
|
||||
fi
|
||||
done < "$B/plan"
|
||||
# 3. writes: temporary file in the same folder, then mv
|
||||
while read -r op mode owner h p; do
|
||||
case "$op" in
|
||||
W)
|
||||
read -r data
|
||||
mkdir -p "$(dirname "$R$p")" || exit 4
|
||||
t=$(mktemp "$R$p.confsync.XXXXXX") || exit 4
|
||||
if printf '%s' "$data" | base64 -d > "$t" && chmod "$mode" "$t" && chown "$owner" "$t" && mv -f "$t" "$R$p"; then :; else
|
||||
rm -f "$t"; sh "$B/restore.sh"; echo "write failed: $p" >&2; exit 4
|
||||
fi ;;
|
||||
D)
|
||||
rm -f "$R$p" || { sh "$B/restore.sh"; echo "delete failed: $p" >&2; exit 4; } ;;
|
||||
esac
|
||||
done < "$B/plan"
|
||||
echo ok
|
||||
`
|
||||
}
|
||||
|
||||
func restoreScript(dir string) string {
|
||||
return "# confsync:restore " + dir + "\nsh '" + dir + "/restore.sh'\n"
|
||||
}
|
||||
|
||||
func armScript(dir, unit string) string {
|
||||
return fmt.Sprintf("# confsync:arm %s\nsystemd-run --quiet --unit=%s --on-active=%ds --timer-property=AccuracySec=1s /bin/sh -c 'sh %s/restore.sh; ifreload -a'\n",
|
||||
unit, unit, int(RestoreDelay.Seconds()), dir)
|
||||
}
|
||||
|
||||
func disarmScript(unit string) string {
|
||||
return "# confsync:disarm " + unit + "\nsystemctl stop " + unit + ".timer\n"
|
||||
}
|
||||
|
||||
func reloadScript(cmd string) string {
|
||||
return "# confsync:reload " + cmd + "\n" + cmd + "\n"
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package nodesync
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Line is one line of a unified diff: Op is ' ' (context), '-' (target only)
|
||||
// or '+' (written by the replication). A hunk starts with Op '@' and Text
|
||||
// "@@ -a,b +c,d @@".
|
||||
type Line struct {
|
||||
Op string `json:"op"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
const diffContext = 3
|
||||
|
||||
// maxCells bounds the LCS table; beyond, the diff shows every line replaced.
|
||||
const maxCells = 4_000_000
|
||||
|
||||
// Diff compares the current content of a file with the content to write.
|
||||
func Diff(oldText, newText []byte) []Line {
|
||||
a, b := lines(oldText), lines(newText)
|
||||
ops := editScript(a, b)
|
||||
// group the changes into hunks with their context
|
||||
var out []Line
|
||||
n := len(ops)
|
||||
i := 0
|
||||
for i < n {
|
||||
if ops[i].Op == " " {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
start := i - diffContext
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := i
|
||||
for end < n {
|
||||
if ops[end].Op != " " {
|
||||
end++
|
||||
continue
|
||||
}
|
||||
// a run of context: the hunk ends when it is longer than 2*context
|
||||
j := end
|
||||
for j < n && ops[j].Op == " " {
|
||||
j++
|
||||
}
|
||||
if j == n || j-end > 2*diffContext {
|
||||
end += diffContext
|
||||
if end > n {
|
||||
end = n
|
||||
}
|
||||
break
|
||||
}
|
||||
end = j
|
||||
}
|
||||
oa, ob := ops[start].A, ops[start].B
|
||||
ca, cb := 0, 0
|
||||
for _, o := range ops[start:end] {
|
||||
if o.Op != "+" {
|
||||
ca++
|
||||
}
|
||||
if o.Op != "-" {
|
||||
cb++
|
||||
}
|
||||
}
|
||||
out = append(out, Line{Op: "@", Text: hunkHeader(oa, ca, ob, cb)})
|
||||
for _, o := range ops[start:end] {
|
||||
out = append(out, Line{Op: o.Op, Text: o.Text})
|
||||
}
|
||||
i = end
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type editOp struct {
|
||||
Op string
|
||||
Text string
|
||||
A, B int // line numbers (1-based) where this op starts in a and b
|
||||
}
|
||||
|
||||
func editScript(a, b []string) []editOp {
|
||||
var ops []editOp
|
||||
if len(a)*len(b) > maxCells {
|
||||
for i, l := range a {
|
||||
ops = append(ops, editOp{"-", l, i + 1, 1})
|
||||
}
|
||||
for i, l := range b {
|
||||
ops = append(ops, editOp{"+", l, len(a) + 1, i + 1})
|
||||
}
|
||||
return ops
|
||||
}
|
||||
// lcs[i][j]: length of the LCS of a[i:] and b[j:]
|
||||
lcs := make([][]int, len(a)+1)
|
||||
for i := range lcs {
|
||||
lcs[i] = make([]int, len(b)+1)
|
||||
}
|
||||
for i := len(a) - 1; i >= 0; i-- {
|
||||
for j := len(b) - 1; j >= 0; j-- {
|
||||
if a[i] == b[j] {
|
||||
lcs[i][j] = lcs[i+1][j+1] + 1
|
||||
} else if lcs[i+1][j] >= lcs[i][j+1] {
|
||||
lcs[i][j] = lcs[i+1][j]
|
||||
} else {
|
||||
lcs[i][j] = lcs[i][j+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
i, j := 0, 0
|
||||
for i < len(a) || j < len(b) {
|
||||
switch {
|
||||
case i < len(a) && j < len(b) && a[i] == b[j]:
|
||||
ops = append(ops, editOp{" ", a[i], i + 1, j + 1})
|
||||
i++
|
||||
j++
|
||||
case j < len(b) && (i == len(a) || lcs[i][j+1] > lcs[i+1][j]):
|
||||
ops = append(ops, editOp{"+", b[j], i + 1, j + 1})
|
||||
j++
|
||||
default:
|
||||
ops = append(ops, editOp{"-", a[i], i + 1, j + 1})
|
||||
i++
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func lines(b []byte) []string {
|
||||
s := string(b)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
s = strings.TrimSuffix(s, "\n")
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
func hunkHeader(a, ca, b, cb int) string {
|
||||
r := func(start, n int) string {
|
||||
if n == 0 {
|
||||
start--
|
||||
}
|
||||
if n == 1 {
|
||||
return strconv.Itoa(start)
|
||||
}
|
||||
return strconv.Itoa(start) + "," + strconv.Itoa(n)
|
||||
}
|
||||
return "@@ -" + r(a, ca) + " +" + r(b, cb) + " @@"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package nodesync replicates the system configuration of one PVE node (the
|
||||
// master) to other nodes (the targets), of the same cluster or not. It is the
|
||||
// second replication mode of pve-confsync, next to the cluster to cluster one
|
||||
// (package engine). What the PVE API covers goes through the API: the network
|
||||
// interfaces (/nodes/{node}/network, written in interfaces.new then reloaded)
|
||||
// and the DNS servers (/nodes/{node}/dns). The other files go through SSH as
|
||||
// root on the node: multipath, chrony, sysctl, interfaces.d. Same pipeline as
|
||||
// the engine: read, normalize, diff, plan, apply with backup, reload, verify.
|
||||
package nodesync
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ItemKind tells how an item is read and written.
|
||||
type ItemKind string
|
||||
|
||||
const (
|
||||
KindNetwork ItemKind = "network" // API /nodes/{node}/network
|
||||
KindDNS ItemKind = "dns" // API /nodes/{node}/dns
|
||||
KindFile ItemKind = "file" // files over SSH
|
||||
)
|
||||
|
||||
// Item is one replicable element.
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Kind ItemKind `json:"kind"`
|
||||
// Files: paths or glob patterns read on the nodes, and paths never touched.
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
Exclude []string `json:"exclude,omitempty"`
|
||||
// Glob items hold several files: a file only on the target is kept, or
|
||||
// deleted with the prune option. Single file items are never deleted.
|
||||
Glob bool `json:"glob,omitempty"`
|
||||
// Additive files (multipath wwids) only get the lines of the master they
|
||||
// miss: nothing is removed from the target.
|
||||
Additive bool `json:"additive,omitempty"`
|
||||
// Reload is the command run on the node after a write (empty: none).
|
||||
Reload string `json:"reload,omitempty"`
|
||||
// Network items reload the network of the node: typed confirmation,
|
||||
// automatic restore when the node is unreachable afterwards.
|
||||
Network bool `json:"network,omitempty"`
|
||||
}
|
||||
|
||||
// Items is the catalog, in apply order (reloads follow this order, the
|
||||
// network last).
|
||||
var Items = []Item{
|
||||
{ID: "sysctl", Kind: KindFile, Paths: []string{"/etc/sysctl.d/*.conf"}, Glob: true, Reload: "sysctl --system"},
|
||||
{ID: "chrony", Kind: KindFile, Paths: []string{"/etc/chrony/chrony.conf", "/etc/chrony/sources.d/*.sources"}, Glob: true,
|
||||
Reload: "systemctl restart chrony"},
|
||||
{ID: "multipath", Kind: KindFile, Paths: []string{"/etc/multipath.conf"}, Reload: "multipathd reconfigure"},
|
||||
{ID: "wwids", Kind: KindFile, Paths: []string{"/etc/multipath/wwids"}, Additive: true, Reload: "multipathd reconfigure"},
|
||||
{ID: "dns", Kind: KindDNS},
|
||||
// interfaces.d/sdn is generated by the SDN of PVE: never replicated
|
||||
{ID: "interfaces-d", Kind: KindFile, Paths: []string{"/etc/network/interfaces.d/*"}, Exclude: []string{"/etc/network/interfaces.d/sdn"},
|
||||
Glob: true, Reload: "ifreload -a", Network: true},
|
||||
{ID: "network", Kind: KindNetwork, Network: true},
|
||||
}
|
||||
|
||||
// ItemByID returns an item of the catalog.
|
||||
func ItemByID(id string) (Item, error) {
|
||||
for _, it := range Items {
|
||||
if it.ID == id {
|
||||
return it, nil
|
||||
}
|
||||
}
|
||||
return Item{}, fmt.Errorf("unknown item %q", id)
|
||||
}
|
||||
|
||||
func itemRank(id string) int {
|
||||
for i, it := range Items {
|
||||
if it.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(Items)
|
||||
}
|
||||
|
||||
// Policy of a field of an API item (network, dns).
|
||||
type Policy string
|
||||
|
||||
const (
|
||||
Sync Policy = "sync" // copied from the master
|
||||
Ignore Policy = "ignore" // never written
|
||||
Override Policy = "override" // only written from the target mapping
|
||||
)
|
||||
|
||||
// defaultPolicy: the addresses and gateways are those of each node; they are
|
||||
// only written from the mapping of a target.
|
||||
func defaultPolicy(item, field string) Policy {
|
||||
if item == "network" {
|
||||
switch field {
|
||||
case "cidr", "cidr6", "gateway", "gateway6":
|
||||
return Override
|
||||
}
|
||||
}
|
||||
return Sync
|
||||
}
|
||||
|
||||
// DefaultPolicies lists the fields whose default is not "sync", for the UI.
|
||||
var DefaultPolicies = map[string]map[string]Policy{
|
||||
"network": {"cidr": Override, "cidr6": Override, "gateway": Override, "gateway6": Override},
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package nodesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pve-confsync/internal/config"
|
||||
"pve-confsync/internal/pve/pvetest"
|
||||
"pve-confsync/internal/sshx"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ProbeWait, TaskPoll = time.Millisecond, time.Millisecond
|
||||
}
|
||||
|
||||
// endpoint connects to a node of a fake cluster like the web UI does.
|
||||
func endpoint(t *testing.T, s *pvetest.Server, cluster string, n *pvetest.Node) Endpoint {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(s.Certificate().Raw)
|
||||
c, err := config.Cluster{Name: cluster, URL: s.URL, User: "root@pam", Password: pvetest.Password,
|
||||
Fingerprint: hex.EncodeToString(sum[:])}.PVE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, port := n.SSHAddr()
|
||||
o := sshx.Options{Host: host, Port: port, Password: pvetest.Password, HostKey: n.SSHHostKey()}
|
||||
sh, err := sshx.New(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Endpoint{Key: cluster + "/" + n.Name, Cluster: cluster, Node: n.Name, API: c, Shell: sh}
|
||||
}
|
||||
|
||||
const multipath = "defaults {\n user_friendly_names yes\n find_multipaths no\n}\nblacklist {\n devnode \"^sda\"\n}\n"
|
||||
|
||||
// lab: one cluster, a master pve1 and a target pve2 with differences.
|
||||
func lab(t *testing.T) (*pvetest.Server, *pvetest.Node, *pvetest.Node, map[string]Endpoint) {
|
||||
s := pvetest.New("9.1.4")
|
||||
t.Cleanup(s.Close)
|
||||
m, c := s.AddNode("pve1"), s.AddNode("pve2")
|
||||
m.Net = map[string]map[string]any{
|
||||
"eno1": {"type": "eth", "mtu": "9000"},
|
||||
"eno2": {"type": "eth", "mtu": "9000"},
|
||||
"bond0": {"type": "bond", "slaves": "eno1 eno2", "bond_mode": "802.3ad", "bond_xmit_hash_policy": "layer3+4", "mtu": "9000"},
|
||||
"vmbr0": {"type": "bridge", "bridge_ports": "bond0", "bridge_vlan_aware": "1", "cidr": "10.0.0.11/24", "gateway": "10.0.0.1", "autostart": "1"},
|
||||
"vmbr1": {"type": "bridge", "bridge_ports": "bond0.50", "cidr": "10.5.0.11/24", "comments": "stockage"},
|
||||
"bond0.50": {"type": "vlan", "mtu": "9000"},
|
||||
}
|
||||
// the target has other NIC names and an active-backup bond
|
||||
c.Net = map[string]map[string]any{
|
||||
"enp1s0f0": {"type": "eth"},
|
||||
"enp1s0f1": {"type": "eth"},
|
||||
"bond0": {"type": "bond", "slaves": "enp1s0f0 enp1s0f1", "bond_mode": "active-backup"},
|
||||
"vmbr0": {"type": "bridge", "bridge_ports": "bond0", "cidr": "10.0.0.12/24", "gateway": "10.0.0.1", "autostart": "1"},
|
||||
}
|
||||
m.DNS = map[string]string{"search": "lab.local", "dns1": "10.0.0.53", "dns2": "10.0.0.54"}
|
||||
c.DNS = map[string]string{"search": "lab.local", "dns1": "8.8.8.8"}
|
||||
m.Files["/etc/multipath.conf"] = pvetest.FakeFile{Content: multipath}
|
||||
m.Files["/etc/multipath/wwids"] = pvetest.FakeFile{Content: "# wwids\n/3600a0980383030/\n/3600a0980383031/\n"}
|
||||
c.Files["/etc/multipath/wwids"] = pvetest.FakeFile{Content: "# wwids\n/3600a0980383031/\n/36000local/\n"}
|
||||
m.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "net.core.rmem_max = 16777216\n"}
|
||||
c.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "net.core.rmem_max = 8388608\n", Mode: "600"}
|
||||
c.Files["/etc/sysctl.d/99-local.conf"] = pvetest.FakeFile{Content: "vm.swappiness = 10\n"}
|
||||
m.Files["/etc/network/interfaces.d/sdn"] = pvetest.FakeFile{Content: "auto vnet1\n"}
|
||||
eps := map[string]Endpoint{"lab/pve1": endpoint(t, s, "lab", m), "lab/pve2": endpoint(t, s, "lab", c)}
|
||||
return s, m, c, eps
|
||||
}
|
||||
|
||||
func profile() *Profile {
|
||||
return &Profile{Name: "stockage", Master: "lab/pve1", Targets: []string{"lab/pve2"},
|
||||
Items: []string{"network", "dns", "multipath", "wwids", "sysctl", "interfaces-d"},
|
||||
Mapping: map[string]TargetMap{"lab/pve2": {
|
||||
NICs: map[string]string{"eno1": "enp1s0f0", "eno2": "enp1s0f1"},
|
||||
Values: map[string]map[string]string{"network/vmbr1": {"cidr": "10.5.0.12/24"}},
|
||||
}}}
|
||||
}
|
||||
|
||||
func keys(p Plan) []string {
|
||||
var out []string
|
||||
for _, c := range p.Changes {
|
||||
out = append(out, string(c.Op)+" "+c.Key())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestPlan(t *testing.T) {
|
||||
_, _, _, eps := lab(t)
|
||||
prof := profile()
|
||||
st := Analyze(context.Background(), prof, eps, nil)
|
||||
if st.Master.Error != "" || st.Targets[0].Error != "" {
|
||||
t.Fatal(st.Master.Error, st.Targets[0].Error)
|
||||
}
|
||||
p := st.Targets[0].Plan
|
||||
if len(p.Errors) > 0 {
|
||||
t.Fatalf("errors: %+v", p.Errors)
|
||||
}
|
||||
got := strings.Join(keys(p), "\n")
|
||||
want := strings.Join([]string{
|
||||
"update sysctl//etc/sysctl.d/90-net.conf",
|
||||
"create multipath//etc/multipath.conf",
|
||||
"update wwids//etc/multipath/wwids",
|
||||
"update dns/dns",
|
||||
"update network/enp1s0f0",
|
||||
"update network/enp1s0f1",
|
||||
"update network/bond0",
|
||||
"create network/bond0.50",
|
||||
"update network/vmbr0",
|
||||
"create network/vmbr1",
|
||||
}, "\n")
|
||||
if got != want {
|
||||
t.Fatalf("plan:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
for _, c := range p.Changes {
|
||||
switch c.Key() {
|
||||
case "network/bond0":
|
||||
if c.Set["slaves"] != "enp1s0f0 enp1s0f1" && c.Set["slaves"] != nil {
|
||||
t.Errorf("slaves not renamed: %v", c.Set["slaves"])
|
||||
}
|
||||
if c.Set["bond_mode"] != "802.3ad" {
|
||||
t.Errorf("bond0: %+v", c.Set)
|
||||
}
|
||||
case "network/vmbr0":
|
||||
// the address of the target is kept (override without mapping)
|
||||
if _, ok := c.Set["cidr"]; ok || c.Full["cidr"] != "10.0.0.12/24" {
|
||||
t.Errorf("vmbr0: set %+v full %+v", c.Set, c.Full)
|
||||
}
|
||||
case "network/vmbr1":
|
||||
if c.Full["cidr"] != "10.5.0.12/24" || c.Full["bridge_ports"] != "bond0.50" {
|
||||
t.Errorf("vmbr1: %+v", c.Full)
|
||||
}
|
||||
case "wwids//etc/multipath/wwids":
|
||||
// additive: the local WWID of the target stays
|
||||
if string(c.Content) != "# wwids\n/3600a0980383031/\n/36000local/\n/3600a0980383030/\n" {
|
||||
t.Errorf("wwids: %q", c.Content)
|
||||
}
|
||||
case "sysctl//etc/sysctl.d/90-net.conf":
|
||||
if c.Mode != "600" || len(c.Diff) == 0 {
|
||||
t.Errorf("sysctl: mode %s diff %+v", c.Mode, c.Diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
// the file only on the target is kept without prune; interfaces.d/sdn is never read
|
||||
for _, r := range st.Targets[0].Rows {
|
||||
if r.ID == "/etc/sysctl.d/99-local.conf" && r.Status != StatusExtra {
|
||||
t.Errorf("99-local.conf: %s", r.Status)
|
||||
}
|
||||
if strings.Contains(r.ID, "interfaces.d/sdn") {
|
||||
t.Errorf("sdn file listed")
|
||||
}
|
||||
}
|
||||
if !p.Network() {
|
||||
t.Error("network plan not flagged")
|
||||
}
|
||||
if strings.Join(p.Reloads(), ",") != "sysctl --system,multipathd reconfigure,ifreload -a" {
|
||||
t.Errorf("reloads: %v", p.Reloads())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAndVerify(t *testing.T) {
|
||||
_, _, c, eps := lab(t)
|
||||
prof := profile()
|
||||
st := Analyze(context.Background(), prof, eps, nil)
|
||||
tp := st.Plans(nil)[0]
|
||||
var steps []string
|
||||
if err := st.Apply(context.Background(), tp, nil, func(s Step) {
|
||||
steps = append(steps, s.Action+" "+s.Error)
|
||||
}); err != nil {
|
||||
t.Fatalf("apply: %v\n%s", err, strings.Join(steps, "\n"))
|
||||
}
|
||||
if c.Net["vmbr0"]["cidr"] != "10.0.0.12/24" || c.Net["vmbr1"]["cidr"] != "10.5.0.12/24" {
|
||||
t.Errorf("addresses: vmbr0 %v vmbr1 %v", c.Net["vmbr0"], c.Net["vmbr1"])
|
||||
}
|
||||
if c.Net["vmbr0"]["bridge_vlan_aware"] != "1" || c.Pending != nil {
|
||||
t.Errorf("vmbr0 %v pending %v", c.Net["vmbr0"], c.Pending)
|
||||
}
|
||||
if c.DNS["dns2"] != "10.0.0.54" || c.Files["/etc/multipath.conf"].Content != multipath {
|
||||
t.Errorf("dns %v multipath %q", c.DNS, c.Files["/etc/multipath.conf"].Content)
|
||||
}
|
||||
if len(c.Armed) != 0 {
|
||||
t.Errorf("restore timer still armed: %v", c.Armed)
|
||||
}
|
||||
if got := strings.Join(c.Reloads, ","); got != "sysctl --system,multipathd reconfigure,network" {
|
||||
t.Errorf("reloads: %s", got)
|
||||
}
|
||||
if len(c.Backups()) != 1 {
|
||||
t.Errorf("backups: %v", c.Backups())
|
||||
}
|
||||
st = Analyze(context.Background(), prof, eps, nil)
|
||||
if p := st.Targets[0].Plan; !p.Empty() {
|
||||
t.Errorf("still to do: %v", keys(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadFailureRestores(t *testing.T) {
|
||||
_, _, c, eps := lab(t)
|
||||
prof := profile()
|
||||
prof.Items = []string{"multipath", "sysctl"}
|
||||
c.FailOn = "reload multipathd reconfigure"
|
||||
st := Analyze(context.Background(), prof, eps, nil)
|
||||
err := st.Apply(context.Background(), st.Plans(nil)[0], nil, func(Step) {})
|
||||
if err == nil || !strings.Contains(err.Error(), "multipathd reconfigure") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if _, ok := c.Files["/etc/multipath.conf"]; ok {
|
||||
t.Error("multipath.conf not removed by the restore")
|
||||
}
|
||||
if c.Files["/etc/sysctl.d/90-net.conf"].Content != "net.core.rmem_max = 8388608\n" {
|
||||
t.Errorf("sysctl not restored: %q", c.Files["/etc/sysctl.d/90-net.conf"].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableAfterReloadKeepsTimer(t *testing.T) {
|
||||
_, _, c, eps := lab(t)
|
||||
prof := profile()
|
||||
prof.Items = []string{"network"}
|
||||
st := Analyze(context.Background(), prof, eps, nil)
|
||||
c.FailOn = "probe"
|
||||
err := st.Apply(context.Background(), st.Plans(nil)[0], nil, func(Step) {})
|
||||
if err == nil || !strings.Contains(err.Error(), "unreachable") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(c.Armed) != 1 {
|
||||
t.Fatalf("timer: %v", c.Armed)
|
||||
}
|
||||
// systemd runs the restore: the previous interfaces come back
|
||||
c.Fire()
|
||||
if c.Net["bond0"]["bond_mode"] != "active-backup" || c.Net["vmbr1"] != nil {
|
||||
t.Errorf("not restored: %v", c.Net)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleFileRefused(t *testing.T) {
|
||||
_, _, c, eps := lab(t)
|
||||
prof := profile()
|
||||
prof.Items = []string{"sysctl"}
|
||||
st := Analyze(context.Background(), prof, eps, nil)
|
||||
c.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "changed by hand\n"}
|
||||
err := st.Apply(context.Background(), st.Plans(nil)[0], nil, func(Step) {})
|
||||
if err == nil || !strings.Contains(err.Error(), "changed since the dry-run") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if c.Files["/etc/sysctl.d/90-net.conf"].Content != "changed by hand\n" {
|
||||
t.Error("file overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingNetworkBlocks(t *testing.T) {
|
||||
_, _, c, eps := lab(t)
|
||||
c.Pending = map[string]map[string]any{"vmbr0": {"type": "bridge"}}
|
||||
st := Analyze(context.Background(), profile(), eps, nil)
|
||||
if len(st.Targets[0].Plan.Errors) == 0 {
|
||||
t.Fatal("pending changes not blocking")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingNICIsAnError(t *testing.T) {
|
||||
_, _, _, eps := lab(t)
|
||||
prof := profile()
|
||||
prof.Items = []string{"network"}
|
||||
prof.Mapping = nil
|
||||
st := Analyze(context.Background(), prof, eps, nil)
|
||||
p := st.Targets[0].Plan
|
||||
if len(p.Errors) != 2 || p.Errors[0].Key != "network/eno1" {
|
||||
t.Fatalf("errors: %+v", p.Errors)
|
||||
}
|
||||
// the selection of another object is not blocked
|
||||
if f := Filter(p, []string{"network/vmbr0"}); len(f.Errors) != 0 || len(f.Changes) != 1 {
|
||||
t.Errorf("filter: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneAndProtect(t *testing.T) {
|
||||
_, m, _, eps := lab(t)
|
||||
m.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "# host pve1\nnet.core.rmem_max = 8388608\n"}
|
||||
prof := profile()
|
||||
prof.Items = []string{"sysctl"}
|
||||
prof.Prune = true
|
||||
prof.Protect = map[string][]string{"sysctl": {"^# host "}}
|
||||
st := Analyze(context.Background(), prof, eps, nil)
|
||||
got := strings.Join(keys(st.Targets[0].Plan), ",")
|
||||
// the protected line is absent on the target: dropped, the rest is equal
|
||||
if got != "delete sysctl//etc/sysctl.d/99-local.conf" {
|
||||
t.Fatalf("plan: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiff(t *testing.T) {
|
||||
d := Diff([]byte("a\nb\nc\nd\ne\nf\ng\nh\ni\n"), []byte("a\nb\nc\nd\nE\nf\ng\nh\ni\nj\n"))
|
||||
var b strings.Builder
|
||||
for _, l := range d {
|
||||
b.WriteString(l.Op + l.Text + "\n")
|
||||
}
|
||||
want := "@@@ -2,8 +2,9 @@\n b\n c\n d\n-e\n+E\n f\n g\n h\n i\n+j\n"
|
||||
if b.String() != want {
|
||||
t.Fatalf("diff:\n%s\nwant:\n%s", b.String(), want)
|
||||
}
|
||||
if d := Diff(nil, []byte("x\n")); len(d) != 2 || d[0].Text != "@@ -0,0 +1 @@" {
|
||||
t.Errorf("creation diff: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
package nodesync
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"pve-confsync/internal/model"
|
||||
)
|
||||
|
||||
// Profile is one node replication: a master node, target nodes, the
|
||||
// replicated items and their rules.
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
Master string `json:"master"` // "cluster/node"
|
||||
Targets []string `json:"targets"` // "cluster/node"
|
||||
Items []string `json:"items"`
|
||||
// Prune deletes on the targets what the master does not have: network
|
||||
// interfaces (never the physical ones) and the files of the glob items.
|
||||
Prune bool `json:"prune"`
|
||||
// Policies of the fields of the API items (item -> field -> policy),
|
||||
// over the defaults (addresses and gateways: override).
|
||||
Policies map[string]map[string]Policy `json:"policies"`
|
||||
// Protect: per file item, regular expressions of the lines kept from the
|
||||
// target (hostname, local address…).
|
||||
Protect map[string][]string `json:"protect"`
|
||||
// Mapping: per target, its interface names and its own values.
|
||||
Mapping map[string]TargetMap `json:"mapping"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
// TargetMap holds what is specific to one target.
|
||||
type TargetMap struct {
|
||||
// NICs renames the interfaces of the master (eno1 -> enp1s0f0), in the
|
||||
// interface names and in the fields that list interfaces.
|
||||
NICs map[string]string `json:"nics,omitempty"`
|
||||
// Values: "item/id" -> field -> value written on this target (cidr of
|
||||
// vmbr0…), whatever the master has.
|
||||
Values map[string]map[string]string `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks a profile.
|
||||
func (p *Profile) Validate() error {
|
||||
var errs []error
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" {
|
||||
errs = append(errs, errors.New("name is required"))
|
||||
}
|
||||
if p.Master == "" {
|
||||
errs = append(errs, errors.New("master node is required"))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, t := range p.Targets {
|
||||
if t == p.Master {
|
||||
errs = append(errs, fmt.Errorf("target %s is the master", t))
|
||||
}
|
||||
if seen[t] {
|
||||
errs = append(errs, fmt.Errorf("target %s listed twice", t))
|
||||
}
|
||||
seen[t] = true
|
||||
}
|
||||
if len(p.Items) == 0 {
|
||||
errs = append(errs, errors.New("select at least one item"))
|
||||
}
|
||||
for _, id := range p.Items {
|
||||
if _, err := ItemByID(id); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
for item, fields := range p.Policies {
|
||||
for f, pol := range fields {
|
||||
switch pol {
|
||||
case Sync, Ignore, Override:
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("%s.%s: unknown policy %q", item, f, pol))
|
||||
}
|
||||
if item == "network" && f == "type" && pol != Sync {
|
||||
errs = append(errs, errors.New("network.type: always synchronized"))
|
||||
}
|
||||
}
|
||||
}
|
||||
for item, res := range p.Protect {
|
||||
for _, re := range res {
|
||||
if _, err := regexp.Compile(re); err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: protected lines: %w", item, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Policy resolves the policy of a field.
|
||||
func (p *Profile) Policy(item, field string) Policy {
|
||||
if item == "network" && field == "type" {
|
||||
return Sync
|
||||
}
|
||||
if pol, ok := p.Policies[item][field]; ok {
|
||||
return pol
|
||||
}
|
||||
return defaultPolicy(item, field)
|
||||
}
|
||||
|
||||
// Has tells whether an item is replicated.
|
||||
func (p *Profile) Has(item string) bool {
|
||||
for _, x := range p.Items {
|
||||
if x == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Op is the action planned on one object.
|
||||
type Op string
|
||||
|
||||
const (
|
||||
OpCreate Op = "create"
|
||||
OpUpdate Op = "update"
|
||||
OpDelete Op = "delete"
|
||||
)
|
||||
|
||||
// Change is one planned action: an interface or the DNS servers (Set, Unset,
|
||||
// Old), or a file (Diff, Hash of the target file, content to write).
|
||||
type Change struct {
|
||||
Item string `json:"kind"` // item id ("kind" like the cluster plans, for the UI)
|
||||
ID string `json:"id"` // interface, "dns" or file path
|
||||
Op Op `json:"op"`
|
||||
Set map[string]any `json:"set,omitempty"`
|
||||
Unset []string `json:"unset,omitempty"`
|
||||
Old map[string]any `json:"old,omitempty"`
|
||||
Diff []Line `json:"diff,omitempty"`
|
||||
Hash string `json:"hash,omitempty"` // current file on the target ("absent" when created)
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
// Full is the whole writable configuration of an interface after the
|
||||
// change: PUT /nodes/{node}/network/{iface} recomputes the method and the
|
||||
// families from the parameters, so every field is sent again.
|
||||
Full map[string]any `json:"-"`
|
||||
Content []byte `json:"-"`
|
||||
}
|
||||
|
||||
// Key identifies the object of a change ("item/id").
|
||||
func (c Change) Key() string { return c.Item + "/" + c.ID }
|
||||
|
||||
// Issue is a blocking error or a warning, about one object (Key) or the
|
||||
// whole plan.
|
||||
type Issue struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Plan is the list of changes for one target, in execution order.
|
||||
type Plan struct {
|
||||
Target string `json:"client"`
|
||||
Changes []Change `json:"changes"`
|
||||
Errors []Issue `json:"errors,omitempty"`
|
||||
Warnings []Issue `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// Empty tells whether there is nothing to do.
|
||||
func (p Plan) Empty() bool { return len(p.Changes) == 0 }
|
||||
|
||||
// Network tells whether the plan reloads the network of the node.
|
||||
func (p Plan) Network() bool {
|
||||
for _, c := range p.Changes {
|
||||
if it, _ := ItemByID(c.Item); it.Network {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Reloads lists the commands run after the writes, in order.
|
||||
func (p Plan) Reloads() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, it := range Items {
|
||||
for _, c := range p.Changes {
|
||||
if c.Item != it.ID {
|
||||
continue
|
||||
}
|
||||
cmd := it.Reload
|
||||
if it.Kind == KindNetwork {
|
||||
cmd = "ifreload -a"
|
||||
}
|
||||
if cmd != "" && !seen[cmd] {
|
||||
seen[cmd] = true
|
||||
out = append(out, cmd)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *Plan) errorf(key, format string, a ...any) {
|
||||
p.Errors = append(p.Errors, Issue{key, fmt.Sprintf(format, a...)})
|
||||
}
|
||||
|
||||
func (p *Plan) warnf(key, format string, a ...any) {
|
||||
p.Warnings = append(p.Warnings, Issue{key, fmt.Sprintf(format, a...)})
|
||||
}
|
||||
|
||||
// MakePlan computes the changes that bring the target in line with the master.
|
||||
func MakePlan(prof *Profile, master, target Snapshot) Plan {
|
||||
plan := Plan{Target: target.Key}
|
||||
tm := prof.Mapping[target.Key]
|
||||
if target.Pending && prof.Has("network") {
|
||||
plan.errorf("", "pending network changes on %s (interfaces.new): apply or revert them in PVE first", target.Key)
|
||||
}
|
||||
if master.Pending && prof.Has("network") {
|
||||
plan.warnf("", "the master has pending network changes: its pending configuration is replicated")
|
||||
}
|
||||
for _, id := range prof.Items {
|
||||
it, _ := ItemByID(id)
|
||||
switch it.Kind {
|
||||
case KindNetwork:
|
||||
planNetwork(&plan, prof, tm, master, target)
|
||||
case KindDNS:
|
||||
planDNS(&plan, prof, tm, master, target)
|
||||
case KindFile:
|
||||
planFiles(&plan, prof, it, master.Files[it.ID], target.Files[it.ID])
|
||||
}
|
||||
}
|
||||
sortChanges(plan.Changes)
|
||||
return plan
|
||||
}
|
||||
|
||||
// rank orders the interfaces: what is referenced first (ports, bonds, VLAN
|
||||
// devices), bridges last; deletions run in the reverse order.
|
||||
func netRank(typ string) int {
|
||||
switch typ {
|
||||
case "eth", "alias":
|
||||
return 0
|
||||
case "bond", "OVSBond":
|
||||
return 1
|
||||
case "vlan":
|
||||
return 2
|
||||
case "bridge", "OVSBridge":
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
func sortChanges(chs []Change) {
|
||||
typ := func(c Change) string {
|
||||
if t, ok := c.Full["type"].(string); ok {
|
||||
return t
|
||||
}
|
||||
t, _ := c.Old["type"].(string)
|
||||
return t
|
||||
}
|
||||
sort.SliceStable(chs, func(i, j int) bool {
|
||||
a, b := chs[i], chs[j]
|
||||
if (a.Op == OpDelete) != (b.Op == OpDelete) {
|
||||
return b.Op == OpDelete
|
||||
}
|
||||
if a.Item != b.Item {
|
||||
return itemRank(a.Item) < itemRank(b.Item)
|
||||
}
|
||||
if a.Item == "network" {
|
||||
ra, rb := netRank(typ(a)), netRank(typ(b))
|
||||
if a.Op == OpDelete {
|
||||
ra, rb = rb, ra
|
||||
}
|
||||
if ra != rb {
|
||||
return ra < rb
|
||||
}
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
}
|
||||
|
||||
// netLists are the fields naming other interfaces (space separated lists or
|
||||
// one name): renamed with the NIC mapping of the target.
|
||||
var netLists = map[string]bool{
|
||||
"bridge_ports": true, "slaves": true, "ovs_ports": true, "ovs_bonds": true,
|
||||
"bond-primary": true, "vlan-raw-device": true, "ovs_bridge": true,
|
||||
}
|
||||
|
||||
// rename maps an interface name of the master to the target: eno1, and the
|
||||
// VLAN interfaces built on it (eno1.100).
|
||||
func rename(name string, nics map[string]string) string {
|
||||
if to, ok := nics[name]; ok {
|
||||
return to
|
||||
}
|
||||
if base, vlan, ok := strings.Cut(name, "."); ok {
|
||||
if to, ok := nics[base]; ok {
|
||||
return to + "." + vlan
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func renameList(v string, nics map[string]string) string {
|
||||
parts := strings.Fields(v)
|
||||
for i, p := range parts {
|
||||
parts[i] = rename(p, nics)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func planNetwork(plan *Plan, prof *Profile, tm TargetMap, master, target Snapshot) {
|
||||
cur := map[string]model.Object{}
|
||||
for _, o := range target.Network {
|
||||
cur[o.ID] = o
|
||||
}
|
||||
inMaster := map[string]bool{}
|
||||
for _, m := range master.Network {
|
||||
id := rename(m.ID, tm.NICs)
|
||||
key := "network/" + id
|
||||
inMaster[id] = true
|
||||
desired := map[string]any{}
|
||||
managed := map[string]bool{}
|
||||
for f, v := range m.Fields {
|
||||
if prof.Policy("network", f) != Sync {
|
||||
continue
|
||||
}
|
||||
if netLists[f] {
|
||||
v = renameList(v.(string), tm.NICs)
|
||||
}
|
||||
desired[f] = v
|
||||
}
|
||||
for f := range netWritable {
|
||||
if prof.Policy("network", f) == Sync {
|
||||
managed[f] = true
|
||||
}
|
||||
}
|
||||
for f, v := range tm.Values[key] {
|
||||
if prof.Policy("network", f) == Ignore || !netWritable[f] || f == "type" {
|
||||
continue
|
||||
}
|
||||
desired[f] = v
|
||||
managed[f] = true
|
||||
}
|
||||
typ, _ := desired["type"].(string)
|
||||
o, exists := cur[id]
|
||||
if !exists {
|
||||
if physical(typ) {
|
||||
plan.errorf(key, "physical interface %s absent on %s: add it to the NIC mapping of the target", id, target.Key)
|
||||
continue
|
||||
}
|
||||
plan.Changes = append(plan.Changes, Change{Item: "network", ID: id, Op: OpCreate, Set: desired, Full: desired})
|
||||
continue
|
||||
}
|
||||
if t, _ := o.Fields["type"].(string); t != typ {
|
||||
plan.errorf(key, "type differs (%s on the target, %s on the master): re-create the interface by hand", t, typ)
|
||||
continue
|
||||
}
|
||||
if mo, to := strings.Join(master.Options[m.ID], "\n"), strings.Join(target.Options[id], "\n"); mo != to {
|
||||
plan.warnf(key, "options the API does not write differ (%s): not replicated", optionNames(master.Options[m.ID], target.Options[id]))
|
||||
}
|
||||
ch := Change{Item: "network", ID: id, Op: OpUpdate, Set: map[string]any{}, Old: map[string]any{}}
|
||||
full := map[string]any{}
|
||||
for f, v := range o.Fields {
|
||||
full[f] = v
|
||||
}
|
||||
for f, v := range desired {
|
||||
if !model.Equal(v, o.Fields[f]) {
|
||||
ch.Set[f] = v
|
||||
if old, ok := o.Fields[f]; ok {
|
||||
ch.Old[f] = old
|
||||
}
|
||||
}
|
||||
full[f] = v
|
||||
}
|
||||
for f := range managed {
|
||||
if _, want := desired[f]; want {
|
||||
continue
|
||||
}
|
||||
if old, has := o.Fields[f]; has {
|
||||
ch.Unset = append(ch.Unset, f)
|
||||
ch.Old[f] = old
|
||||
delete(full, f)
|
||||
}
|
||||
}
|
||||
sort.Strings(ch.Unset)
|
||||
if len(ch.Set) > 0 || len(ch.Unset) > 0 {
|
||||
ch.Full = full
|
||||
plan.Changes = append(plan.Changes, ch)
|
||||
}
|
||||
}
|
||||
if !prof.Prune {
|
||||
return
|
||||
}
|
||||
for id, o := range cur {
|
||||
if inMaster[id] {
|
||||
continue
|
||||
}
|
||||
if t, _ := o.Fields["type"].(string); physical(t) {
|
||||
continue
|
||||
}
|
||||
plan.Changes = append(plan.Changes, Change{Item: "network", ID: id, Op: OpDelete, Old: o.Fields})
|
||||
}
|
||||
}
|
||||
|
||||
// optionNames gives the option keywords that differ (bond-miimon, post-up…).
|
||||
func optionNames(a, b []string) string {
|
||||
set := map[string]bool{}
|
||||
count := map[string]int{}
|
||||
for _, l := range a {
|
||||
count[l]++
|
||||
}
|
||||
for _, l := range b {
|
||||
count[l]--
|
||||
}
|
||||
for l, n := range count {
|
||||
if n != 0 {
|
||||
set[strings.Fields(l + " ")[0]] = true
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func planDNS(plan *Plan, prof *Profile, tm TargetMap, master, target Snapshot) {
|
||||
const key = "dns/dns"
|
||||
desired := map[string]any{}
|
||||
for _, f := range dnsFields {
|
||||
pol := prof.Policy("dns", f)
|
||||
if v, ok := master.DNS[f]; ok && pol == Sync {
|
||||
desired[f] = v
|
||||
}
|
||||
if pol != Sync {
|
||||
if v, ok := target.DNS[f]; ok {
|
||||
desired[f] = v // PUT /nodes/{node}/dns rewrites every server
|
||||
}
|
||||
}
|
||||
if v, ok := tm.Values[key][f]; ok && pol != Ignore {
|
||||
desired[f] = v
|
||||
}
|
||||
}
|
||||
ch := Change{Item: "dns", ID: "dns", Op: OpUpdate, Set: map[string]any{}, Old: map[string]any{}, Full: desired}
|
||||
for _, f := range dnsFields {
|
||||
v, want := desired[f]
|
||||
old, has := target.DNS[f]
|
||||
switch {
|
||||
case want && !model.Equal(v, old):
|
||||
ch.Set[f] = v
|
||||
if has {
|
||||
ch.Old[f] = old
|
||||
}
|
||||
case !want && has:
|
||||
ch.Unset = append(ch.Unset, f)
|
||||
ch.Old[f] = old
|
||||
}
|
||||
}
|
||||
if len(ch.Set) == 0 && len(ch.Unset) == 0 {
|
||||
return
|
||||
}
|
||||
if desired["search"] == nil {
|
||||
plan.errorf(key, "a search domain is required by the API (PUT /nodes/{node}/dns)")
|
||||
return
|
||||
}
|
||||
plan.Changes = append(plan.Changes, ch)
|
||||
}
|
||||
|
||||
func planFiles(plan *Plan, prof *Profile, it Item, master, target map[string]File) {
|
||||
var protect []*regexp.Regexp
|
||||
for _, s := range prof.Protect[it.ID] {
|
||||
if re, err := regexp.Compile(s); err == nil {
|
||||
protect = append(protect, re)
|
||||
}
|
||||
}
|
||||
if len(master) == 0 && !it.Glob {
|
||||
plan.warnf(it.ID+"/"+it.Paths[0], "%s absent on the master: not replicated", it.Paths[0])
|
||||
return
|
||||
}
|
||||
paths := make([]string, 0, len(master))
|
||||
for p := range master {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, p := range paths {
|
||||
m := master[p]
|
||||
t, exists := target[p]
|
||||
var content []byte
|
||||
switch {
|
||||
case it.Additive && exists:
|
||||
content = additive(t.Content, m.Content)
|
||||
case exists:
|
||||
content = protectLines(m.Content, t.Content, protect)
|
||||
default:
|
||||
content = protectLines(m.Content, nil, protect)
|
||||
}
|
||||
if exists && hash(content) == t.Hash {
|
||||
continue
|
||||
}
|
||||
ch := Change{Item: it.ID, ID: p, Content: content, Mode: m.Mode, Owner: m.Owner, Hash: "absent", Op: OpCreate}
|
||||
var old []byte
|
||||
if exists {
|
||||
// an update keeps the mode and owner of the target file
|
||||
ch.Op, ch.Hash, ch.Mode, ch.Owner, old = OpUpdate, t.Hash, t.Mode, t.Owner, t.Content
|
||||
}
|
||||
ch.Diff = Diff(old, content)
|
||||
plan.Changes = append(plan.Changes, ch)
|
||||
}
|
||||
if !prof.Prune || !it.Glob {
|
||||
return
|
||||
}
|
||||
for p, t := range target {
|
||||
if _, ok := master[p]; ok {
|
||||
continue
|
||||
}
|
||||
plan.Changes = append(plan.Changes, Change{Item: it.ID, ID: p, Op: OpDelete, Hash: t.Hash, Diff: Diff(t.Content, nil)})
|
||||
}
|
||||
}
|
||||
|
||||
// protectLines: each line of the master matching a protected expression takes
|
||||
// the value of the first line of the target matching the same expression
|
||||
// (dropped when the target has none).
|
||||
func protectLines(master, target []byte, protect []*regexp.Regexp) []byte {
|
||||
if len(protect) == 0 {
|
||||
return master
|
||||
}
|
||||
tl := lines(target)
|
||||
used := map[int]bool{}
|
||||
var out []string
|
||||
for _, l := range lines(master) {
|
||||
var re *regexp.Regexp
|
||||
for _, r := range protect {
|
||||
if r.MatchString(l) {
|
||||
re = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if re == nil {
|
||||
out = append(out, l)
|
||||
continue
|
||||
}
|
||||
for i, x := range tl {
|
||||
if !used[i] && re.MatchString(x) {
|
||||
used[i] = true
|
||||
out = append(out, x)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return joinLines(out)
|
||||
}
|
||||
|
||||
// additive appends to the target the lines of the master it does not have.
|
||||
func additive(target, master []byte) []byte {
|
||||
tl := lines(target)
|
||||
have := map[string]bool{}
|
||||
for _, l := range tl {
|
||||
have[l] = true
|
||||
}
|
||||
out := tl
|
||||
for _, l := range lines(master) {
|
||||
if !have[l] {
|
||||
have[l] = true
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return joinLines(out)
|
||||
}
|
||||
|
||||
func joinLines(l []string) []byte {
|
||||
if len(l) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []byte(strings.Join(l, "\n") + "\n")
|
||||
}
|
||||
|
||||
// Filter keeps the changes of the given objects ("item/id"), plus the
|
||||
// interfaces they need that the plan creates (ports of a bridge, raw device
|
||||
// of a VLAN). Issues about other objects do not block the selection.
|
||||
func Filter(p Plan, keys []string) Plan {
|
||||
if len(keys) == 0 {
|
||||
return p
|
||||
}
|
||||
byKey := map[string]Change{}
|
||||
for _, c := range p.Changes {
|
||||
byKey[c.Key()] = c
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, k := range keys {
|
||||
want[k] = true
|
||||
}
|
||||
for changed := true; changed; {
|
||||
changed = false
|
||||
for k := range want {
|
||||
c, ok := byKey[k]
|
||||
if !ok || c.Item != "network" || c.Op == OpDelete {
|
||||
continue
|
||||
}
|
||||
for f, v := range c.Full {
|
||||
if !netLists[f] {
|
||||
continue
|
||||
}
|
||||
for _, n := range strings.Fields(v.(string)) {
|
||||
dep := "network/" + n
|
||||
if d, ok := byKey[dep]; ok && d.Op == OpCreate && !want[dep] {
|
||||
want[dep] = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := Plan{Target: p.Target}
|
||||
for _, e := range p.Errors {
|
||||
if e.Key == "" || want[e.Key] {
|
||||
out.Errors = append(out.Errors, e)
|
||||
}
|
||||
}
|
||||
for _, w := range p.Warnings {
|
||||
if w.Key == "" || want[w.Key] {
|
||||
out.Warnings = append(out.Warnings, w)
|
||||
}
|
||||
}
|
||||
for _, c := range p.Changes {
|
||||
if want[c.Key()] {
|
||||
out.Changes = append(out.Changes, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Digest identifies the content of a plan: the apply only runs when the plan
|
||||
// computed again is the one reviewed in the dry-run.
|
||||
func (p Plan) Digest() string {
|
||||
type ch struct {
|
||||
Op Op `json:"op"`
|
||||
Key string `json:"key"`
|
||||
Set map[string]any `json:"set,omitempty"`
|
||||
Unset []string `json:"unset,omitempty"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
New string `json:"new,omitempty"`
|
||||
}
|
||||
l := make([]ch, 0, len(p.Changes))
|
||||
for _, c := range p.Changes {
|
||||
x := ch{Op: c.Op, Key: c.Key(), Set: c.Set, Unset: c.Unset, Hash: c.Hash}
|
||||
if c.Content != nil || c.Op != OpDelete && c.Hash != "" {
|
||||
x.New = hash(c.Content)
|
||||
}
|
||||
l = append(l, x)
|
||||
}
|
||||
errs := make([]string, 0, len(p.Errors))
|
||||
for _, e := range p.Errors {
|
||||
errs = append(errs, e.Key+" "+e.Message)
|
||||
}
|
||||
sort.Strings(errs)
|
||||
b, _ := json.Marshal(struct {
|
||||
Target string `json:"target"`
|
||||
Changes []ch `json:"changes"`
|
||||
Errors []string `json:"errors"`
|
||||
}{p.Target, l, errs})
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:12])
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package nodesync
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/bits"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pve-confsync/internal/model"
|
||||
"pve-confsync/internal/pve"
|
||||
)
|
||||
|
||||
// Shell runs a command as root on the node, stdin fed with in (sshx.Client).
|
||||
type Shell interface {
|
||||
Run(ctx context.Context, cmd string, in []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// Endpoint is one node: the API of its cluster (calls proxied to the node)
|
||||
// and SSH as root on the node itself.
|
||||
type Endpoint struct {
|
||||
Key string // "cluster/node"
|
||||
Cluster string
|
||||
Node string
|
||||
API *pve.Client
|
||||
Shell Shell // nil: SSH host key not recorded yet
|
||||
// Dial opens a new SSH access, to check that the node still answers after
|
||||
// a network reload (nil: Shell is used).
|
||||
Dial func() (Shell, error)
|
||||
}
|
||||
|
||||
// File is a file read on a node.
|
||||
type File struct {
|
||||
Path string `json:"path"`
|
||||
Mode string `json:"mode"` // octal, e.g. 644
|
||||
Owner string `json:"owner"` // user:group
|
||||
Hash string `json:"hash"` // SHA-256 of the content
|
||||
Content []byte `json:"-"`
|
||||
}
|
||||
|
||||
// Snapshot is what was read on one node.
|
||||
type Snapshot struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Network []model.Object `json:"-"` // interfaces (id = iface), canonical fields
|
||||
Options map[string][]string `json:"-"` // iface -> lines the API does not write (bond-miimon…)
|
||||
DNS map[string]any `json:"-"`
|
||||
Files map[string]map[string]File `json:"-"` // item -> path -> file
|
||||
// Pending: /etc/network/interfaces.new exists (network changes not applied yet).
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
ReadAt time.Time `json:"readAt"`
|
||||
}
|
||||
|
||||
// maxFile: larger files are not replicated (configuration files are small).
|
||||
const maxFile = 1 << 20
|
||||
|
||||
// Read loads the given items from a node.
|
||||
func Read(ctx context.Context, ep Endpoint, items []string) (Snapshot, error) {
|
||||
snap := Snapshot{Key: ep.Key, Files: map[string]map[string]File{}, ReadAt: time.Now().UTC()}
|
||||
v, err := NodeVersion(ctx, ep)
|
||||
if err != nil {
|
||||
return snap, err
|
||||
}
|
||||
snap.Version = v
|
||||
var files []Item
|
||||
for _, id := range items {
|
||||
it, err := ItemByID(id)
|
||||
if err != nil {
|
||||
return snap, err
|
||||
}
|
||||
switch it.Kind {
|
||||
case KindNetwork:
|
||||
if snap.Network, snap.Options, err = readNetwork(ctx, ep); err != nil {
|
||||
return snap, fmt.Errorf("%s: read network: %w", ep.Key, err)
|
||||
}
|
||||
case KindDNS:
|
||||
if snap.DNS, err = readDNS(ctx, ep); err != nil {
|
||||
return snap, fmt.Errorf("%s: read dns: %w", ep.Key, err)
|
||||
}
|
||||
case KindFile:
|
||||
files = append(files, it)
|
||||
}
|
||||
}
|
||||
// SSH is needed for the files, and for the pending network changes
|
||||
if ep.Shell == nil {
|
||||
return snap, fmt.Errorf("%s: SSH host key not recorded yet, test the node first", ep.Key)
|
||||
}
|
||||
out, err := ep.Shell.Run(ctx, readScript(files), nil)
|
||||
if err != nil {
|
||||
return snap, fmt.Errorf("%s: read files: %w", ep.Key, err)
|
||||
}
|
||||
got, pending, warns, err := parseRead(out)
|
||||
if err != nil {
|
||||
return snap, fmt.Errorf("%s: read files: %w", ep.Key, err)
|
||||
}
|
||||
snap.Pending = pending
|
||||
snap.Warnings = append(snap.Warnings, warns...)
|
||||
for _, it := range files {
|
||||
m := map[string]File{}
|
||||
for p, f := range got {
|
||||
if matchItem(it, p) {
|
||||
m[p] = f
|
||||
}
|
||||
}
|
||||
snap.Files[it.ID] = m
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// NodeVersion reads the pve-manager version of the node and refuses anything
|
||||
// but PVE 9.x.
|
||||
func NodeVersion(ctx context.Context, ep Endpoint) (string, error) {
|
||||
var v struct {
|
||||
Version string `json:"version"`
|
||||
Release string `json:"release"`
|
||||
}
|
||||
if err := ep.API.Get(ctx, "nodes/"+url.PathEscape(ep.Node)+"/version", nil, &v); err != nil {
|
||||
return "", fmt.Errorf("%s: version check: %w", ep.Key, err)
|
||||
}
|
||||
ref := v.Release
|
||||
if ref == "" {
|
||||
ref = v.Version
|
||||
}
|
||||
if major, err := strconv.Atoi(strings.SplitN(ref, ".", 2)[0]); err != nil || major != pve.SupportedMajor {
|
||||
return v.Version, fmt.Errorf("%s: node runs PVE %q, only %d.x is supported", ep.Key, ref, pve.SupportedMajor)
|
||||
}
|
||||
return v.Version, nil
|
||||
}
|
||||
|
||||
func matchItem(it Item, p string) bool {
|
||||
for _, x := range it.Exclude {
|
||||
if x == p {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, pat := range it.Paths {
|
||||
if ok, _ := path.Match(pat, p); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readScript lists the files of the items: one header line per file
|
||||
// ("F <mode> <user:group> <path>", "L <size> <path>" when too large) then
|
||||
// its content in base64 on one line; "PENDING" when interfaces.new exists.
|
||||
// The first line is a marker the fake node of the tests understands.
|
||||
func readScript(files []Item) string {
|
||||
var pats []string
|
||||
for _, it := range files {
|
||||
pats = append(pats, it.Paths...)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# confsync:read\n")
|
||||
if len(pats) > 0 {
|
||||
fmt.Fprintf(&b, "for f in %s; do\n", strings.Join(pats, " "))
|
||||
b.WriteString(` [ -f "$f" ] && [ ! -L "$f" ] || continue
|
||||
s=$(stat -c %s "$f")
|
||||
if [ "$s" -gt ` + strconv.Itoa(maxFile) + ` ]; then printf 'L %s %s\n' "$s" "$f"; continue; fi
|
||||
printf 'F %s %s\n' "$(stat -c '%a %U:%G' "$f")" "$f"
|
||||
base64 -w0 < "$f"; echo
|
||||
done
|
||||
`)
|
||||
}
|
||||
b.WriteString("[ -e /etc/network/interfaces.new ] && echo PENDING\ntrue\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func parseRead(out []byte) (map[string]File, bool, []string, error) {
|
||||
files := map[string]File{}
|
||||
pending := false
|
||||
var warns []string
|
||||
sc := bufio.NewScanner(bytes.NewReader(out))
|
||||
sc.Buffer(make([]byte, 64<<10), 4*maxFile)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case line == "PENDING":
|
||||
pending = true
|
||||
case strings.HasPrefix(line, "L "):
|
||||
p := strings.SplitN(line, " ", 3)
|
||||
if len(p) == 3 {
|
||||
warns = append(warns, fmt.Sprintf("%s: %s bytes, too large, not replicated", p[2], p[1]))
|
||||
}
|
||||
case strings.HasPrefix(line, "F "):
|
||||
p := strings.SplitN(line, " ", 4)
|
||||
if len(p) != 4 || !sc.Scan() {
|
||||
return nil, false, nil, fmt.Errorf("unexpected output %q", line)
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sc.Text()))
|
||||
if err != nil {
|
||||
return nil, false, nil, fmt.Errorf("%s: %w", p[3], err)
|
||||
}
|
||||
if !safePath(p[3]) {
|
||||
warns = append(warns, fmt.Sprintf("%s: unexpected characters in the path, not replicated", p[3]))
|
||||
continue
|
||||
}
|
||||
files[p[3]] = File{Path: p[3], Mode: p[1], Owner: p[2], Hash: hash(data), Content: data}
|
||||
}
|
||||
}
|
||||
return files, pending, warns, sc.Err()
|
||||
}
|
||||
|
||||
// safePath: the paths go into shell scripts between single quotes.
|
||||
func safePath(p string) bool {
|
||||
if !strings.HasPrefix(p, "/") || strings.Contains(p, "..") {
|
||||
return false
|
||||
}
|
||||
for _, r := range p {
|
||||
if !(r == '/' || r == '.' || r == '-' || r == '_' || r == '+' || r == '@' ||
|
||||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hash(b []byte) string {
|
||||
s := sha256.Sum256(b)
|
||||
return hex.EncodeToString(s[:])
|
||||
}
|
||||
|
||||
// ---- network (GET /nodes/{node}/network) ----
|
||||
|
||||
// netWritable are the parameters of POST/PUT /nodes/{node}/network[/{iface}]
|
||||
// (pve-manager PVE/API2/Network.pm, $confdesc), in their cidr form: the GET
|
||||
// returns address/netmask and cidr, the canonical form keeps cidr only.
|
||||
var netWritable = map[string]bool{
|
||||
"type": true, "comments": true, "comments6": true, "autostart": true,
|
||||
"bridge_vlan_aware": true, "bridge_vids": true, "bridge_ports": true,
|
||||
"ovs_ports": true, "ovs_tag": true, "ovs_options": true, "ovs_bridge": true, "ovs_bonds": true,
|
||||
"slaves": true, "bond_mode": true, "bond-primary": true, "bond_xmit_hash_policy": true,
|
||||
"vlan-raw-device": true, "vlan-id": true,
|
||||
"gateway": true, "cidr": true, "gateway6": true, "cidr6": true, "mtu": true,
|
||||
}
|
||||
|
||||
// netTypes are the interface types handled (fabric, vnet and unknown are not).
|
||||
var netTypes = map[string]bool{
|
||||
"eth": true, "bond": true, "bridge": true, "vlan": true, "alias": true,
|
||||
"OVSBridge": true, "OVSBond": true, "OVSPort": true, "OVSIntPort": true,
|
||||
}
|
||||
|
||||
// physical interfaces cannot be created: they must exist on the target.
|
||||
func physical(typ string) bool { return typ == "eth" }
|
||||
|
||||
func readNetwork(ctx context.Context, ep Endpoint) ([]model.Object, map[string][]string, error) {
|
||||
var raw []map[string]any
|
||||
if err := ep.API.Get(ctx, "nodes/"+url.PathEscape(ep.Node)+"/network", nil, &raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var objs []model.Object
|
||||
opts := map[string][]string{}
|
||||
for _, r := range raw {
|
||||
id := pve.Str(r["iface"])
|
||||
typ := pve.Str(r["type"])
|
||||
if id == "" || id == "lo" || !netTypes[typ] {
|
||||
continue
|
||||
}
|
||||
f := map[string]any{}
|
||||
for k, v := range r {
|
||||
if !netWritable[k] || v == nil {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimRight(pve.Str(v), "\n")
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
f[k] = s
|
||||
}
|
||||
// a static address without cidr (older parsers): rebuild it
|
||||
if _, ok := f["cidr"]; !ok {
|
||||
if a, m := pve.Str(r["address"]), pve.Str(r["netmask"]); a != "" && m != "" {
|
||||
f["cidr"] = a + "/" + maskBits(m)
|
||||
}
|
||||
}
|
||||
if l := listOf(r["options"]); len(l) > 0 {
|
||||
opts[id] = l
|
||||
}
|
||||
if l := listOf(r["options6"]); len(l) > 0 {
|
||||
opts[id] = append(opts[id], l...)
|
||||
}
|
||||
objs = append(objs, model.Object{Kind: "network", ID: id, Fields: f})
|
||||
}
|
||||
sort.Slice(objs, func(i, j int) bool { return objs[i].ID < objs[j].ID })
|
||||
return objs, opts, nil
|
||||
}
|
||||
|
||||
func listOf(v any) []string {
|
||||
l, _ := v.([]any)
|
||||
out := make([]string, 0, len(l))
|
||||
for _, e := range l {
|
||||
out = append(out, pve.Str(e))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// maskBits converts 255.255.255.0 to 24 (a prefix length is kept).
|
||||
func maskBits(m string) string {
|
||||
if !strings.Contains(m, ".") {
|
||||
return m
|
||||
}
|
||||
n := 0
|
||||
for _, p := range strings.Split(m, ".") {
|
||||
b, _ := strconv.Atoi(p)
|
||||
n += bits.OnesCount8(uint8(b))
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// ---- dns (GET /nodes/{node}/dns) ----
|
||||
|
||||
var dnsFields = []string{"search", "dns1", "dns2", "dns3"}
|
||||
|
||||
func readDNS(ctx context.Context, ep Endpoint) (map[string]any, error) {
|
||||
var raw map[string]any
|
||||
if err := ep.API.Get(ctx, "nodes/"+url.PathEscape(ep.Node)+"/dns", nil, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{}
|
||||
for _, k := range dnsFields {
|
||||
if s := pve.Str(raw[k]); s != "" {
|
||||
out[k] = s
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package nodesync
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPrepareScriptForReal runs the prepare script and restore.sh with the
|
||||
// local shell in a temporary folder (every path prefixed with it).
|
||||
func TestPrepareScriptForReal(t *testing.T) {
|
||||
if _, err := exec.LookPath("sha256sum"); err != nil {
|
||||
t.Skip("sha256sum not installed")
|
||||
}
|
||||
root := t.TempDir()
|
||||
u, _ := user.Current()
|
||||
g, _ := user.LookupGroupId(u.Gid)
|
||||
owner := u.Username + ":" + g.Name
|
||||
write := func(p, s string) {
|
||||
if err := os.MkdirAll(filepath.Dir(root+p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(root+p, []byte(s), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
read := func(p string) string {
|
||||
b, err := os.ReadFile(root + p)
|
||||
if err != nil {
|
||||
return "<absent>"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
write("/etc/sysctl.d/90-net.conf", "old\n")
|
||||
write("/etc/sysctl.d/99-local.conf", "local\n")
|
||||
write("/etc/network/interfaces", "auto lo\n")
|
||||
plan := Plan{Changes: []Change{
|
||||
{Item: "sysctl", ID: "/etc/sysctl.d/90-net.conf", Op: OpUpdate, Hash: hash([]byte("old\n")), Mode: "640", Owner: owner, Content: []byte("new\n")},
|
||||
{Item: "multipath", ID: "/etc/multipath.conf", Op: OpCreate, Hash: "absent", Mode: "644", Owner: owner, Content: []byte("defaults {}\n")},
|
||||
{Item: "sysctl", ID: "/etc/sysctl.d/99-local.conf", Op: OpDelete, Hash: hash([]byte("local\n"))},
|
||||
{Item: "network", ID: "vmbr0", Op: OpUpdate},
|
||||
}}
|
||||
run := func(script string, in []byte) (string, error) {
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
cmd.Stdin = strings.NewReader(string(in))
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
const dir = "/var/lib/pve-confsync/backup/test"
|
||||
if out, err := run(prepareScriptIn(dir, root), prepareInput(plan, true, false)); err != nil || strings.TrimSpace(out) != "ok" {
|
||||
t.Fatalf("prepare: %v %s", err, out)
|
||||
}
|
||||
if read("/etc/sysctl.d/90-net.conf") != "new\n" || read("/etc/multipath.conf") != "defaults {}\n" || read("/etc/sysctl.d/99-local.conf") != "<absent>" {
|
||||
t.Fatalf("writes: %q %q %q", read("/etc/sysctl.d/90-net.conf"), read("/etc/multipath.conf"), read("/etc/sysctl.d/99-local.conf"))
|
||||
}
|
||||
if fi, _ := os.Stat(root + "/etc/sysctl.d/90-net.conf"); fi.Mode().Perm() != 0o640 {
|
||||
t.Errorf("mode %v", fi.Mode())
|
||||
}
|
||||
if m, _ := filepath.Glob(root + "/etc/sysctl.d/*.confsync.*"); len(m) > 0 {
|
||||
t.Errorf("temporary files left: %v", m)
|
||||
}
|
||||
// a second run with the same plan: the files changed, nothing is written
|
||||
if out, err := run(prepareScriptIn(dir+"2", root), prepareInput(plan, false, false)); err == nil || !strings.Contains(out, "changed since the dry-run") {
|
||||
t.Errorf("stale check: %v %s", err, out)
|
||||
}
|
||||
write("/etc/network/interfaces.new", "pending\n")
|
||||
if out, err := run("sh '"+root+dir+"/restore.sh'", nil); err != nil {
|
||||
t.Fatalf("restore: %v %s", err, out)
|
||||
}
|
||||
if read("/etc/sysctl.d/90-net.conf") != "old\n" || read("/etc/multipath.conf") != "<absent>" || read("/etc/sysctl.d/99-local.conf") != "local\n" ||
|
||||
read("/etc/network/interfaces") != "auto lo\n" || read("/etc/network/interfaces.new") != "<absent>" {
|
||||
t.Errorf("restore: %q %q %q %q", read("/etc/sysctl.d/90-net.conf"), read("/etc/multipath.conf"), read("/etc/sysctl.d/99-local.conf"), read("/etc/network/interfaces.new"))
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ type Server struct {
|
||||
Shadow map[string]string // user without realm -> hash (priv/shadow.cfg)
|
||||
FailOn string // "METHOD path" answering 500, to test rollbacks
|
||||
Calls []string // "METHOD path" of every write, "SSH write" for shadow.cfg
|
||||
Nodes []*Node // nodes of the node replication (AddNode); none: one node "n1"
|
||||
locks int
|
||||
ssh *sshServer
|
||||
}
|
||||
@@ -83,6 +84,9 @@ func New(version string) *Server {
|
||||
// Close stops the API and the SSH server.
|
||||
func (s *Server) Close() {
|
||||
s.ssh.close()
|
||||
for _, n := range s.Nodes {
|
||||
n.ssh.close()
|
||||
}
|
||||
s.Server.Close()
|
||||
}
|
||||
|
||||
@@ -146,14 +150,26 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
if running {
|
||||
cfg = &s.Running
|
||||
}
|
||||
var nd *Node
|
||||
if len(seg) >= 3 && seg[0] == "nodes" {
|
||||
nd = s.node(seg[1])
|
||||
}
|
||||
switch {
|
||||
case nd != nil && r.Method == "GET" && len(seg) == 3 && seg[2] == "version":
|
||||
reply(w, map[string]any{"version": nd.version(), "release": release(nd.version()), "repoid": "abcdef"})
|
||||
case nd != nil && (seg[2] == "network" || seg[2] == "dns"):
|
||||
s.nodeAPI(w, r, nd, seg)
|
||||
case key == "GET version" || (r.Method == "GET" && len(seg) == 3 && seg[0] == "nodes" && seg[2] == "version"):
|
||||
reply(w, map[string]any{"version": s.Version, "release": release(s.Version), "repoid": "abcdef"})
|
||||
case key == "GET cluster/status":
|
||||
reply(w, []any{
|
||||
map[string]any{"type": "cluster", "name": "fake", "quorate": 1},
|
||||
map[string]any{"type": "node", "name": "n1", "online": 1},
|
||||
})
|
||||
st := []any{map[string]any{"type": "cluster", "name": "fake", "quorate": 1}}
|
||||
if len(s.Nodes) == 0 {
|
||||
st = append(st, map[string]any{"type": "node", "name": "n1", "online": 1})
|
||||
}
|
||||
for i, n := range s.Nodes {
|
||||
st = append(st, map[string]any{"type": "node", "name": n.Name, "online": 1, "nodeid": i + 1, "ip": "127.0.0.1"})
|
||||
}
|
||||
reply(w, st)
|
||||
case r.Method == "GET" && len(seg) == 4 && seg[0] == "nodes" && seg[2] == "sdn" && seg[3] == "zones":
|
||||
var out []any
|
||||
for id := range s.Running.Zones {
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package pvetest
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// FakeFile is a file of a fake node.
|
||||
type FakeFile struct {
|
||||
Content string
|
||||
Mode string // 644 by default
|
||||
Owner string // root:root by default
|
||||
}
|
||||
|
||||
// Node is a node of a fake cluster for the node replication: its own SSH
|
||||
// server (files, reloads, restore timer), and its network interfaces and DNS
|
||||
// servers served by the API of the cluster (/nodes/{node}/network, /dns).
|
||||
// Like PVE, the interface writes go to a pending configuration
|
||||
// (interfaces.new) applied by PUT /nodes/{node}/network.
|
||||
type Node struct {
|
||||
Name string
|
||||
Hostname string // answered by hostname (default: Name)
|
||||
Version string // pve-manager version (default: the cluster's)
|
||||
Files map[string]FakeFile
|
||||
Net map[string]map[string]any // iface -> fields as written (cidr, type…)
|
||||
Pending map[string]map[string]any // interfaces.new; nil: no pending change
|
||||
DNS map[string]string
|
||||
Reloads []string // reload commands run, "network" for PUT /network
|
||||
Armed map[string]bool // restore timers armed (systemd-run units)
|
||||
FailOn string // "reload <cmd>", "prepare" or "probe": that step fails
|
||||
Calls []string // SSH steps run
|
||||
backups map[string]backup
|
||||
srv *Server
|
||||
ssh *sshServer
|
||||
}
|
||||
|
||||
type backup struct {
|
||||
files map[string]*FakeFile // nil: absent before
|
||||
net map[string]map[string]any
|
||||
pending map[string]map[string]any
|
||||
dns map[string]string
|
||||
hasNet bool
|
||||
hasDNS bool
|
||||
}
|
||||
|
||||
// AddNode adds a node to the fake cluster (listed by GET /cluster/status).
|
||||
func (s *Server) AddNode(name string) *Node {
|
||||
n := &Node{Name: name, Hostname: name, Files: map[string]FakeFile{}, Net: map[string]map[string]any{},
|
||||
DNS: map[string]string{}, Armed: map[string]bool{}, backups: map[string]backup{}, srv: s}
|
||||
n.ssh = newSSH(func(user, pass string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return user == "root" && pass == s.Password
|
||||
}, n.exec)
|
||||
s.mu.Lock()
|
||||
s.Nodes = append(s.Nodes, n)
|
||||
s.mu.Unlock()
|
||||
return n
|
||||
}
|
||||
|
||||
// SSHAddr returns the host and port of the SSH server of the node.
|
||||
func (n *Node) SSHAddr() (string, int) {
|
||||
a := n.ssh.ln.Addr().(*net.TCPAddr)
|
||||
return a.IP.String(), a.Port
|
||||
}
|
||||
|
||||
// SSHHostKey returns the host key of the node (authorized_keys format).
|
||||
func (n *Node) SSHHostKey() string {
|
||||
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(n.ssh.key)))
|
||||
}
|
||||
|
||||
func (s *Server) node(name string) *Node {
|
||||
for _, n := range s.Nodes {
|
||||
if n.Name == name {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Node) version() string {
|
||||
if n.Version != "" {
|
||||
return n.Version
|
||||
}
|
||||
return n.srv.Version
|
||||
}
|
||||
|
||||
func cloneNet(m map[string]map[string]any) map[string]map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]map[string]any{}
|
||||
for k, v := range m {
|
||||
f := map[string]any{}
|
||||
for x, y := range v {
|
||||
f[x] = y
|
||||
}
|
||||
out[k] = f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- API (called with s.mu held) ----
|
||||
|
||||
func (s *Server) nodeAPI(w http.ResponseWriter, r *http.Request, n *Node, seg []string) {
|
||||
switch {
|
||||
case len(seg) == 3 && seg[2] == "dns" && r.Method == "GET":
|
||||
out := map[string]any{}
|
||||
for k, v := range n.DNS {
|
||||
out[k] = v
|
||||
}
|
||||
reply(w, out)
|
||||
case len(seg) == 3 && seg[2] == "dns" && r.Method == "PUT":
|
||||
if r.PostForm.Get("search") == "" {
|
||||
fail(w, 400, "search: property is missing and it is not optional")
|
||||
return
|
||||
}
|
||||
n.DNS = map[string]string{}
|
||||
for _, k := range []string{"search", "dns1", "dns2", "dns3"} {
|
||||
if v := r.PostForm.Get(k); v != "" {
|
||||
n.DNS[k] = v
|
||||
}
|
||||
}
|
||||
reply(w, nil)
|
||||
case len(seg) == 3 && seg[2] == "network" && r.Method == "GET":
|
||||
cfg := n.Net
|
||||
if n.Pending != nil {
|
||||
cfg = n.Pending
|
||||
}
|
||||
var out []any
|
||||
prio := 0
|
||||
for _, id := range sortedKeys(cfg) {
|
||||
prio++
|
||||
f := map[string]any{"iface": id, "priority": prio, "families": []any{"inet"}, "method": "manual", "active": 1}
|
||||
for k, v := range cfg[id] {
|
||||
f[k] = v
|
||||
}
|
||||
if c, ok := f["cidr"].(string); ok {
|
||||
a, m, _ := strings.Cut(c, "/")
|
||||
f["address"], f["netmask"], f["method"] = a, m, "static"
|
||||
}
|
||||
if f["type"] == "eth" {
|
||||
f["exists"] = 1
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
reply(w, out)
|
||||
case len(seg) == 3 && seg[2] == "network" && r.Method == "DELETE":
|
||||
n.Pending = nil
|
||||
reply(w, nil)
|
||||
case len(seg) == 3 && seg[2] == "network" && r.Method == "PUT":
|
||||
if n.Pending != nil {
|
||||
n.Net, n.Pending = n.Pending, nil
|
||||
}
|
||||
n.Reloads = append(n.Reloads, "network")
|
||||
reply(w, "UPID:"+n.Name+":00001234:00005678:65000000:srvreload:networking:root@pam:")
|
||||
case len(seg) == 3 && seg[2] == "network" && r.Method == "POST":
|
||||
id := r.PostForm.Get("iface")
|
||||
cfg := n.pending()
|
||||
if cfg[id] != nil {
|
||||
fail(w, 400, "iface: interface already exists")
|
||||
return
|
||||
}
|
||||
cfg[id] = fields(r.PostForm, "iface")
|
||||
reply(w, nil)
|
||||
case len(seg) == 4 && seg[2] == "network":
|
||||
cfg := n.pending()
|
||||
cur := cfg[seg[3]]
|
||||
if cur == nil {
|
||||
fail(w, 400, "iface: interface does not exist")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "DELETE":
|
||||
delete(cfg, seg[3])
|
||||
case "PUT":
|
||||
if r.PostForm.Get("type") == "" {
|
||||
fail(w, 400, "type: property is missing and it is not optional")
|
||||
return
|
||||
}
|
||||
for _, d := range strings.Split(r.PostForm.Get("delete"), ",") {
|
||||
delete(cur, d)
|
||||
}
|
||||
// like PVE: without an address in the parameters, the interface
|
||||
// becomes "manual" and loses its address
|
||||
if r.PostForm.Get("cidr") == "" {
|
||||
delete(cur, "cidr")
|
||||
}
|
||||
for k, v := range fields(r.PostForm, "delete") {
|
||||
cur[k] = v
|
||||
}
|
||||
default:
|
||||
fail(w, 501, "not implemented in fake")
|
||||
return
|
||||
}
|
||||
reply(w, nil)
|
||||
default:
|
||||
fail(w, 501, "not implemented in fake: "+r.Method+" "+strings.Join(seg, "/"))
|
||||
}
|
||||
}
|
||||
|
||||
// pending returns interfaces.new, created from the current configuration.
|
||||
func (n *Node) pending() map[string]map[string]any {
|
||||
if n.Pending == nil {
|
||||
n.Pending = cloneNet(n.Net)
|
||||
}
|
||||
return n.Pending
|
||||
}
|
||||
|
||||
// ---- SSH ----
|
||||
|
||||
func (n *Node) exec(cmd string, in io.Reader, out, errw io.Writer) int {
|
||||
s := n.srv
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
first, _, _ := strings.Cut(cmd, "\n")
|
||||
verb, arg, _ := strings.Cut(strings.TrimPrefix(first, "# confsync:"), " ")
|
||||
if strings.HasPrefix(first, "# confsync:") {
|
||||
n.Calls = append(n.Calls, verb)
|
||||
if n.FailOn == verb || n.FailOn == verb+" "+arg {
|
||||
fmt.Fprintln(errw, "injected failure")
|
||||
return 1
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(cmd, "pveversion"):
|
||||
fmt.Fprintf(out, "pve-manager/%s/abcdef (running kernel: 6.14.8-2-pve)\n", n.version())
|
||||
if strings.Contains(cmd, "hostname") {
|
||||
fmt.Fprintln(out, n.Hostname)
|
||||
}
|
||||
return 0
|
||||
case verb == "read":
|
||||
return n.read(cmd, out)
|
||||
case verb == "prepare":
|
||||
data, _ := io.ReadAll(in)
|
||||
return n.prepare(arg, data, errw)
|
||||
case verb == "restore":
|
||||
b, ok := n.backups[arg]
|
||||
if !ok {
|
||||
fmt.Fprintln(errw, "no backup "+arg)
|
||||
return 1
|
||||
}
|
||||
n.restore(b)
|
||||
return 0
|
||||
case verb == "arm":
|
||||
n.Armed[arg] = true
|
||||
return 0
|
||||
case verb == "disarm":
|
||||
delete(n.Armed, arg)
|
||||
return 0
|
||||
case verb == "reload", verb == "probe":
|
||||
if verb == "reload" {
|
||||
n.Reloads = append(n.Reloads, arg)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(errw, "fake node: unknown command %q\n", first)
|
||||
return 127
|
||||
}
|
||||
|
||||
// Fire runs the armed restore timers (as systemd would after the delay).
|
||||
func (n *Node) Fire() {
|
||||
n.srv.mu.Lock()
|
||||
defer n.srv.mu.Unlock()
|
||||
for unit := range n.Armed {
|
||||
dir := strings.Replace(unit, "pve-confsync-restore-", "/var/lib/pve-confsync/backup/", 1)
|
||||
if b, ok := n.backups[dir]; ok {
|
||||
n.restore(b)
|
||||
}
|
||||
n.Reloads = append(n.Reloads, "ifreload -a")
|
||||
delete(n.Armed, unit)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Node) read(cmd string, out io.Writer) int {
|
||||
var pats []string
|
||||
for _, l := range strings.Split(cmd, "\n") {
|
||||
if strings.HasPrefix(l, "for f in ") {
|
||||
pats = strings.Fields(strings.TrimSuffix(strings.TrimPrefix(l, "for f in "), "; do"))
|
||||
}
|
||||
}
|
||||
for _, p := range sortedKeys(n.Files) {
|
||||
for _, pat := range pats {
|
||||
if ok, _ := path.Match(pat, p); ok {
|
||||
f := n.Files[p]
|
||||
fmt.Fprintf(out, "F %s %s %s\n%s\n", def(f.Mode, "644"), def(f.Owner, "root:root"), p,
|
||||
base64.StdEncoding.EncodeToString([]byte(f.Content)))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.Pending != nil {
|
||||
fmt.Fprintln(out, "PENDING")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func def(v, d string) string {
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// prepare emulates the prepare script: checks, backups, writes.
|
||||
func (n *Node) prepare(dir string, plan []byte, errw io.Writer) int {
|
||||
type op struct{ op, mode, owner, hash, path, data string }
|
||||
var ops []op
|
||||
sc := bufio.NewScanner(bytes.NewReader(plan))
|
||||
sc.Buffer(make([]byte, 64<<10), 8<<20)
|
||||
for sc.Scan() {
|
||||
p := strings.SplitN(sc.Text(), " ", 5)
|
||||
if len(p) != 5 {
|
||||
continue
|
||||
}
|
||||
o := op{p[0], p[1], p[2], p[3], p[4], ""}
|
||||
if o.op == "W" && sc.Scan() {
|
||||
o.data = sc.Text()
|
||||
}
|
||||
ops = append(ops, o)
|
||||
}
|
||||
for _, o := range ops {
|
||||
if o.op != "W" && o.op != "D" {
|
||||
continue
|
||||
}
|
||||
h := "absent"
|
||||
if f, ok := n.Files[o.path]; ok {
|
||||
h = sha([]byte(f.Content))
|
||||
}
|
||||
if h != o.hash {
|
||||
fmt.Fprintf(errw, "changed since the dry-run: %s\n", o.path)
|
||||
return 3
|
||||
}
|
||||
}
|
||||
b := backup{files: map[string]*FakeFile{}}
|
||||
for _, o := range ops {
|
||||
switch o.path {
|
||||
case "/etc/network/interfaces", "/etc/network/interfaces.new":
|
||||
b.hasNet, b.net, b.pending = true, cloneNet(n.Net), cloneNet(n.Pending)
|
||||
continue
|
||||
case "/etc/resolv.conf":
|
||||
b.hasDNS, b.dns = true, map[string]string{}
|
||||
for k, v := range n.DNS {
|
||||
b.dns[k] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f, ok := n.Files[o.path]; ok {
|
||||
c := f
|
||||
b.files[o.path] = &c
|
||||
} else {
|
||||
b.files[o.path] = nil
|
||||
}
|
||||
}
|
||||
n.backups[dir] = b
|
||||
for _, o := range ops {
|
||||
switch o.op {
|
||||
case "W":
|
||||
data, err := base64.StdEncoding.DecodeString(o.data)
|
||||
if err != nil {
|
||||
n.restore(b)
|
||||
fmt.Fprintln(errw, "write failed: "+o.path)
|
||||
return 4
|
||||
}
|
||||
n.Files[o.path] = FakeFile{Content: string(data), Mode: o.mode, Owner: o.owner}
|
||||
case "D":
|
||||
delete(n.Files, o.path)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (n *Node) restore(b backup) {
|
||||
for p, f := range b.files {
|
||||
if f == nil {
|
||||
delete(n.Files, p)
|
||||
} else {
|
||||
n.Files[p] = *f
|
||||
}
|
||||
}
|
||||
if b.hasNet {
|
||||
n.Net, n.Pending = cloneNet(b.net), cloneNet(b.pending)
|
||||
}
|
||||
if b.hasDNS {
|
||||
n.DNS = map[string]string{}
|
||||
for k, v := range b.dns {
|
||||
n.DNS[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backups lists the backup folders written on the node.
|
||||
func (n *Node) Backups() []string {
|
||||
n.srv.mu.Lock()
|
||||
defer n.srv.mu.Unlock()
|
||||
var out []string
|
||||
for d := range n.backups {
|
||||
out = append(out, d)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sha(b []byte) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -17,18 +17,27 @@ import (
|
||||
// sshServer answers the commands pve-confsync runs on a node: pveversion, the
|
||||
// read of priv/shadow.cfg and its locked write (adapter password).
|
||||
type sshServer struct {
|
||||
ln net.Listener
|
||||
key ssh.PublicKey
|
||||
ln net.Listener
|
||||
key ssh.PublicKey
|
||||
exec func(cmd string, in io.Reader, out, errw io.Writer) int
|
||||
}
|
||||
|
||||
func startSSH(s *Server) *sshServer {
|
||||
return newSSH(func(user, pass string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return user == "root" && pass == s.Password
|
||||
}, s.exec)
|
||||
}
|
||||
|
||||
// newSSH starts an SSH server accepting the password check ok and running
|
||||
// the commands with exec.
|
||||
func newSSH(ok func(user, pass string) bool, exec func(cmd string, in io.Reader, out, errw io.Writer) int) *sshServer {
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
signer, _ := ssh.NewSignerFromKey(priv)
|
||||
cfg := &ssh.ServerConfig{
|
||||
PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c.User() == "root" && string(pass) == s.Password {
|
||||
if ok(c.User(), string(pass)) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("denied")
|
||||
@@ -39,14 +48,14 @@ func startSSH(s *Server) *sshServer {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
srv := &sshServer{ln: ln, key: signer.PublicKey()}
|
||||
srv := &sshServer{ln: ln, key: signer.PublicKey(), exec: exec}
|
||||
go func() {
|
||||
for {
|
||||
nc, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go s.serveSSH(nc, cfg)
|
||||
go srv.serve(nc, cfg)
|
||||
}
|
||||
}()
|
||||
return srv
|
||||
@@ -65,7 +74,7 @@ func (s *Server) SSHHostKey() string {
|
||||
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(s.ssh.key)))
|
||||
}
|
||||
|
||||
func (s *Server) serveSSH(nc net.Conn, cfg *ssh.ServerConfig) {
|
||||
func (x *sshServer) serve(nc net.Conn, cfg *ssh.ServerConfig) {
|
||||
_, chans, reqs, err := ssh.NewServerConn(nc, cfg)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
@@ -91,7 +100,7 @@ func (s *Server) serveSSH(nc net.Conn, cfg *ssh.ServerConfig) {
|
||||
var p struct{ Cmd string }
|
||||
_ = ssh.Unmarshal(req.Payload, &p)
|
||||
_ = req.Reply(true, nil)
|
||||
status := s.exec(p.Cmd, ch, ch, ch.Stderr())
|
||||
status := x.exec(p.Cmd, ch, ch, ch.Stderr())
|
||||
b := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(b, uint32(status))
|
||||
_, _ = ch.SendRequest("exit-status", false, b)
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pve-confsync/internal/nodesync"
|
||||
"pve-confsync/internal/pve"
|
||||
"pve-confsync/internal/service"
|
||||
"pve-confsync/internal/sshx"
|
||||
"pve-confsync/internal/store"
|
||||
)
|
||||
|
||||
// ---- node replication: nodes (SSH access), profiles, state, dry-run, apply ----
|
||||
|
||||
// handleNodes lists the nodes, the profiles and the catalog of items.
|
||||
func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
nodes, err := s.store.Nodes(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||
return
|
||||
}
|
||||
profiles, err := s.store.Profiles(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||
return
|
||||
}
|
||||
if identity(r).Role != store.RoleAdmin {
|
||||
for i, n := range nodes {
|
||||
nodes[i] = store.Node{Key: n.Key, Cluster: n.Cluster, Node: n.Node, ClusterRole: n.ClusterRole, LastCheck: n.LastCheck}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"nodes": nodes, "profiles": profiles, "items": nodesync.Items,
|
||||
"defaults": nodesync.DefaultPolicies})
|
||||
}
|
||||
|
||||
// handleDiscover reads the nodes of every cluster (GET /cluster/status) and
|
||||
// records them with their address in the cluster.
|
||||
func (s *Server) handleDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
cls, err := s.store.Clusters(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), readTimeout)
|
||||
defer cancel()
|
||||
errs := map[string]string{}
|
||||
found := 0
|
||||
for _, c := range cls {
|
||||
if c.Role == store.ClusterDisabled {
|
||||
continue
|
||||
}
|
||||
_, cc, err := s.store.Cluster(ctx, c.Name)
|
||||
var nodes []service.DiscoveredNode
|
||||
if err == nil {
|
||||
nodes, err = service.DiscoverNodes(ctx, cc)
|
||||
}
|
||||
if err == nil {
|
||||
var d []store.Discovered
|
||||
for _, n := range nodes {
|
||||
d = append(d, store.Discovered{Name: n.Name, IP: n.IP})
|
||||
}
|
||||
err = s.store.SetNodes(ctx, c.Name, d)
|
||||
found += len(d)
|
||||
}
|
||||
if err != nil {
|
||||
errs[c.Name] = err.Error()
|
||||
}
|
||||
}
|
||||
s.audit(r, Identity{}, "nodes-discover", "all clusters", len(errs) == 0, fmt.Sprintf("%d node(s)", found))
|
||||
s.invalidateNodes()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"found": found, "errors": errs})
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveNode(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
SSHHost string `json:"sshHost"`
|
||||
SSHPort int `json:"sshPort"`
|
||||
Password string `json:"password"`
|
||||
ClearPassword bool `json:"clearPassword"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||
return
|
||||
}
|
||||
cl, node := r.PathValue("cluster"), r.PathValue("node")
|
||||
err := s.store.SaveNode(r.Context(), cl, node, req.SSHHost, req.SSHPort, req.Password, req.ClearPassword)
|
||||
detail := fmt.Sprintf("ssh=%s:%d", req.SSHHost, req.SSHPort)
|
||||
if req.Password != "" {
|
||||
detail += " password=changed"
|
||||
}
|
||||
if req.ClearPassword {
|
||||
detail += " password=cluster"
|
||||
}
|
||||
s.audit(r, Identity{}, "node-save", store.NodeKey(cl, node), err == nil, errText(err, detail))
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "unknown node", "notfound")
|
||||
case err != nil:
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
||||
default:
|
||||
s.invalidateNodes()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
// handleCheckNode tests SSH on a node; the first test records its host key.
|
||||
func (s *Server) handleCheckNode(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Trust bool `json:"trust"`
|
||||
}
|
||||
if r.ContentLength > 0 {
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||
return
|
||||
}
|
||||
}
|
||||
cl, node := r.PathValue("cluster"), r.PathValue("node")
|
||||
a, err := s.store.NodeAccessOf(r.Context(), cl, node)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "unknown node", "notfound")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
res := service.CheckNode(ctx, node, a.SSH, req.Trust)
|
||||
chk := &store.Check{At: time.Now().UTC(), OK: res.OK, Error: res.Error, Version: res.Version, SSH: res.OK}
|
||||
if err := s.store.PinNode(r.Context(), cl, node, res.SSHHostKey, chk); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||
return
|
||||
}
|
||||
detail := res.Error
|
||||
if res.SSHHostKey != "" {
|
||||
detail += " SSH key recorded " + res.SSHFingerprint
|
||||
}
|
||||
s.audit(r, Identity{}, "node-check", store.NodeKey(cl, node), res.OK, strings.TrimSpace(detail))
|
||||
s.invalidateNodes()
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *Server) handleForgetNode(w http.ResponseWriter, r *http.Request) {
|
||||
cl, node := r.PathValue("cluster"), r.PathValue("node")
|
||||
err := s.store.ForgetNode(r.Context(), cl, node)
|
||||
s.audit(r, Identity{}, "node-forget", store.NodeKey(cl, node), err == nil, errText(err, "SSH key forgotten"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error(), "notfound")
|
||||
return
|
||||
}
|
||||
s.invalidateNodes()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var p nodesync.Profile
|
||||
if err := readJSON(r, &p); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||
return
|
||||
}
|
||||
old := r.PathValue("name")
|
||||
if old == "-" {
|
||||
old = ""
|
||||
}
|
||||
err := s.store.SaveProfile(r.Context(), old, p)
|
||||
b, _ := json.Marshal(p)
|
||||
s.audit(r, Identity{}, "profile-save", p.Name, err == nil, errText(err, string(b)))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
||||
return
|
||||
}
|
||||
s.invalidateNodes()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
err := s.store.DeleteProfile(r.Context(), name)
|
||||
s.audit(r, Identity{}, "profile-delete", name, err == nil, errText(err, ""))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error(), "notfound")
|
||||
return
|
||||
}
|
||||
s.invalidateNodes()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// endpoints builds the access to every node: one API client per cluster,
|
||||
// SSH as root on each node whose host key is recorded.
|
||||
func (s *Server) endpoints(ctx context.Context) (map[string]nodesync.Endpoint, error) {
|
||||
acc, err := s.store.NodeAccesses(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apis := map[string]*pve.Client{}
|
||||
out := map[string]nodesync.Endpoint{}
|
||||
for key, a := range acc {
|
||||
c, ok := apis[a.Cluster.Name]
|
||||
if !ok {
|
||||
if c, err = a.Cluster.PVE(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apis[a.Cluster.Name] = c
|
||||
}
|
||||
ep := nodesync.Endpoint{Key: key, Cluster: a.Node.Cluster, Node: a.Node.Node, API: c}
|
||||
if a.SSH.HostKey != "" {
|
||||
if sh, err := sshx.New(a.SSH); err == nil {
|
||||
ep.Shell = sh
|
||||
}
|
||||
}
|
||||
out[key] = ep
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) invalidateNodes() {
|
||||
s.nodeMu.Lock()
|
||||
s.nodeStates = nil
|
||||
s.nodeMu.Unlock()
|
||||
}
|
||||
|
||||
// handleNodeState returns the last comparison of a profile; refresh=1 reads
|
||||
// the nodes again.
|
||||
func (s *Server) handleNodeState(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("profile")
|
||||
s.nodeMu.Lock()
|
||||
st := s.nodeStates[name]
|
||||
s.nodeMu.Unlock()
|
||||
if st == nil || r.URL.Query().Get("refresh") == "1" {
|
||||
prof, err := s.store.Profile(r.Context(), name)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "unknown profile", "notfound")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||
return
|
||||
}
|
||||
eps, err := s.endpoints(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "config")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), readTimeout)
|
||||
defer cancel()
|
||||
st = nodesync.Analyze(ctx, &prof, eps, nil)
|
||||
s.rememberNodes(st, true)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"state": st})
|
||||
}
|
||||
|
||||
// rememberNodes keeps a comparison; a partial one (some targets) only
|
||||
// replaces the targets it read.
|
||||
func (s *Server) rememberNodes(st *nodesync.State, all bool) {
|
||||
s.nodeMu.Lock()
|
||||
defer s.nodeMu.Unlock()
|
||||
if s.nodeStates == nil {
|
||||
s.nodeStates = map[string]*nodesync.State{}
|
||||
}
|
||||
old := s.nodeStates[st.Profile]
|
||||
if all || old == nil {
|
||||
if all {
|
||||
s.nodeStates[st.Profile] = st
|
||||
}
|
||||
return
|
||||
}
|
||||
cp := *old
|
||||
cp.Master = st.Master
|
||||
cp.Targets = append([]nodesync.Target(nil), old.Targets...)
|
||||
for _, t := range st.Targets {
|
||||
for i := range cp.Targets {
|
||||
if cp.Targets[i].Key == t.Key {
|
||||
cp.Targets[i] = t
|
||||
}
|
||||
}
|
||||
}
|
||||
s.nodeStates[st.Profile] = &cp
|
||||
}
|
||||
|
||||
type nodeSelection struct {
|
||||
Profile string `json:"profile"`
|
||||
Scope string `json:"scope"` // global (several targets), client (one target) or object
|
||||
Targets []string `json:"targets"` // global: the targets (empty: all of them)
|
||||
Client string `json:"client"` // client and object scopes
|
||||
Objects []string `json:"objects"` // object scope: "item/id"
|
||||
Digests map[string]string `json:"digests"`
|
||||
Confirm string `json:"confirm"` // network changes: target (profile name for several)
|
||||
}
|
||||
|
||||
func (sel *nodeSelection) check() error {
|
||||
if sel.Profile == "" {
|
||||
return errors.New("profile required")
|
||||
}
|
||||
s := selection{Scope: sel.Scope, Targets: sel.Targets, Client: sel.Client, Objects: sel.Objects}
|
||||
if err := s.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
sel.Targets, sel.Client, sel.Objects = s.Targets, s.Client, s.Objects
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sel nodeSelection) clients() []string {
|
||||
if sel.Client == "" {
|
||||
return sel.Targets
|
||||
}
|
||||
return []string{sel.Client}
|
||||
}
|
||||
|
||||
func (sel nodeSelection) target() string {
|
||||
t := sel.Profile + ": "
|
||||
switch {
|
||||
case sel.Scope == "client":
|
||||
t += sel.Client
|
||||
case sel.Scope == "object":
|
||||
t += sel.Client + ": " + strings.Join(sel.Objects, ", ")
|
||||
case len(sel.Targets) > 0:
|
||||
t += "targets " + strings.Join(sel.Targets, ", ")
|
||||
default:
|
||||
t += "all targets"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (s *Server) nodePlan(ctx context.Context, sel nodeSelection) (*nodesync.State, []nodesync.TargetPlan, error) {
|
||||
prof, err := s.store.Profile(ctx, sel.Profile)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
err = fmt.Errorf("unknown profile %q", sel.Profile)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, c := range sel.clients() {
|
||||
found := false
|
||||
for _, t := range prof.Targets {
|
||||
found = found || t == c
|
||||
}
|
||||
if !found {
|
||||
return nil, nil, fmt.Errorf("unknown target %q", c)
|
||||
}
|
||||
}
|
||||
eps, err := s.endpoints(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rctx, cancel := context.WithTimeout(ctx, readTimeout)
|
||||
defer cancel()
|
||||
st := nodesync.Analyze(rctx, &prof, eps, sel.clients())
|
||||
s.rememberNodes(st, sel.Scope == "global" && len(sel.Targets) == 0)
|
||||
return st, st.Plans(sel.Objects), nil
|
||||
}
|
||||
|
||||
// node runs are journaled with the scopes "nodes", "node" and "node-object".
|
||||
func nodeScope(scope string) string {
|
||||
switch scope {
|
||||
case "global":
|
||||
return "nodes"
|
||||
case "client":
|
||||
return "node"
|
||||
}
|
||||
return "node-object"
|
||||
}
|
||||
|
||||
type nodeRunDetail struct {
|
||||
Profile string `json:"profile"`
|
||||
Plans []nodesync.TargetPlan `json:"plans"`
|
||||
Results map[string]*nodeResult `json:"results,omitempty"`
|
||||
}
|
||||
|
||||
type nodeResult struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Steps []nodesync.Step `json:"steps"`
|
||||
}
|
||||
|
||||
func nodeSummary(plans []nodesync.TargetPlan) (string, string) {
|
||||
changes, targets, blocked := 0, 0, 0
|
||||
for _, p := range plans {
|
||||
if p.Error != "" || len(p.Plan.Errors) > 0 {
|
||||
blocked++
|
||||
continue
|
||||
}
|
||||
if n := len(p.Plan.Changes); n > 0 {
|
||||
changes += n
|
||||
targets++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case blocked > 0:
|
||||
return store.RunBlocked, fmt.Sprintf("%d change(s) on %d target(s), %d target(s) in error", changes, targets, blocked)
|
||||
case changes == 0:
|
||||
return store.RunNoop, "no difference"
|
||||
}
|
||||
return store.RunOK, fmt.Sprintf("%d change(s) on %d target(s)", changes, targets)
|
||||
}
|
||||
|
||||
func nodePlanTargets(plans []nodesync.TargetPlan) []string {
|
||||
out := make([]string, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
out = append(out, p.Client)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleNodeDryRun(w http.ResponseWriter, r *http.Request) {
|
||||
var sel nodeSelection
|
||||
if err := readJSON(r, &sel); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||
return
|
||||
}
|
||||
if err := sel.check(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||
return
|
||||
}
|
||||
id := identity(r)
|
||||
_, plans, err := s.nodePlan(r.Context(), sel)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "config")
|
||||
return
|
||||
}
|
||||
status, summary := nodeSummary(plans)
|
||||
d, _ := json.Marshal(nodeRunDetail{Profile: sel.Profile, Plans: plans})
|
||||
runID, err := s.store.StartRun(r.Context(), store.Run{User: id.User, Mode: "dry-run", Scope: nodeScope(sel.Scope),
|
||||
Clients: nodePlanTargets(plans), Objects: sel.Objects, Status: status, Summary: summary, Detail: d})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||
return
|
||||
}
|
||||
_ = s.store.UpdateRun(r.Context(), runID, status, summary, d)
|
||||
s.audit(r, id, "node-dry-run", sel.target(), true, summary)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"run": runID, "plans": plans, "summary": summary})
|
||||
}
|
||||
|
||||
// handleNodeApply recomputes the plans, checks the digests of the dry-run and
|
||||
// the typed confirmation of the network changes, then applies them in the
|
||||
// background, one target after the other.
|
||||
func (s *Server) handleNodeApply(w http.ResponseWriter, r *http.Request) {
|
||||
var sel nodeSelection
|
||||
if err := readJSON(r, &sel); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||
return
|
||||
}
|
||||
if err := sel.check(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||
return
|
||||
}
|
||||
id := identity(r)
|
||||
if !s.applyMu.TryLock() {
|
||||
writeError(w, http.StatusConflict, "a replication is already running", "busy")
|
||||
return
|
||||
}
|
||||
started := false
|
||||
defer func() {
|
||||
if !started {
|
||||
s.applyMu.Unlock()
|
||||
}
|
||||
}()
|
||||
st, plans, err := s.nodePlan(r.Context(), sel)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "config")
|
||||
return
|
||||
}
|
||||
var todo []nodesync.TargetPlan
|
||||
network := false
|
||||
for _, p := range plans {
|
||||
switch {
|
||||
case p.Error != "":
|
||||
s.audit(r, id, "node-apply", sel.target(), false, p.Client+": "+p.Error)
|
||||
writeError(w, http.StatusConflict, p.Client+": "+p.Error, "read")
|
||||
return
|
||||
case len(p.Plan.Errors) > 0:
|
||||
s.audit(r, id, "node-apply", sel.target(), false, p.Client+": plan en erreur")
|
||||
writeError(w, http.StatusConflict, p.Client+": "+p.Plan.Errors[0].Message, "plan")
|
||||
return
|
||||
case sel.Digests[p.Client] != p.Digest:
|
||||
s.audit(r, id, "node-apply", sel.target(), false, p.Client+": the state changed since the dry-run")
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"error": p.Client + ": the state changed since the dry-run, run it again",
|
||||
"code": "stale", "plans": plans})
|
||||
return
|
||||
case p.Plan.Empty():
|
||||
continue
|
||||
}
|
||||
network = network || p.Network
|
||||
todo = append(todo, p)
|
||||
}
|
||||
if len(todo) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"run": 0, "summary": "no difference"})
|
||||
return
|
||||
}
|
||||
if network {
|
||||
want := sel.Client
|
||||
if sel.Scope == "global" {
|
||||
want = sel.Profile
|
||||
}
|
||||
if sel.Confirm != want {
|
||||
writeError(w, http.StatusBadRequest, "network changes: confirmation required ("+want+")", "confirm")
|
||||
return
|
||||
}
|
||||
}
|
||||
det := &nodeRunDetail{Profile: sel.Profile, Plans: todo, Results: map[string]*nodeResult{}}
|
||||
for _, p := range todo {
|
||||
det.Results[p.Client] = &nodeResult{Status: "pending", Steps: []nodesync.Step{}}
|
||||
}
|
||||
d, _ := json.Marshal(det)
|
||||
runID, err := s.store.StartRun(r.Context(), store.Run{User: id.User, Mode: "apply", Scope: nodeScope(sel.Scope),
|
||||
Clients: nodePlanTargets(todo), Objects: sel.Objects, Status: store.RunRunning, Summary: "en cours", Detail: d})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||
return
|
||||
}
|
||||
s.audit(r, id, "node-apply-start", sel.target(), true, fmt.Sprintf("journal #%d", runID))
|
||||
started = true
|
||||
s.jobs.Add(1)
|
||||
rr := r.Clone(context.WithoutCancel(r.Context()))
|
||||
go func() {
|
||||
defer s.jobs.Done()
|
||||
defer s.applyMu.Unlock()
|
||||
s.runNodeApply(rr, id, runID, sel, st, det)
|
||||
}()
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"run": runID})
|
||||
}
|
||||
|
||||
// runNodeApply applies the plans one target after the other and stops at the
|
||||
// first failure: the nodes are often of the same cluster, a configuration
|
||||
// that breaks one would break the next.
|
||||
func (s *Server) runNodeApply(r *http.Request, id Identity, runID int64, sel nodeSelection, st *nodesync.State, det *nodeRunDetail) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
var mu sync.Mutex
|
||||
save := func(status, summary string) {
|
||||
mu.Lock()
|
||||
d, _ := json.Marshal(det)
|
||||
mu.Unlock()
|
||||
if err := s.store.UpdateRun(ctx, runID, status, summary, d); err != nil {
|
||||
log.Printf("journal #%d: %v", runID, err)
|
||||
}
|
||||
}
|
||||
failed, done := 0, 0
|
||||
for _, p := range det.Plans {
|
||||
res := det.Results[p.Client]
|
||||
if failed > 0 {
|
||||
res.Status = "skipped"
|
||||
continue
|
||||
}
|
||||
res.Status = store.RunRunning
|
||||
save(store.RunRunning, p.Client+" en cours")
|
||||
err := st.Apply(ctx, p, sel.Objects, func(step nodesync.Step) {
|
||||
mu.Lock()
|
||||
res.Steps = append(res.Steps, step)
|
||||
mu.Unlock()
|
||||
save(store.RunRunning, p.Client+" : "+step.Action)
|
||||
})
|
||||
if err != nil {
|
||||
failed++
|
||||
res.Status, res.Error = store.RunError, err.Error()
|
||||
log.Printf("node apply %s (journal #%d): %v", p.Client, runID, err)
|
||||
s.audit(r, id, "node-apply", p.Client, false, err.Error())
|
||||
continue
|
||||
}
|
||||
done++
|
||||
res.Status = store.RunOK
|
||||
s.audit(r, id, "node-apply", p.Client, true, fmt.Sprintf("%d change(s), verified", len(p.Plan.Changes)))
|
||||
}
|
||||
status := store.RunOK
|
||||
summary := fmt.Sprintf("%d target(s) replicated and verified", done)
|
||||
if failed > 0 {
|
||||
status = store.RunError
|
||||
summary = fmt.Sprintf("%d target(s) failed, %d replicated", failed, done)
|
||||
}
|
||||
save(status, summary)
|
||||
s.invalidateNodes()
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pve-confsync/internal/nodesync"
|
||||
"pve-confsync/internal/service"
|
||||
"pve-confsync/internal/store"
|
||||
)
|
||||
@@ -40,6 +41,10 @@ type Server struct {
|
||||
state *service.State
|
||||
stateAt time.Time
|
||||
|
||||
// last comparison of each node replication profile
|
||||
nodeMu sync.Mutex
|
||||
nodeStates map[string]*nodesync.State
|
||||
|
||||
applyMu sync.Mutex // one synchronization at a time
|
||||
jobs sync.WaitGroup
|
||||
}
|
||||
@@ -83,6 +88,18 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /api/settings", s.role(viewer, s.handleSettings))
|
||||
mux.Handle("PUT /api/settings", s.role(admin, s.handleSaveSettings))
|
||||
|
||||
mux.Handle("GET /api/nodes", s.role(viewer, s.handleNodes))
|
||||
mux.Handle("POST /api/nodes/discover", s.role(admin, s.handleDiscover))
|
||||
mux.Handle("PUT /api/nodes/{cluster}/{node}", s.role(admin, s.handleSaveNode))
|
||||
mux.Handle("POST /api/nodes/{cluster}/{node}/check", s.role(admin, s.handleCheckNode))
|
||||
mux.Handle("POST /api/nodes/{cluster}/{node}/forget", s.role(admin, s.handleForgetNode))
|
||||
mux.Handle("PUT /api/profiles/{name}", s.role(admin, s.handleSaveProfile))
|
||||
mux.Handle("DELETE /api/profiles/{name}", s.role(admin, s.handleDeleteProfile))
|
||||
mux.Handle("GET /api/nodestate", s.role(viewer, s.handleNodeState))
|
||||
mux.Handle("POST /api/nodedryrun", s.role(operator, s.handleNodeDryRun))
|
||||
// writing system files as root on the nodes: administrators only
|
||||
mux.Handle("POST /api/nodeapply", s.role(admin, s.handleNodeApply))
|
||||
|
||||
mux.Handle("GET /api/users", s.role(admin, s.handleUsers))
|
||||
mux.Handle("PUT /api/users/{name}", s.role(admin, s.handleSaveUser))
|
||||
mux.Handle("DELETE /api/users/{name}", s.role(admin, s.handleDeleteUser))
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"pve-confsync/internal/config"
|
||||
"pve-confsync/internal/pve"
|
||||
"pve-confsync/internal/sshx"
|
||||
)
|
||||
|
||||
// DiscoveredNode is a node listed by GET /cluster/status.
|
||||
type DiscoveredNode struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Online bool `json:"online"`
|
||||
}
|
||||
|
||||
// DiscoverNodes lists the nodes of a cluster with their address in the
|
||||
// cluster. A standalone node without address gets the host of the API URL.
|
||||
func DiscoverNodes(ctx context.Context, cl config.Cluster) ([]DiscoveredNode, error) {
|
||||
cl.SSHHostKey = ""
|
||||
c, err := cl.PVE()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var status []map[string]any
|
||||
if err := c.Get(ctx, "cluster/status", nil, &status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []DiscoveredNode
|
||||
for _, s := range status {
|
||||
if pve.Str(s["type"]) != "node" {
|
||||
continue
|
||||
}
|
||||
n := DiscoveredNode{Name: pve.Str(s["name"]), IP: pve.Str(s["ip"]), Online: pve.Str(s["online"]) == "1"}
|
||||
if n.IP == "" {
|
||||
if u, err := url.Parse(cl.URL); err == nil {
|
||||
n.IP = u.Hostname()
|
||||
}
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NodeCheck is the result of the SSH test of a node.
|
||||
type NodeCheck struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SSHHostKey string `json:"-"`
|
||||
SSHFingerprint string `json:"sshFingerprint,omitempty"`
|
||||
SSHMismatch bool `json:"sshMismatch,omitempty"`
|
||||
}
|
||||
|
||||
// CheckNode tests SSH as root on a node: the host key (recorded on the first
|
||||
// connection, trust accepts a new one), pveversion, and the host name, which
|
||||
// must be the name of the node in its cluster (a wrong address is refused).
|
||||
func CheckNode(ctx context.Context, node string, o sshx.Options, trust bool) NodeCheck {
|
||||
var res NodeCheck
|
||||
fail := func(err error) NodeCheck {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
if o.HostKey == "" || trust {
|
||||
seen, fp, err := sshx.Scan(ctx, o)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("SSH: %w", err))
|
||||
}
|
||||
o.HostKey, res.SSHHostKey, res.SSHFingerprint = seen, seen, fp
|
||||
}
|
||||
sh, err := sshx.New(o)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("SSH: %w", err))
|
||||
}
|
||||
out, err := sh.Run(ctx, "pveversion; hostname", nil)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "host key mismatch") {
|
||||
_, fp, _ := sshx.Scan(ctx, o)
|
||||
res.SSHMismatch, res.SSHFingerprint = true, fp
|
||||
return fail(fmt.Errorf("SSH: node %s presents host key %s instead of the recorded %s", o.Host, fp, sshx.Fingerprint(o.HostKey)))
|
||||
}
|
||||
return fail(fmt.Errorf("SSH: %w", err))
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if !strings.HasPrefix(lines[0], "pve-manager/") {
|
||||
return fail(fmt.Errorf("SSH: %s is not a PVE node (pveversion: %q)", o.Host, lines[0]))
|
||||
}
|
||||
res.Version = strings.SplitN(strings.TrimPrefix(lines[0], "pve-manager/"), "/", 2)[0]
|
||||
host := ""
|
||||
if len(lines) > 1 {
|
||||
host = strings.TrimSpace(lines[len(lines)-1])
|
||||
}
|
||||
if short, _, _ := strings.Cut(host, "."); !strings.EqualFold(short, node) {
|
||||
res.SSHHostKey = "" // not the expected node: nothing is recorded
|
||||
return fail(fmt.Errorf("SSH: %s answers as %q, not as node %s", o.Host, host, node))
|
||||
}
|
||||
res.OK = true
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pve-confsync/internal/config"
|
||||
"pve-confsync/internal/nodesync"
|
||||
"pve-confsync/internal/sshx"
|
||||
)
|
||||
|
||||
// nodeSchema: the nodes of the clusters (node replication) and the profiles.
|
||||
// A node is reached over SSH as root at its address in the cluster (GET
|
||||
// /cluster/status) or at an address given by the administrator, with the
|
||||
// root password of its cluster or its own.
|
||||
const nodeSchema = `
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
cluster TEXT NOT NULL,
|
||||
node TEXT NOT NULL,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
ssh_host TEXT NOT NULL DEFAULT '',
|
||||
ssh_port INTEGER NOT NULL DEFAULT 22,
|
||||
password BLOB,
|
||||
ssh_hostkey TEXT NOT NULL DEFAULT '',
|
||||
last_check TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (cluster, node)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS node_profiles (
|
||||
name TEXT PRIMARY KEY,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
body TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
// Node is a node as stored. The password is never returned to the UI.
|
||||
type Node struct {
|
||||
Key string `json:"key"` // "cluster/node"
|
||||
Cluster string `json:"cluster"`
|
||||
Node string `json:"node"`
|
||||
IP string `json:"ip"` // address in the cluster
|
||||
SSHHost string `json:"sshHost"` // override (empty: IP)
|
||||
SSHPort int `json:"sshPort"`
|
||||
Password string `json:"password,omitempty"` // write only (empty: the cluster's)
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
SSHFingerprint string `json:"sshFingerprint"`
|
||||
ClusterRole string `json:"clusterRole"`
|
||||
LastCheck *Check `json:"lastCheck,omitempty"`
|
||||
}
|
||||
|
||||
// NodeKey is the identity of a node in the profiles.
|
||||
func NodeKey(cluster, node string) string { return cluster + "/" + node }
|
||||
|
||||
const nodeCols = `n.cluster, n.node, n.ip, n.ssh_host, n.ssh_port, n.password, n.ssh_hostkey, n.last_check, c.role`
|
||||
|
||||
func scanNode(scan func(...any) error) (Node, []byte, string, error) {
|
||||
var n Node
|
||||
var blob []byte
|
||||
var key, check string
|
||||
err := scan(&n.Cluster, &n.Node, &n.IP, &n.SSHHost, &n.SSHPort, &blob, &key, &check, &n.ClusterRole)
|
||||
if err != nil {
|
||||
return n, nil, "", err
|
||||
}
|
||||
n.Key = NodeKey(n.Cluster, n.Node)
|
||||
n.HasPassword = len(blob) > 0
|
||||
n.SSHFingerprint = sshx.Fingerprint(key)
|
||||
if check != "" {
|
||||
n.LastCheck = &Check{}
|
||||
_ = json.Unmarshal([]byte(check), n.LastCheck)
|
||||
}
|
||||
return n, blob, key, nil
|
||||
}
|
||||
|
||||
// Nodes lists the known nodes, by cluster then name.
|
||||
func (s *Store) Nodes(ctx context.Context) ([]Node, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+nodeCols+` FROM nodes n JOIN clusters c ON c.name = n.cluster
|
||||
ORDER BY c.role = 'primary' DESC, c.position, n.cluster, n.node`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Node{}
|
||||
for rows.Next() {
|
||||
n, _, _, err := scanNode(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Discovered is a node read in GET /cluster/status.
|
||||
type Discovered struct {
|
||||
Name string
|
||||
IP string
|
||||
}
|
||||
|
||||
// SetNodes records the nodes of a cluster: new ones are added, the address of
|
||||
// the known ones is updated (their pinned host key is forgotten when the SSH
|
||||
// address changes), the nodes gone from the cluster are removed.
|
||||
func (s *Store) SetNodes(ctx context.Context, cluster string, nodes []Discovered) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
keep := map[string]bool{}
|
||||
for _, d := range nodes {
|
||||
keep[d.Name] = true
|
||||
var ip, host string
|
||||
err := tx.QueryRowContext(ctx, `SELECT ip, ssh_host FROM nodes WHERE cluster = ? AND node = ?`, cluster, d.Name).Scan(&ip, &host)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO nodes (cluster, node, ip) VALUES (?,?,?)`, cluster, d.Name, d.IP)
|
||||
case err == nil && ip != d.IP:
|
||||
q := `UPDATE nodes SET ip = ? WHERE cluster = ? AND node = ?`
|
||||
if host == "" {
|
||||
q = `UPDATE nodes SET ip = ?, ssh_hostkey = '' WHERE cluster = ? AND node = ?`
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, q, d.IP, cluster, d.Name)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT node FROM nodes WHERE cluster = ?`, cluster)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var gone []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if !keep[n] {
|
||||
gone = append(gone, n)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, n := range gone {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM nodes WHERE cluster = ? AND node = ?`, cluster, n); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SaveNode sets the SSH access of a node: address (empty: its address in the
|
||||
// cluster), port, password (empty: unchanged; clear: back to the password
|
||||
// of the cluster). A new address forgets the pinned host key.
|
||||
func (s *Store) SaveNode(ctx context.Context, cluster, node, host string, port int, password string, clear bool) error {
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
return errors.New("ssh port: 1-65535 expected")
|
||||
}
|
||||
var oldHost string
|
||||
var oldPort int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT ssh_host, ssh_port FROM nodes WHERE cluster = ? AND node = ?`, cluster, node).Scan(&oldHost, &oldPort)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := `UPDATE nodes SET ssh_host = ?, ssh_port = ?`
|
||||
args := []any{strings.TrimSpace(host), port}
|
||||
if oldHost != strings.TrimSpace(host) || oldPort != port {
|
||||
q += `, ssh_hostkey = ''`
|
||||
}
|
||||
switch {
|
||||
case clear:
|
||||
q += `, password = NULL`
|
||||
case password != "":
|
||||
blob, err := s.box.Seal([]byte(password))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q += `, password = ?`
|
||||
args = append(args, blob)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, q+` WHERE cluster = ? AND node = ?`, append(args, cluster, node)...)
|
||||
return err
|
||||
}
|
||||
|
||||
// PinNode records the SSH host key seen on the first connection (empty: left
|
||||
// unchanged) and the result of the test.
|
||||
func (s *Store) PinNode(ctx context.Context, cluster, node, hostKey string, check *Check) error {
|
||||
var chk []byte
|
||||
if check != nil {
|
||||
chk, _ = json.Marshal(check)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE nodes SET
|
||||
ssh_hostkey = CASE WHEN ? <> '' THEN ? ELSE ssh_hostkey END,
|
||||
last_check = CASE WHEN ? <> '' THEN ? ELSE last_check END
|
||||
WHERE cluster = ? AND node = ?`, hostKey, hostKey, string(chk), string(chk), cluster, node)
|
||||
return err
|
||||
}
|
||||
|
||||
// ForgetNode clears the pinned SSH host key of a node.
|
||||
func (s *Store) ForgetNode(ctx context.Context, cluster, node string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET ssh_hostkey = '' WHERE cluster = ? AND node = ?`, cluster, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NodeAccess is the decrypted access to one node: the connection of its
|
||||
// cluster (API) and SSH as root on the node.
|
||||
type NodeAccess struct {
|
||||
Node
|
||||
Cluster config.Cluster
|
||||
SSH sshx.Options
|
||||
}
|
||||
|
||||
// NodeAccesses returns the access to every node of the clusters not disabled.
|
||||
func (s *Store) NodeAccesses(ctx context.Context) (map[string]NodeAccess, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+nodeCols+` FROM nodes n JOIN clusters c ON c.name = n.cluster WHERE c.role <> 'disabled'`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type row struct {
|
||||
n Node
|
||||
blob []byte
|
||||
key string
|
||||
}
|
||||
var list []row
|
||||
for rows.Next() {
|
||||
n, blob, key, err := scanNode(rows.Scan)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, row{n, blob, key})
|
||||
}
|
||||
rows.Close()
|
||||
out := map[string]NodeAccess{}
|
||||
clusters := map[string]config.Cluster{}
|
||||
for _, r := range list {
|
||||
cc, ok := clusters[r.n.Cluster]
|
||||
if !ok {
|
||||
var err error
|
||||
if _, cc, err = s.Cluster(ctx, r.n.Cluster); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clusters[r.n.Cluster] = cc
|
||||
}
|
||||
a, err := s.access(r.n, r.blob, r.key, cc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[r.n.Key] = a
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NodeAccessOf returns the access to one node.
|
||||
func (s *Store) NodeAccessOf(ctx context.Context, cluster, node string) (NodeAccess, error) {
|
||||
n, blob, key, err := scanNode(s.db.QueryRowContext(ctx, `SELECT `+nodeCols+` FROM nodes n JOIN clusters c ON c.name = n.cluster
|
||||
WHERE n.cluster = ? AND n.node = ?`, cluster, node).Scan)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return NodeAccess{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return NodeAccess{}, err
|
||||
}
|
||||
_, cc, err := s.Cluster(ctx, cluster)
|
||||
if err != nil {
|
||||
return NodeAccess{}, err
|
||||
}
|
||||
return s.access(n, blob, key, cc)
|
||||
}
|
||||
|
||||
func (s *Store) access(n Node, blob []byte, key string, cc config.Cluster) (NodeAccess, error) {
|
||||
pass := cc.Password
|
||||
if len(blob) > 0 {
|
||||
p, err := s.box.Open(blob)
|
||||
if err != nil {
|
||||
return NodeAccess{}, fmt.Errorf("node %s: password: %w", n.Key, err)
|
||||
}
|
||||
pass = string(p)
|
||||
}
|
||||
host := n.SSHHost
|
||||
if host == "" {
|
||||
host = n.IP
|
||||
}
|
||||
cc.SSHHostKey = "" // the cluster SSH access is not used here
|
||||
return NodeAccess{Node: n, Cluster: cc,
|
||||
SSH: sshx.Options{Host: host, Port: n.SSHPort, User: "root", Password: pass, HostKey: key}}, nil
|
||||
}
|
||||
|
||||
// ---- profiles ----
|
||||
|
||||
// Profiles returns the node replication profiles, in order.
|
||||
func (s *Store) Profiles(ctx context.Context) ([]nodesync.Profile, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT name, position, body FROM node_profiles ORDER BY position, name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []nodesync.Profile{}
|
||||
for rows.Next() {
|
||||
var name, body string
|
||||
var pos int
|
||||
if err := rows.Scan(&name, &pos, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p nodesync.Profile
|
||||
if err := json.Unmarshal([]byte(body), &p); err != nil {
|
||||
return nil, fmt.Errorf("profile %s: %w", name, err)
|
||||
}
|
||||
p.Name, p.Position = name, pos
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Profile returns one profile.
|
||||
func (s *Store) Profile(ctx context.Context, name string) (nodesync.Profile, error) {
|
||||
ps, err := s.Profiles(ctx)
|
||||
if err != nil {
|
||||
return nodesync.Profile{}, err
|
||||
}
|
||||
for _, p := range ps {
|
||||
if p.Name == name {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return nodesync.Profile{}, ErrNotFound
|
||||
}
|
||||
|
||||
// SaveProfile creates or updates a profile; oldName renames it.
|
||||
func (s *Store) SaveProfile(ctx context.Context, oldName string, p nodesync.Profile) error {
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldName == "" {
|
||||
oldName = p.Name
|
||||
}
|
||||
body, _ := json.Marshal(p)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(ctx, `UPDATE node_profiles SET name = ?, position = ?, body = ?, updated_at = ? WHERE name = ?`,
|
||||
p.Name, p.Position, string(body), time.Now().Unix(), oldName)
|
||||
if err == nil {
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO node_profiles (name, position, body, updated_at) VALUES (?,?,?,?)`,
|
||||
p.Name, p.Position, string(body), time.Now().Unix())
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return fmt.Errorf("profile %q already exists", p.Name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteProfile removes a profile.
|
||||
func (s *Store) DeleteProfile(ctx context.Context, name string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM node_profiles WHERE name = ?`, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renameClusterNodes follows a renamed cluster in the nodes and the profiles.
|
||||
func renameClusterNodes(ctx context.Context, tx *sql.Tx, old, name string) error {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET cluster = ? WHERE cluster = ?`, name, old); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT name, body FROM node_profiles`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bodies := map[string]string{}
|
||||
for rows.Next() {
|
||||
var n, b string
|
||||
if err := rows.Scan(&n, &b); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
bodies[n] = b
|
||||
}
|
||||
rows.Close()
|
||||
ren := func(k string) string {
|
||||
if strings.HasPrefix(k, old+"/") {
|
||||
return name + k[len(old):]
|
||||
}
|
||||
return k
|
||||
}
|
||||
for n, b := range bodies {
|
||||
var p nodesync.Profile
|
||||
if json.Unmarshal([]byte(b), &p) != nil {
|
||||
continue
|
||||
}
|
||||
p.Master = ren(p.Master)
|
||||
for i, t := range p.Targets {
|
||||
p.Targets[i] = ren(t)
|
||||
}
|
||||
m := map[string]nodesync.TargetMap{}
|
||||
for k, v := range p.Mapping {
|
||||
m[ren(k)] = v
|
||||
}
|
||||
p.Mapping = m
|
||||
nb, _ := json.Marshal(p)
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE node_profiles SET body = ? WHERE name = ?`, string(nb), n); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"pve-confsync/internal/nodesync"
|
||||
)
|
||||
|
||||
func TestNodesAndProfiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := Open(filepath.Join(t.TempDir(), "t.db"), testKey(1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveCluster(ctx, "", Cluster{Name: "lab", Role: ClusterPrimary, URL: "https://lab:8006", Password: "cluster-pass"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetNodes(ctx, "lab", []Discovered{{"pve1", "10.0.0.11"}, {"pve2", "10.0.0.12"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.PinNode(ctx, "lab", "pve2", "ssh-ed25519 AAAA", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// the cluster password by default, the node's own one once set
|
||||
a, err := st.NodeAccessOf(ctx, "lab", "pve2")
|
||||
if err != nil || a.SSH.Password != "cluster-pass" || a.SSH.Host != "10.0.0.12" || a.SSH.HostKey == "" {
|
||||
t.Fatalf("%+v %v", a.SSH, err)
|
||||
}
|
||||
if err := st.SaveNode(ctx, "lab", "pve2", "192.168.0.12", 22, "own-pass", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ = st.NodeAccessOf(ctx, "lab", "pve2")
|
||||
if a.SSH.Password != "own-pass" || a.SSH.Host != "192.168.0.12" || a.SSH.HostKey != "" {
|
||||
t.Fatalf("new address must forget the host key: %+v", a.SSH)
|
||||
}
|
||||
// a node gone from the cluster is removed
|
||||
if err := st.SetNodes(ctx, "lab", []Discovered{{"pve1", "10.0.0.11"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ns, _ := st.Nodes(ctx); len(ns) != 1 || ns[0].Key != "lab/pve1" {
|
||||
t.Fatalf("nodes: %+v", ns)
|
||||
}
|
||||
p := nodesync.Profile{Name: "net", Master: "lab/pve1", Targets: []string{"lab/pve2"}, Items: []string{"network"},
|
||||
Mapping: map[string]nodesync.TargetMap{"lab/pve2": {NICs: map[string]string{"eno1": "enp1s0"}}}}
|
||||
if err := st.SaveProfile(ctx, "", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveProfile(ctx, "", nodesync.Profile{Name: "bad", Master: "lab/pve1", Targets: []string{"lab/pve1"}, Items: []string{"network"}}); err == nil {
|
||||
t.Error("master as target accepted")
|
||||
}
|
||||
// renaming the cluster follows in the nodes and the profiles
|
||||
c, _, _ := st.Cluster(ctx, "lab")
|
||||
c.Name = "rennes"
|
||||
if err := st.SaveCluster(ctx, "lab", c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.Profile(ctx, "net")
|
||||
if err != nil || got.Master != "rennes/pve1" || got.Targets[0] != "rennes/pve2" || got.Mapping["rennes/pve2"].NICs["eno1"] != "enp1s0" {
|
||||
t.Fatalf("profile after rename: %+v %v", got, err)
|
||||
}
|
||||
if ns, _ := st.Nodes(ctx); len(ns) != 1 || ns[0].Key != "rennes/pve1" {
|
||||
t.Fatalf("nodes after rename: %+v", ns)
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func Open(path string, key []byte) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
if _, err := db.Exec(schema + nodeSchema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
@@ -391,6 +391,11 @@ func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) erro
|
||||
}
|
||||
return err
|
||||
}
|
||||
if exists && oldName != c.Name {
|
||||
if err := renameClusterNodes(ctx, tx, oldName, c.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -465,7 +470,8 @@ func (s *Store) DeleteCluster(ctx context.Context, name string) error {
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
_, err = s.db.ExecContext(ctx, `DELETE FROM nodes WHERE cluster = ?`, name)
|
||||
return err
|
||||
}
|
||||
|
||||
func nonNil(m map[model.Kind]map[string]map[string]any) any {
|
||||
|
||||
+604
-50
@@ -213,6 +213,85 @@
|
||||
},
|
||||
},
|
||||
};
|
||||
// node replication: the second mode (system files of the nodes)
|
||||
Object.assign(I18N.fr, {
|
||||
modeAriaSw: 'Type de réplication', modeCluster: 'Clusters (API)', modeNode: 'Nœuds (fichiers)',
|
||||
modeClusterTitle: "Réplication cluster vers cluster par l'API : options datacenter, comptes, SDN",
|
||||
modeNodeTitle: 'Réplication nœud vers nœud : réseau et DNS (API), multipath, chrony, sysctl (SSH)',
|
||||
viewNodes: 'Nœuds et profils', profileAria: 'Profil de réplication', nreference: 'MAÎTRE', thMaster: 'Maître',
|
||||
nodesHelp: "Un profil réplique la configuration système d'un nœud maître vers des nœuds cibles, du même cluster ou d'un autre. Le réseau et le DNS passent par l'API PVE, les fichiers par SSH en root sur chaque nœud (mot de passe du cluster, ou propre au nœud). La clé d'hôte SSH de chaque nœud est enregistrée au premier test. Le maître n'est jamais modifié.",
|
||||
addProfile: '+ Nouveau profil', discover: 'Découvrir les nœuds', profilesTitle: 'Profils de réplication', nodesTitle: 'Nœuds',
|
||||
noProfiles: 'Aucun profil.', noNodes: "Aucun nœud connu : « Découvrir les nœuds » lit les nœuds de chaque cluster (adresse dans le cluster).",
|
||||
setupNodes: 'Aucun profil de réplication de nœuds : découvrez les nœuds, puis créez un profil (nœud maître, cibles, éléments).', setupNodesBtn: 'Créer un profil',
|
||||
nodeHelp: "Adresse SSH du nœud : vide, son adresse dans le cluster. Le mot de passe root est celui du cluster, sauf s'il en a un propre. Changer d'adresse oublie la clé d'hôte enregistrée.",
|
||||
nHost: 'Adresse SSH', nPassword: 'Mot de passe root', nClear: 'Utiliser le mot de passe du cluster', forgetKey: 'Oublier la clé',
|
||||
editNode: 'Nœud {n}', nodePwdKeep: 'Vide : mot de passe inchangé ({w}).', pwdCluster: 'celui du cluster', pwdOwn: 'propre au nœud',
|
||||
newProfile: 'Nouveau profil', editProfile: 'Profil {p}', pMaster: 'Nœud maître', pTargets: 'Nœuds cibles', pItems: 'Éléments répliqués',
|
||||
pItemsHelp: "Réseau et DNS : API PVE (interfaces.new puis rechargement). Les autres : SSH en root, sauvegarde du fichier sur le nœud, écriture atomique, rechargement (sysctl --system, systemctl restart chrony, multipathd reconfigure, ifreload -a). wwids : ajout seul. Jamais répliqués : /etc/pve, corosync, hostname, clés SSH, interfaces.d/sdn (généré par le SDN).",
|
||||
pPrune: "Supprimer sur les cibles ce que le maître n'a pas", pPruneHelp: 'Désactivé par défaut. Activé : interfaces (jamais les interfaces physiques) et fichiers sysctl.d, chrony sources.d, interfaces.d absents du maître.',
|
||||
pPolHelp: "sync : copie du maître ; ignore : jamais écrit ; override : écrit seulement depuis le mapping de la cible. Par défaut, les adresses et passerelles (cidr, cidr6, gateway, gateway6) sont en override : chaque nœud garde les siennes.",
|
||||
pProtect: 'Lignes protégées des fichiers', pProtectHelp: "Expressions régulières, une par ligne : une ligne du maître qui correspond prend la valeur de la ligne de la cible qui correspond (supprimée si la cible n'en a pas).",
|
||||
pMapping: 'Mapping par cible', pMappingHelp: "Interfaces : noms du maître et de la cible quand ils diffèrent (eno1=enp65s0f0, séparés par des virgules ; les VLAN eno1.100 suivent). Valeurs : une par ligne, « interface champ=valeur » (vmbr1 cidr=10.5.0.12/24) ou « dns champ=valeur ».",
|
||||
mapNics: 'Interfaces (maître=cible)', mapValues: 'Valeurs propres',
|
||||
confirmDeleteProfile: 'Supprimer le profil {p} ?', targetsN: '{n} cible(s)', discovered: '{n} nœud(s) trouvé(s)', clusterRoleN: 'cluster {r}',
|
||||
netConfirm: "Ces changements rechargent le réseau de {c} (ifreload -a). S'il n'est plus joignable ensuite, le nœud restaure seul sa configuration précédente au bout de 120 s. Tapez « {w} » pour confirmer.",
|
||||
netConfirmGlobal: "Ces changements rechargent le réseau de plusieurs nœuds, l'un après l'autre (arrêt au premier échec). Tapez le nom du profil « {w} » pour confirmer.",
|
||||
netBadge: 'RÉSEAU', reloadsLine: 'Puis : {l}', fileSame: 'Fichier identique.', fileExtra: 'Fichier présent seulement sur la cible (conservé).',
|
||||
nodeStats: 'Profil {p} · maître {ref} · {n} cible(s) · {d} avec écarts · lu à {t}',
|
||||
noTargetN: 'Aucune cible dans ce profil.', noTargetNHint: 'Ajoutez des nœuds cibles au profil dans la vue Nœuds et profils.',
|
||||
emptyOkHintN: 'La cible est conforme au maître pour les éléments du profil.',
|
||||
syncAdminOnly: 'Seuls les administrateurs répliquent vers les nœuds.',
|
||||
});
|
||||
Object.assign(I18N.en, {
|
||||
modeAriaSw: 'Replication kind', modeCluster: 'Clusters (API)', modeNode: 'Nodes (files)',
|
||||
modeClusterTitle: 'Cluster to cluster replication through the API: datacenter options, accounts, SDN',
|
||||
modeNodeTitle: 'Node to node replication: network and DNS (API), multipath, chrony, sysctl (SSH)',
|
||||
viewNodes: 'Nodes and profiles', profileAria: 'Replication profile', nreference: 'MASTER', thMaster: 'Master',
|
||||
nodesHelp: 'A profile replicates the system configuration of a master node to target nodes, of the same cluster or another one. The network and the DNS go through the PVE API, the files through SSH as root on each node (password of the cluster, or its own). The SSH host key of each node is recorded on the first test. The master is never written.',
|
||||
addProfile: '+ New profile', discover: 'Discover the nodes', profilesTitle: 'Replication profiles', nodesTitle: 'Nodes',
|
||||
noProfiles: 'No profile.', noNodes: 'No known node: "Discover the nodes" reads the nodes of every cluster (address in the cluster).',
|
||||
setupNodes: 'No node replication profile: discover the nodes, then create a profile (master node, targets, items).', setupNodesBtn: 'Create a profile',
|
||||
nodeHelp: 'SSH address of the node: empty, its address in the cluster. The root password is the one of the cluster, unless it has its own. A new address forgets the recorded host key.',
|
||||
nHost: 'SSH address', nPassword: 'Root password', nClear: 'Use the password of the cluster', forgetKey: 'Forget the key',
|
||||
editNode: 'Node {n}', nodePwdKeep: 'Empty: password unchanged ({w}).', pwdCluster: 'the cluster one', pwdOwn: 'own password',
|
||||
newProfile: 'New profile', editProfile: 'Profile {p}', pMaster: 'Master node', pTargets: 'Target nodes', pItems: 'Replicated items',
|
||||
pItemsHelp: 'Network and DNS: PVE API (interfaces.new then reload). The others: SSH as root, backup of the file on the node, atomic write, reload (sysctl --system, systemctl restart chrony, multipathd reconfigure, ifreload -a). wwids: add only. Never replicated: /etc/pve, corosync, hostname, SSH keys, interfaces.d/sdn (generated by the SDN).',
|
||||
pPrune: 'Delete on the targets what the master does not have', pPruneHelp: 'Off by default. On: interfaces (never the physical ones) and the files of sysctl.d, chrony sources.d, interfaces.d absent from the master.',
|
||||
pPolHelp: 'sync: copied from the master; ignore: never written; override: only written from the target mapping. By default the addresses and gateways (cidr, cidr6, gateway, gateway6) are override: each node keeps its own.',
|
||||
pProtect: 'Protected lines of the files', pProtectHelp: 'Regular expressions, one per line: a line of the master that matches takes the value of the matching line of the target (dropped when the target has none).',
|
||||
pMapping: 'Mapping per target', pMappingHelp: 'Interfaces: names on the master and on the target when they differ (eno1=enp65s0f0, comma separated; VLANs eno1.100 follow). Values: one per line, "interface field=value" (vmbr1 cidr=10.5.0.12/24) or "dns field=value".',
|
||||
mapNics: 'Interfaces (master=target)', mapValues: 'Own values',
|
||||
confirmDeleteProfile: 'Delete profile {p}?', targetsN: '{n} target(s)', discovered: '{n} node(s) found', clusterRoleN: 'cluster {r}',
|
||||
netConfirm: 'These changes reload the network of {c} (ifreload -a). If it is unreachable afterwards, the node restores its previous configuration by itself after 120 s. Type "{w}" to confirm.',
|
||||
netConfirmGlobal: 'These changes reload the network of several nodes, one after the other (stop at the first failure). Type the name of the profile "{w}" to confirm.',
|
||||
netBadge: 'NETWORK', reloadsLine: 'Then: {l}', fileSame: 'Same file.', fileExtra: 'File only on the target (kept).',
|
||||
nodeStats: 'Profile {p} · master {ref} · {n} target(s) · {d} with differences · read at {t}',
|
||||
noTargetN: 'No target in this profile.', noTargetNHint: 'Add target nodes to the profile in the Nodes and profiles view.',
|
||||
emptyOkHintN: 'The target is in line with the master for the items of the profile.',
|
||||
syncAdminOnly: 'Only administrators replicate to the nodes.',
|
||||
});
|
||||
const NODE_KINDS = {
|
||||
fr: [{ network: 'Réseau (API)', dns: 'DNS (API)', multipath: 'multipath.conf', wwids: 'wwids multipath', chrony: 'chrony', sysctl: 'sysctl.d', 'interfaces-d': 'interfaces.d' },
|
||||
{ network: 'réseau', dns: 'dns', multipath: 'multipath', wwids: 'wwids', chrony: 'chrony', sysctl: 'sysctl', 'interfaces-d': 'if.d' }],
|
||||
en: [{ network: 'Network (API)', dns: 'DNS (API)', multipath: 'multipath.conf', wwids: 'multipath wwids', chrony: 'chrony', sysctl: 'sysctl.d', 'interfaces-d': 'interfaces.d' },
|
||||
{ network: 'network', dns: 'dns', multipath: 'multipath', wwids: 'wwids', chrony: 'chrony', sysctl: 'sysctl', 'interfaces-d': 'if.d' }],
|
||||
};
|
||||
for (const l of ['fr', 'en']) {
|
||||
Object.assign(I18N[l].kinds, NODE_KINDS[l][0]);
|
||||
Object.assign(I18N[l].kindShort, NODE_KINDS[l][1]);
|
||||
}
|
||||
Object.assign(I18N.fr.action, { 'nodes-discover': 'découverte des nœuds', 'node-save': 'nœud enregistré', 'node-check': 'test du nœud',
|
||||
'node-forget': 'clé oubliée', 'profile-save': 'profil enregistré', 'profile-delete': 'profil supprimé', 'node-dry-run': 'dry-run nœuds',
|
||||
'node-apply-start': 'réplication nœuds lancée', 'node-apply': 'réplication nœuds' });
|
||||
Object.assign(I18N.en.action, { 'nodes-discover': 'node discovery', 'node-save': 'node saved', 'node-check': 'node test',
|
||||
'node-forget': 'key forgotten', 'profile-save': 'profile saved', 'profile-delete': 'profile deleted', 'node-dry-run': 'node dry-run',
|
||||
'node-apply-start': 'node replication started', 'node-apply': 'node replication' });
|
||||
Object.assign(I18N.fr.scope, { nodes: 'nœuds', node: 'nœud', 'node-object': 'objet de nœud' });
|
||||
Object.assign(I18N.en.scope, { nodes: 'nodes', node: 'node', 'node-object': 'node object' });
|
||||
I18N.fr.res.skipped = 'non lancé';
|
||||
I18N.en.res.skipped = 'not run';
|
||||
const NITEMS = ['network', 'dns', 'multipath', 'wwids', 'chrony', 'sysctl', 'interfaces-d'];
|
||||
|
||||
const KINDS = ['datacenter-options', 'pve-group', 'pve-role', 'pve-user', 'pve-password', 'sdn-zone', 'sdn-vnet', 'sdn-subnet'];
|
||||
// kinds chosen as one item: the accounts of the pve realm with their passwords
|
||||
const BUNDLES = { 'pve-user': ['pve-password'] };
|
||||
@@ -350,6 +429,29 @@
|
||||
[/SDN configuration committed but reload failed/, 'configuration SDN validée mais rechargement en échec'],
|
||||
[/root@pam cannot be deleted/, 'root@pam ne peut pas être supprimé'],
|
||||
[/\bconnection refused\b/, 'connexion refusée'],
|
||||
[/physical interface (\S+) absent on (\S+): add it to the NIC mapping of the target/, 'interface physique $1 absente de $2 : ajoutez-la au mapping des interfaces de la cible'],
|
||||
[/type differs \((\S+) on the target, (\S+) on the master\): re-create the interface by hand/, 'type différent ($1 sur la cible, $2 sur le maître) : recréez l\'interface à la main'],
|
||||
[/options the API does not write differ \((.*)\): not replicated/, "options que l'API n'écrit pas différentes ($1) : non répliquées"],
|
||||
[/pending network changes on (\S+) \(interfaces.new\): apply or revert them in PVE first/, 'changements réseau en attente sur $1 (interfaces.new) : appliquez-les ou annulez-les d\'abord dans PVE'],
|
||||
[/the master has pending network changes: its pending configuration is replicated/, 'le maître a des changements réseau en attente : sa configuration en attente est répliquée'],
|
||||
[/a search domain is required by the API.*/, "un domaine de recherche est obligatoire pour l'API (PUT /nodes/{node}/dns)"],
|
||||
[/^(\S+) absent on the master: not replicated$/, '$1 absent du maître : non répliqué'],
|
||||
[/: (\d+) bytes, too large, not replicated/, ' : $1 octets, trop gros, non répliqué'],
|
||||
[/SSH host key not recorded yet, test the node first/, "clé d'hôte SSH pas encore enregistrée, testez d'abord le nœud"],
|
||||
[/node runs PVE "([^"]*)", only (\d+)\.x is supported/, 'le nœud est en PVE « $1 », seul $2.x est pris en charge'],
|
||||
[/unknown node (\S+) \(discover the nodes of its cluster\)/, 'nœud $1 inconnu (découvrez les nœuds de son cluster)'],
|
||||
[/changed since the dry-run: (\S+)/, 'modifié depuis le dry-run : $1'],
|
||||
[/unreachable after the network reload: it restores its previous configuration by itself within (\S+)/, 'injoignable après le rechargement du réseau : il restaure seul sa configuration précédente sous $1'],
|
||||
[/the node is unreachable: it restores its previous configuration by itself within (\S+)/, 'le nœud est injoignable : il restaure seul sa configuration précédente sous $1'],
|
||||
[/^network changes: confirmation required \((.*)\)$/, 'changements réseau : confirmation requise ($1)'],
|
||||
[/answers as "([^"]*)", not as node (\S+)/, 'répond en tant que « $1 », pas en tant que nœud $2'],
|
||||
[/^select at least one item$/, 'cochez au moins un élément'],
|
||||
[/^master node is required$/, 'le nœud maître est obligatoire'],
|
||||
[/^name is required$/, 'le nom est obligatoire'],
|
||||
[/target (\S+) is the master/, 'la cible $1 est le maître'],
|
||||
[/^profile "(.*)" already exists$/, 'le profil « $1 » existe déjà'],
|
||||
[/restore failed, run sh (\S+) on the node/, 'restauration en échec, lancez sh $1 sur le nœud'],
|
||||
[/^unknown profile$/, 'profil inconnu'],
|
||||
];
|
||||
function tm(msg) {
|
||||
if (lang !== 'fr' || !msg) return msg;
|
||||
@@ -410,16 +512,21 @@
|
||||
}
|
||||
|
||||
// ---------- application state ----------
|
||||
// the state of the differences view is kept per replication mode: S.state,
|
||||
// S.client and S.skip are those of the current mode (getters below)
|
||||
const MODES = {
|
||||
cluster: { state: null, client: null, skip: new Set(JSON.parse(store.get('skipTargets') || '[]')), skipKey: 'skipTargets' },
|
||||
node: { state: null, client: null, skip: new Set(JSON.parse(store.get('skipNodes') || '[]')), skipKey: 'skipNodes' },
|
||||
};
|
||||
const S = {
|
||||
me: null,
|
||||
state: null, // last comparison
|
||||
setup: '', // 'clusters' (empty database) or 'primary' (no primary chosen)
|
||||
mode: store.get('mode') === 'node' ? 'node' : 'cluster',
|
||||
nodes: [], profiles: [], profile: store.get('profile') || '', // node replication
|
||||
setup: '', // 'clusters' (empty database), 'primary' (no primary chosen) or 'nodes' (no profile)
|
||||
clusters: [], // clusters view
|
||||
settings: null, // synchronization settings (replicated kinds)
|
||||
deps: {}, // kind -> kinds it needs
|
||||
skip: new Set(JSON.parse(store.get('skipTargets') || '[]')), // targets left out of the global actions
|
||||
view: 'diff',
|
||||
client: null, // selected client
|
||||
open: new Set(), // expanded object rows (client|key)
|
||||
kind: '',
|
||||
status: store.get('status') || 'diff',
|
||||
@@ -427,9 +534,15 @@
|
||||
runs: [], runsEnd: false, runOpen: new Set(), runMode: '',
|
||||
events: [], eventsEnd: false, eventOpen: new Set(),
|
||||
};
|
||||
for (const k of ['state', 'client', 'skip']) {
|
||||
Object.defineProperty(S, k, { get: () => MODES[S.mode][k], set: (v) => { MODES[S.mode][k] = v; } });
|
||||
}
|
||||
const isNode = () => S.mode === 'node';
|
||||
const can = {
|
||||
sync: () => S.me && (S.me.role === 'admin' || S.me.role === 'operator'),
|
||||
admin: () => S.me && S.me.role === 'admin',
|
||||
// writing system files as root on the nodes: administrators only
|
||||
apply: () => (isNode() ? can.admin() : can.sync()),
|
||||
};
|
||||
|
||||
// ---------- language, theme, density ----------
|
||||
@@ -445,7 +558,9 @@
|
||||
}
|
||||
function fillSelects() {
|
||||
const ks = $('#kindSel');
|
||||
ks.replaceChildren(h('option', { value: '', text: t('allKinds') }), ...KINDS.map((k) => h('option', { value: k, text: t('kinds.' + k) })));
|
||||
const kinds = isNode() ? NITEMS : KINDS;
|
||||
if (!kinds.includes(S.kind)) S.kind = '';
|
||||
ks.replaceChildren(h('option', { value: '', text: t('allKinds') }), ...kinds.map((k) => h('option', { value: k, text: t('kinds.' + k) })));
|
||||
ks.value = S.kind;
|
||||
ks.classList.toggle('set', !!S.kind);
|
||||
const ss = $('#statusSel');
|
||||
@@ -456,6 +571,13 @@
|
||||
const ms = $('#runModeSel');
|
||||
ms.replaceChildren(...[['', 'allModes'], ['apply', 'onlyApply'], ['dry-run', 'onlyDry']].map(([v, k]) => h('option', { value: v, text: t(k) })));
|
||||
ms.value = S.runMode;
|
||||
fillProfiles();
|
||||
}
|
||||
function fillProfiles() {
|
||||
const ps = $('#profileSel');
|
||||
ps.replaceChildren(...S.profiles.map((p) => h('option', { value: p.name, text: p.name })));
|
||||
if (!S.profiles.some((p) => p.name === S.profile)) S.profile = S.profiles.length ? S.profiles[0].name : '';
|
||||
ps.value = S.profile;
|
||||
}
|
||||
function themeOpt() { return store.get('theme') || 'system'; }
|
||||
function setTheme(v) {
|
||||
@@ -471,15 +593,26 @@
|
||||
$$('#densitySwitch [data-density]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.density === (v || 'normal'))));
|
||||
}
|
||||
|
||||
// ---------- routing (#diff/<target>, #clusters, #runs, #audit) ----------
|
||||
// ---------- routing (#diff/<target>, #clusters, #runs, #audit; node mode:
|
||||
// #node/diff/<target>, #node/nodes, #node/runs) ----------
|
||||
function readHash() {
|
||||
const [v, c] = decodeURIComponent(location.hash.slice(1)).split('/');
|
||||
S.view = ['diff', 'clusters', 'runs', 'audit'].includes(v) ? v : 'diff';
|
||||
const parts = decodeURIComponent(location.hash.slice(1)).split('/');
|
||||
if (parts[0] === 'node') {
|
||||
S.mode = 'node';
|
||||
parts.shift();
|
||||
} else if (parts[0]) {
|
||||
S.mode = 'cluster';
|
||||
}
|
||||
const v = parts[0];
|
||||
const views = isNode() ? ['diff', 'nodes', 'runs', 'audit'] : ['diff', 'clusters', 'runs', 'audit'];
|
||||
S.view = views.includes(v) ? v : 'diff';
|
||||
if (S.view === 'audit' && !can.admin()) S.view = 'diff';
|
||||
// node keys hold a slash: cluster/node
|
||||
const c = parts.slice(1).join('/');
|
||||
if (c) S.client = c;
|
||||
}
|
||||
function writeHash() {
|
||||
const hsh = '#' + S.view + (S.view === 'diff' && S.client ? '/' + encodeURIComponent(S.client) : '');
|
||||
const hsh = '#' + (isNode() ? 'node/' : '') + S.view + (S.view === 'diff' && S.client ? '/' + encodeURIComponent(S.client).replace(/%2F/g, '/') : '');
|
||||
if (location.hash !== hsh) history.replaceState(null, '', hsh);
|
||||
}
|
||||
function setView(v) {
|
||||
@@ -489,10 +622,27 @@
|
||||
if (v === 'runs') loadRuns(true);
|
||||
if (v === 'audit') loadAudit(true);
|
||||
if (v === 'clusters') loadClusters();
|
||||
if (v === 'nodes') loadNodes();
|
||||
}
|
||||
// setMode switches between the two replications; each keeps its own view state.
|
||||
function setMode(m) {
|
||||
if (m === S.mode) return;
|
||||
S.mode = m;
|
||||
store.set('mode', m === 'node' ? 'node' : null);
|
||||
S.open.clear();
|
||||
if (S.view === 'clusters' || S.view === 'nodes') S.view = m === 'node' ? 'nodes' : 'clusters';
|
||||
fillSelects();
|
||||
writeHash();
|
||||
renderAll();
|
||||
if (S.view === 'nodes') loadNodes();
|
||||
if (S.view === 'clusters') loadClusters();
|
||||
if (S.view === 'runs') renderRuns();
|
||||
if (!S.state) loadState(false);
|
||||
}
|
||||
|
||||
// ---------- loading ----------
|
||||
async function loadState(refresh) {
|
||||
if (isNode()) return loadNodeState(refresh);
|
||||
busy(true);
|
||||
$('#refreshBtn').classList.add('spin');
|
||||
try {
|
||||
@@ -517,6 +667,39 @@
|
||||
}
|
||||
renderAll();
|
||||
}
|
||||
// loadNodeState reads the profiles, then the comparison of the selected one.
|
||||
async function loadNodeState(refresh) {
|
||||
const mode = MODES.node;
|
||||
busy(true);
|
||||
$('#refreshBtn').classList.add('spin');
|
||||
try {
|
||||
const d = await api('GET', 'api/nodes');
|
||||
S.nodes = d.nodes;
|
||||
S.profiles = d.profiles;
|
||||
S.nodeItems = d.items;
|
||||
S.nodeDefaults = d.defaults || {};
|
||||
fillProfiles();
|
||||
$('#error').hidden = true;
|
||||
if (!S.profile) {
|
||||
mode.state = null;
|
||||
} else {
|
||||
const r = await api('GET', 'api/nodestate?profile=' + encodeURIComponent(S.profile) + (refresh ? '&refresh=1' : ''));
|
||||
mode.state = r.state;
|
||||
const names = r.state.clients.map((c) => c.name);
|
||||
if (!names.includes(mode.client)) {
|
||||
const first = r.state.clients.find((c) => c.error || (c.plan && ((c.plan.changes || []).length || (c.plan.errors || []).length)));
|
||||
mode.client = first ? first.name : names[0] || null;
|
||||
}
|
||||
}
|
||||
if (isNode()) writeHash();
|
||||
} catch (e) {
|
||||
if (e.message !== 'auth') showError(e.message);
|
||||
} finally {
|
||||
busy(false);
|
||||
$('#refreshBtn').classList.remove('spin');
|
||||
}
|
||||
if (isNode()) renderAll();
|
||||
}
|
||||
function showError(msg) {
|
||||
const el = $('#error');
|
||||
el.textContent = msg;
|
||||
@@ -529,22 +712,31 @@
|
||||
renderStatus();
|
||||
}
|
||||
function renderView() {
|
||||
document.body.classList.toggle('mode-node', isNode());
|
||||
$$('#modeSw [data-mode]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.mode === S.mode)));
|
||||
$('#viewMode [data-view="clusters"]').hidden = isNode();
|
||||
$('#viewMode [data-view="nodes"]').hidden = !isNode();
|
||||
$$('#viewMode [data-view]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.view === S.view)));
|
||||
$('#diffView').hidden = S.view !== 'diff';
|
||||
$('#clustersView').hidden = S.view !== 'clusters';
|
||||
$('#nodesView').hidden = S.view !== 'nodes';
|
||||
$('#runsView').hidden = S.view !== 'runs';
|
||||
$('#auditView').hidden = S.view !== 'audit';
|
||||
$$('[data-diff]').forEach((e) => { e.hidden = S.view !== 'diff'; });
|
||||
$$('[data-runs]').forEach((e) => { e.hidden = S.view !== 'runs'; });
|
||||
$$('[data-node-only]').forEach((e) => { e.hidden = !isNode() || (e.matches('[data-diff]') && S.view !== 'diff') || (e.id === 'profileSel' && !S.profiles.length); });
|
||||
$('#globalActs').hidden = S.view !== 'diff' || !S.state || !can.sync();
|
||||
$('#setup').hidden = !S.setup || S.view === 'clusters';
|
||||
if (S.setup) {
|
||||
$('#setupText').textContent = t(S.setup === 'clusters' ? 'setupClusters' : 'setupPrimary');
|
||||
$('#setupBtn').textContent = t(S.setup === 'clusters' ? 'setupClustersBtn' : 'setupPrimaryBtn');
|
||||
const setup = isNode() ? (S.profiles.length || !S.me ? '' : 'nodes') : S.setup;
|
||||
$('#setup').hidden = !setup || S.view === 'clusters' || S.view === 'nodes';
|
||||
if (setup) {
|
||||
const k = { clusters: ['setupClusters', 'setupClustersBtn'], primary: ['setupPrimary', 'setupPrimaryBtn'], nodes: ['setupNodes', 'setupNodesBtn'] }[setup];
|
||||
$('#setupText').textContent = t(k[0]);
|
||||
$('#setupBtn').textContent = t(k[1]);
|
||||
}
|
||||
$$('[data-admin-only]').forEach((e) => { e.hidden = !can.admin(); });
|
||||
if (S.view === 'diff') { renderDiff(); renderTargetMenu(); }
|
||||
if (S.view === 'clusters') renderClusters();
|
||||
if (S.view === 'nodes') renderNodes();
|
||||
if (S.view === 'runs') renderRuns();
|
||||
if (S.view === 'audit') renderAudit();
|
||||
}
|
||||
@@ -572,12 +764,12 @@
|
||||
// reference card
|
||||
const r = st.reference;
|
||||
strip.appendChild(h('div', { class: 'ccard ref' },
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-role', text: t('reference') }),
|
||||
r.error ? h('span', { class: 'c-role st st-error', text: t('cst.error') }) : null, h('span', { class: 'c-name', text: r.name }),
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-role', text: t(isNode() ? 'nreference' : 'reference') }),
|
||||
r.error ? h('span', { class: 'c-role st st-error', text: t('cst.error') }) : null, h('span', { class: 'c-name', text: r.node || r.name, title: r.name }),
|
||||
h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })),
|
||||
h('div', { class: 'c-url', text: r.url }),
|
||||
h('div', { class: 'c-url', text: r.url || (r.cluster ? 'cluster ' + r.cluster : '') }),
|
||||
r.error ? h('div', { class: 'c-err', text: tm(r.error), title: r.error })
|
||||
: h('div', { class: 'c-counts' }, h('span', { class: 'muted small', text: t('objectsN', { n: r.objects }) }),
|
||||
: h('div', { class: 'c-counts' }, r.objects != null ? h('span', { class: 'muted small', text: t('objectsN', { n: r.objects }) }) : null,
|
||||
r.nodes && r.nodes.length ? h('span', { class: 'muted small', text: '· ' + t('nodes', { n: r.nodes.length }) }) : null)));
|
||||
for (const c of st.clients) {
|
||||
const cs = clientStatus(c);
|
||||
@@ -594,8 +786,8 @@
|
||||
type: 'button', role: 'tab', class: 'ccard', 'aria-selected': String(c.name === S.client),
|
||||
onclick: () => { S.client = c.name; S.open.clear(); writeHash(); renderDiff(); },
|
||||
},
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-role st st-' + cs, text: t('cst.' + cs) }), h('span', { class: 'c-name', text: c.name }), h('span', { class: 'c-ver', text: c.version ? 'PVE ' + c.version : '' })),
|
||||
h('div', { class: 'c-url', text: c.url }),
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-role st st-' + cs, text: t('cst.' + cs) }), h('span', { class: 'c-name', text: c.node || c.name, title: c.name }), h('span', { class: 'c-ver', text: c.version ? 'PVE ' + c.version : '' })),
|
||||
h('div', { class: 'c-url', text: c.url || (c.cluster ? 'cluster ' + c.cluster : '') }),
|
||||
counts,
|
||||
c.error ? h('div', { class: 'c-err', text: tm(c.error), title: c.error }) : null));
|
||||
}
|
||||
@@ -617,7 +809,7 @@
|
||||
if (!c) {
|
||||
bar.hidden = true;
|
||||
$('#objTable').hidden = true;
|
||||
emptyState(t('noClient'), t('noClientHint'));
|
||||
emptyState(t(isNode() ? 'noTargetN' : 'noClient'), t(isNode() ? 'noTargetNHint' : 'noClientHint'));
|
||||
$('#count').textContent = '';
|
||||
return;
|
||||
}
|
||||
@@ -635,11 +827,13 @@
|
||||
can.sync() ? h('div', { class: 'acts' },
|
||||
h('button', { type: 'button', class: 'btn tool', disabled: !!c.error || !!st.reference.error, onclick: () => openPlan({ scope: 'client', client: c.name }) },
|
||||
icon('scan'), t('dryClient')),
|
||||
h('button', { type: 'button', class: 'btn tool sync', disabled: blocked || nChanges === 0, onclick: () => openPlan({ scope: 'client', client: c.name }, true) },
|
||||
h('button', { type: 'button', class: 'btn tool sync', disabled: blocked || nChanges === 0 || !can.apply(), title: can.apply() ? null : t('syncAdminOnly'),
|
||||
onclick: () => openPlan({ scope: 'client', client: c.name }, true) },
|
||||
icon('sync'), t('syncClient'))) : null);
|
||||
if (c.error) notes.appendChild(note('err', t('clientError'), c.error));
|
||||
for (const e of p.errors || []) notes.appendChild(note('err', t('planError'), e));
|
||||
for (const w of p.warnings || []) notes.appendChild(note('warn', t('warning'), w));
|
||||
for (const e of p.errors || []) notes.appendChild(note('err', t('planError'), issue(e)));
|
||||
for (const w of p.warnings || []) notes.appendChild(note('warn', t('warning'), issue(w)));
|
||||
for (const w of c.warnings || []) if (isNode()) notes.appendChild(note('warn', t('warning'), w));
|
||||
|
||||
const rows = (c.rows || []).filter(match);
|
||||
$('#count').textContent = (c.rows || []).length ? rows.length + ' / ' + c.rows.length : '';
|
||||
@@ -650,7 +844,7 @@
|
||||
if (c.error) { $('#objEmpty').hidden = true; return; }
|
||||
const filtered = (c.rows || []).length && (S.kind || S.q || S.status !== 'diff');
|
||||
if (filtered) emptyState(t('emptyFilter'), t('emptyFilterHint'));
|
||||
else emptyState(t('emptyOk'), t('emptyOkHint'));
|
||||
else emptyState(t('emptyOk'), t(isNode() ? 'emptyOkHintN' : 'emptyOkHint'));
|
||||
return;
|
||||
}
|
||||
$('#objEmpty').hidden = true;
|
||||
@@ -662,6 +856,11 @@
|
||||
$('#objEmptyTitle').textContent = title;
|
||||
$('#objEmptyHint').textContent = hint;
|
||||
}
|
||||
// issue: the plans of the node replication give {key, message}, the cluster ones strings
|
||||
function issue(e) {
|
||||
if (e == null || typeof e === 'string') return e;
|
||||
return (e.key ? e.key.replace(/^[^/]+\//, '') + ' : ' : '') + e.message;
|
||||
}
|
||||
function note(kind, label, text) {
|
||||
return h('div', { class: 'note ' + kind }, h('b', { text: label }), h('span', { text: tm(text) }));
|
||||
}
|
||||
@@ -675,7 +874,8 @@
|
||||
}
|
||||
if (S.q) {
|
||||
const q = S.q.toLowerCase();
|
||||
const hay = [r.id, r.kind, ...(r.fields || []).flatMap((f) => [f.name, fmtVal(f.ref), fmtVal(f.current), fmtVal(f.mapping)])].join('\n').toLowerCase();
|
||||
const hay = [r.id, r.kind, ...(r.fields || []).flatMap((f) => [f.name, fmtVal(f.ref), fmtVal(f.current), fmtVal(f.mapping)]),
|
||||
...(r.diff || []).map((l) => l.text)].join('\n').toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
@@ -684,6 +884,15 @@
|
||||
function summary(r) {
|
||||
const s = h('span', { class: 'sum' });
|
||||
if (r.status === 'error') { s.textContent = tm((r.errors || [])[0] || ''); s.title = s.textContent; return s; }
|
||||
if (r.diff && r.diff.length) {
|
||||
const add = r.diff.filter((l) => l.op === '+').length;
|
||||
const del = r.diff.filter((l) => l.op === '-').length;
|
||||
if (add) s.appendChild(h('span', { class: 'f-add', text: '+' + add }));
|
||||
if (add && del) s.appendChild(document.createTextNode(' '));
|
||||
if (del) s.appendChild(h('span', { class: 'f-unset', text: '−' + del }));
|
||||
return s;
|
||||
}
|
||||
if (!(r.fields || []).length && isNode()) { s.textContent = t(r.status === 'extra' ? 'fileExtra' : 'fileSame'); return s; }
|
||||
const ch = (r.fields || []).filter((f) => f.change);
|
||||
if (!ch.length || r.status === 'delete') {
|
||||
s.textContent = t('fieldsN', { n: (r.fields || []).length });
|
||||
@@ -713,7 +922,7 @@
|
||||
summary(r),
|
||||
h('span', { class: 'acts' }, can.sync() && actionable && !c.error ? [
|
||||
iconBtn('scan', t('dryObj'), () => openPlan({ scope: 'object', client: c.name, objects: [r.key] })),
|
||||
iconBtn('sync', t('syncObj'), () => openPlan({ scope: 'object', client: c.name, objects: [r.key] }, true), 'go'),
|
||||
can.apply() ? iconBtn('sync', t('syncObj'), () => openPlan({ scope: 'object', client: c.name, objects: [r.key] }, true), 'go') : null,
|
||||
] : null));
|
||||
function toggle() {
|
||||
if (S.open.has(key)) S.open.delete(key); else S.open.add(key);
|
||||
@@ -723,9 +932,24 @@
|
||||
return row;
|
||||
}
|
||||
|
||||
// udiff renders the unified diff of a file
|
||||
function udiff(lines) {
|
||||
return h('div', { class: 'udiff' }, ...lines.map((l) => h('div', {
|
||||
class: l.op === '+' ? 'd-add' : l.op === '-' ? 'd-del' : l.op === '@' ? 'd-hunk' : '',
|
||||
text: l.op === '@' ? l.text : l.op + l.text,
|
||||
})));
|
||||
}
|
||||
function details(r) {
|
||||
const d = h('div', { class: 'details' });
|
||||
if ((r.errors || []).length) d.appendChild(h('p', { class: 'errs', text: r.errors.map(tm).join('\n') }));
|
||||
if (r.diff && r.diff.length) {
|
||||
d.appendChild(udiff(r.diff));
|
||||
return d;
|
||||
}
|
||||
if (isNode() && !(r.fields || []).length) {
|
||||
d.appendChild(h('p', { class: 'muted', text: t(r.status === 'extra' ? 'fileExtra' : 'fileSame') }));
|
||||
return d;
|
||||
}
|
||||
const tb = h('tbody');
|
||||
for (const f of r.fields || []) {
|
||||
const cell = (v, cls) => h('td', { class: cls + (v == null ? ' none' : ''), text: v == null ? '—' : fmtVal(v) });
|
||||
@@ -740,7 +964,7 @@
|
||||
return d;
|
||||
}
|
||||
d.appendChild(h('table', { class: 'fields' },
|
||||
h('thead', null, h('tr', null, ...['thField', 'thPolicy', 'thRef', 'thMapping', 'thCurrent', 'thTarget'].map((k) => h('th', { text: t(k) })))),
|
||||
h('thead', null, h('tr', null, ...['thField', 'thPolicy', isNode() ? 'thMaster' : 'thRef', 'thMapping', 'thCurrent', 'thTarget'].map((k) => h('th', { text: t(k) })))),
|
||||
tb));
|
||||
return d;
|
||||
}
|
||||
@@ -750,11 +974,12 @@
|
||||
const s = $('#stats');
|
||||
if (!st) { s.textContent = ''; return; }
|
||||
const d = st.clients.filter((c) => clientStatus(c) !== 'ok').length;
|
||||
s.textContent = t('statsLine', { ref: st.reference.name, n: st.clients.length, d, t: fmtTime(st.at) });
|
||||
s.textContent = isNode() ? t('nodeStats', { p: st.profile, ref: st.reference.name, n: st.clients.length, d, t: fmtTime(st.at) })
|
||||
: t('statsLine', { ref: st.reference.name, n: st.clients.length, d, t: fmtTime(st.at) });
|
||||
}
|
||||
|
||||
// ---------- dry-run, confirmation, synchronization ----------
|
||||
const plan = { sel: null, plans: null, run: 0, poll: 0 };
|
||||
const plan = { sel: null, plans: null, run: 0, poll: 0, node: false };
|
||||
|
||||
function whatLabel(sel) {
|
||||
if (sel.scope === 'global') return (sel.targets || []).length ? t('whatTargets', { l: sel.targets.join(', ') }) : t('whatGlobal');
|
||||
@@ -763,6 +988,9 @@
|
||||
}
|
||||
|
||||
async function openPlan(sel, intentSync) {
|
||||
// a plan stays in the mode it was opened in (node: profile and endpoints)
|
||||
if (sel.profile === undefined && isNode()) sel = Object.assign({ profile: S.profile }, sel);
|
||||
plan.node = sel.profile !== undefined;
|
||||
plan.sel = sel;
|
||||
plan.plans = null;
|
||||
clearInterval(plan.poll);
|
||||
@@ -774,7 +1002,7 @@
|
||||
if (!dlg.open) dlg.showModal();
|
||||
busy(true);
|
||||
try {
|
||||
const d = await api('POST', 'api/dryrun', sel);
|
||||
const d = await api('POST', plan.node ? 'api/nodedryrun' : 'api/dryrun', sel);
|
||||
plan.plans = d.plans;
|
||||
renderPlanKinds(sel, d.plans, intentSync);
|
||||
renderPlans(d.plans);
|
||||
@@ -782,7 +1010,7 @@
|
||||
const total = d.plans.reduce((n, p) => n + ((p.plan && p.plan.changes) || []).length, 0);
|
||||
const blocked = d.plans.some((p) => p.error || ((p.plan && p.plan.errors) || []).length);
|
||||
const ap = $('#planApply');
|
||||
ap.hidden = !can.sync() || total === 0 || blocked;
|
||||
ap.hidden = !(plan.node ? can.admin() : can.sync()) || total === 0 || blocked;
|
||||
ap.disabled = false;
|
||||
if (!ap.hidden && intentSync) ap.focus();
|
||||
// the dry-run read the clusters again: show what it read
|
||||
@@ -800,7 +1028,7 @@
|
||||
// and a change runs the dry-run again, so the reviewed plan is the one applied.
|
||||
function renderPlanKinds(sel, plans, intentSync) {
|
||||
const box = $('#planKinds');
|
||||
box.hidden = sel.scope === 'object' || !S.settings;
|
||||
box.hidden = sel.scope === 'object' || !S.settings || plan.node;
|
||||
if (box.hidden) return;
|
||||
const kinds = S.settings.kinds || KINDS;
|
||||
const admin = can.admin();
|
||||
@@ -850,8 +1078,9 @@
|
||||
else pf.appendChild(h('li', { class: 'add' }, '+ ' + k + ' = ', h('ins', { text: fmtVal(set[k]) })));
|
||||
}
|
||||
for (const k of ch.unset || []) pf.appendChild(h('li', { class: 'unset', text: '− ' + k + ' (' + t('was') + ' ' + fmtVal(old[k]) + ')' }));
|
||||
if (ch.op === 'delete') pf.appendChild(h('li', { class: 'unset', text: Object.keys(old).sort().map((k) => k + '=' + fmtVal(old[k])).join(', ') }));
|
||||
if (ch.op === 'delete' && !ch.diff) pf.appendChild(h('li', { class: 'unset', text: Object.keys(old).sort().map((k) => k + '=' + fmtVal(old[k])).join(', ') }));
|
||||
if (pf.children.length) li.appendChild(pf);
|
||||
if (ch.diff && ch.diff.length) li.appendChild(udiff(ch.diff));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
@@ -867,26 +1096,29 @@
|
||||
h('h4', null, p.client,
|
||||
h('span', { class: 'badge ' + (p.error || errs.length ? 'error' : changes.length ? 'update' : 'same'),
|
||||
text: p.error || errs.length ? t('st.error') : changes.length ? t('changesN', { n: changes.length }) : t('inLine') }),
|
||||
p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null),
|
||||
p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null,
|
||||
p.network ? h('span', { class: 'badge network', text: t('netBadge') }) : null),
|
||||
p.error ? note('err', t('clientError'), p.error) : null,
|
||||
errs.length ? h('div', { class: 'notes' }, note('err', t('planError'), t('planErrors')), ...errs.map((e) => note('err', '', e))) : null,
|
||||
((p.plan && p.plan.warnings) || []).length ? h('div', { class: 'notes' }, ...p.plan.warnings.map((w) => note('warn', t('warning'), w))) : null,
|
||||
changes.length ? changeList(changes) : (!p.error && !errs.length ? h('p', { class: 'muted small', text: t('planEmpty') }) : null)));
|
||||
errs.length ? h('div', { class: 'notes' }, note('err', t('planError'), t('planErrors')), ...errs.map((e) => note('err', '', issue(e)))) : null,
|
||||
((p.plan && p.plan.warnings) || []).length ? h('div', { class: 'notes' }, ...p.plan.warnings.map((w) => note('warn', t('warning'), issue(w)))) : null,
|
||||
changes.length ? changeList(changes) : (!p.error && !errs.length ? h('p', { class: 'muted small', text: t('planEmpty') }) : null),
|
||||
changes.length && (p.reloads || []).length ? h('p', { class: 'plan reloads', text: t('reloadsLine', { l: p.reloads.join(' ; ') }) }) : null));
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPlan() {
|
||||
const sel = plan.sel;
|
||||
const plans = plan.plans || [];
|
||||
const sdn = plans.some((p) => p.sdn && ((p.plan && p.plan.changes) || []).length);
|
||||
const want = sel.scope === 'global' ? S.state.reference.name : sel.client;
|
||||
const sdn = plans.some((p) => (p.sdn || p.network) && ((p.plan && p.plan.changes) || []).length);
|
||||
const want = sel.scope === 'global' ? (plan.node ? sel.profile : MODES.cluster.state.reference.name) : sel.client;
|
||||
let confirmText = '';
|
||||
if (sdn) {
|
||||
let box = $('#planBody .confirm-box');
|
||||
if (!box) {
|
||||
const inp = h('input', { class: 'inp mono', autocomplete: 'off', spellcheck: 'false', 'aria-label': want });
|
||||
box = h('div', { class: 'confirm-box' },
|
||||
h('span', { text: sel.scope === 'global' ? t('sdnConfirmGlobal', { w: want }) : t('sdnConfirm', { c: sel.client, w: want }) }), inp);
|
||||
h('span', { text: plan.node ? (sel.scope === 'global' ? t('netConfirmGlobal', { w: want }) : t('netConfirm', { c: sel.client, w: want }))
|
||||
: sel.scope === 'global' ? t('sdnConfirmGlobal', { w: want }) : t('sdnConfirm', { c: sel.client, w: want }) }), inp);
|
||||
$('#planBody').appendChild(box);
|
||||
$('#planApply').disabled = true;
|
||||
inp.addEventListener('input', () => { $('#planApply').disabled = inp.value !== want; });
|
||||
@@ -903,7 +1135,7 @@
|
||||
$('#planApply').disabled = true;
|
||||
busy(true);
|
||||
try {
|
||||
const d = await api('POST', 'api/apply', Object.assign({}, sel, { digests, confirm: confirmText }));
|
||||
const d = await api('POST', plan.node ? 'api/nodeapply' : 'api/apply', Object.assign({}, sel, { digests, confirm: confirmText }));
|
||||
if (!d.run) { toast(tm(d.summary) || t('planEmpty')); return; }
|
||||
plan.run = d.run;
|
||||
$('#planApply').hidden = true;
|
||||
@@ -956,16 +1188,18 @@
|
||||
res ? h('span', { class: 'badge ' + res.status, text: t('res.' + res.status) })
|
||||
: h('span', { class: 'badge ' + (p.error || errs.length ? 'error' : changes.length ? 'update' : 'same'),
|
||||
text: p.error || errs.length ? t('st.error') : changes.length ? t('changesN', { n: changes.length }) : t('inLine') }),
|
||||
p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null));
|
||||
p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null,
|
||||
p.network ? h('span', { class: 'badge network', text: t('netBadge') }) : null));
|
||||
if (p.error) box.appendChild(note('err', t('clientError'), p.error));
|
||||
for (const e of errs) box.appendChild(note('err', t('planError'), e));
|
||||
for (const e of errs) box.appendChild(note('err', t('planError'), issue(e)));
|
||||
if (changes.length) box.appendChild(changeList(changes));
|
||||
if (res && res.steps && res.steps.length) {
|
||||
box.appendChild(h('ul', { class: 'steps' }, ...res.steps.map((s) => h('li', null,
|
||||
h('time', { text: fmtTime(s.at) }), h('span', { class: s.error ? 'ko' : 'ok', text: s.error ? '✗' : '✓' }),
|
||||
h('span', { text: s.action }), s.error ? h('span', { class: 'e', text: s.error }) : null))));
|
||||
h('span', { text: s.action }), s.error ? h('span', { class: 'e', text: tm(s.error) }) : null))));
|
||||
}
|
||||
if (res && res.error) box.appendChild(note('err', t('st.error'), res.error));
|
||||
if (res && res.status === 'skipped') box.appendChild(h('p', { class: 'muted small', text: t('res.skipped') }));
|
||||
wrap.appendChild(box);
|
||||
}
|
||||
return wrap;
|
||||
@@ -990,7 +1224,8 @@
|
||||
function renderRuns() {
|
||||
const list = $('#runList');
|
||||
list.replaceChildren();
|
||||
const runs = S.runs.filter((r) => !S.runMode || r.mode === S.runMode)
|
||||
const runs = S.runs.filter((r) => (r.scope || '').startsWith('node') === isNode())
|
||||
.filter((r) => !S.runMode || r.mode === S.runMode)
|
||||
.filter((r) => !S.q || [r.user, r.summary, ...(r.clients || []), ...(r.objects || [])].join(' ').toLowerCase().includes(S.q.toLowerCase()));
|
||||
$('#count').textContent = S.runs.length ? String(runs.length) : '';
|
||||
if (!runs.length) list.appendChild(h('div', { class: 'row', role: 'presentation' }, h('span', { class: 'muted txt', text: t('noRuns') })));
|
||||
@@ -1248,14 +1483,14 @@
|
||||
return s;
|
||||
}
|
||||
function refIds(kind) {
|
||||
const st = S.state;
|
||||
const st = MODES.cluster.state;
|
||||
if (!st) return [];
|
||||
const ids = new Set();
|
||||
for (const c of st.clients) for (const r of c.rows || []) if (r.kind === kind) ids.add(r.id);
|
||||
return Array.from(ids).sort();
|
||||
}
|
||||
function knownFields(kind) {
|
||||
const st = S.state;
|
||||
const st = MODES.cluster.state;
|
||||
const f = new Set();
|
||||
if (st) for (const c of st.clients) for (const r of c.rows || []) if (r.kind === kind) for (const x of r.fields || []) f.add(x.name);
|
||||
if (kind === 'sdn-zone') ['bridge', 'nodes', 'peers', 'fabric', 'dns', 'reversedns', 'dnszone', 'mtu'].forEach((x) => f.add(x));
|
||||
@@ -1543,13 +1778,15 @@
|
||||
}
|
||||
}
|
||||
function saveSkip() {
|
||||
store.set('skipTargets', S.skip.size ? JSON.stringify(Array.from(S.skip)) : null);
|
||||
store.set(MODES[S.mode].skipKey, S.skip.size ? JSON.stringify(Array.from(S.skip)) : null);
|
||||
renderTargetMenu();
|
||||
}
|
||||
function globalSel() {
|
||||
const names = S.state ? S.state.clients.map((c) => c.name) : [];
|
||||
const sel = selectedTargets();
|
||||
return { scope: 'global', targets: sel.length === names.length ? [] : sel };
|
||||
const g = { scope: 'global', targets: sel.length === names.length ? [] : sel };
|
||||
if (isNode()) g.profile = S.profile;
|
||||
return g;
|
||||
}
|
||||
|
||||
// users
|
||||
@@ -1608,11 +1845,327 @@
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
// ---------- nodes and profiles view (node replication) ----------
|
||||
async function loadNodes() {
|
||||
try {
|
||||
const d = await api('GET', 'api/nodes');
|
||||
S.nodes = d.nodes;
|
||||
S.profiles = d.profiles;
|
||||
S.nodeItems = d.items;
|
||||
S.nodeDefaults = d.defaults || {};
|
||||
} catch (e) {
|
||||
if (e.message !== 'auth') toast(e.message, true);
|
||||
return;
|
||||
}
|
||||
fillProfiles();
|
||||
renderView();
|
||||
}
|
||||
function renderNodes() {
|
||||
const admin = can.admin();
|
||||
const pl = $('#profileList');
|
||||
pl.replaceChildren();
|
||||
if (!S.profiles.length) pl.appendChild(h('p', { class: 'muted', text: t('noProfiles') }));
|
||||
for (const p of S.profiles) {
|
||||
pl.appendChild(h('div', { class: 'citem prof' + (p.name === S.profile ? ' ref' : '') },
|
||||
h('div', { class: 'n' }, h('span', { text: p.name })),
|
||||
h('div', { class: 'u', text: p.master + ' → ' + ((p.targets || []).join(', ') || '—'), title: (p.targets || []).join(', ') }),
|
||||
h('div', { class: 'acts' },
|
||||
h('button', { type: 'button', class: 'btn tool', onclick: () => { S.profile = p.name; store.set('profile', p.name); MODES.node.state = null; setView('diff'); loadState(false); } },
|
||||
icon('scan'), t('viewDiff')),
|
||||
admin ? h('button', { type: 'button', class: 'btn tool', onclick: () => editProfile(p) }, icon('pencil'), t('edit')) : null),
|
||||
h('div', { class: 'items' }, ...(p.items || []).map((k) => h('span', { class: 'kind ' + k, text: t('kinds.' + k) })),
|
||||
h('span', { class: 'muted small', text: t('targetsN', { n: (p.targets || []).length }) + (p.prune ? ' · prune' : '') }))));
|
||||
}
|
||||
const nl = $('#nodeList');
|
||||
nl.replaceChildren();
|
||||
if (!S.nodes.length) nl.appendChild(h('p', { class: 'muted', text: t('noNodes') }));
|
||||
for (const n of S.nodes) {
|
||||
const chk = h('div', { class: 'chk', hidden: true });
|
||||
const meta = h('div', { class: 'meta' }, checkLine(n));
|
||||
if (admin && n.sshFingerprint) meta.appendChild(h('span', { class: 'mono', title: n.sshFingerprint, text: 'SSH ' + n.sshFingerprint }));
|
||||
if (admin && n.hasPassword) meta.appendChild(h('span', { text: t('pwdOwn') }));
|
||||
const addr = admin ? 'ssh root@' + (n.sshHost || n.ip) + (n.sshPort && n.sshPort !== 22 ? ':' + n.sshPort : '') : '';
|
||||
nl.appendChild(h('div', { class: 'citem node cl ' + (n.clusterRole || '') },
|
||||
h('div', { class: 'n' }, h('span', { class: 'rbadge ' + n.clusterRole, text: n.cluster }), h('span', { text: n.node })),
|
||||
h('div', { class: 'u', text: addr, title: addr }),
|
||||
admin ? h('div', { class: 'acts' },
|
||||
h('button', { type: 'button', class: 'btn tool', onclick: () => checkNode(n, chk) }, icon('plug'), t('test')),
|
||||
h('button', { type: 'button', class: 'btn tool', onclick: () => editNode(n) }, icon('pencil'), t('edit'))) : h('span'),
|
||||
meta, chk));
|
||||
}
|
||||
}
|
||||
const nodePath = (n) => 'api/nodes/' + encodeURIComponent(n.cluster) + '/' + encodeURIComponent(n.node);
|
||||
async function checkNode(n, el, trust) {
|
||||
if (el) { el.hidden = false; el.className = 'chk muted'; el.textContent = t('testing'); }
|
||||
let r;
|
||||
try {
|
||||
r = await api('POST', nodePath(n) + '/check', trust ? { trust: true } : undefined);
|
||||
} catch (e) {
|
||||
if (el) { el.className = 'chk ko'; el.textContent = '✗ ' + e.message; }
|
||||
return false;
|
||||
}
|
||||
await loadNodes();
|
||||
const row = $$('#nodeList .citem').find((x) => $('.n span:first-child', x).textContent === n.cluster && $('.n span:last-child', x).textContent === n.node);
|
||||
el = row ? $('.chk', row) : null;
|
||||
if (!el) return r.ok;
|
||||
el.hidden = false;
|
||||
if (!r.ok) {
|
||||
el.className = 'chk ko';
|
||||
el.replaceChildren('✗ ' + tm(r.error));
|
||||
if (r.sshMismatch) {
|
||||
el.append(' ', h('button', { type: 'button', class: 'link-btn', text: t('trustKey'),
|
||||
onclick: () => { if (confirm(t('confirmTrust', { c: n.key, fp: r.sshFingerprint }))) checkNode(n, el, true); } }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
el.className = 'chk ok';
|
||||
el.textContent = '✓ ' + t('testOk') + (r.version ? ' · PVE ' + r.version : '') + (r.sshFingerprint ? ' — ' + t('pinned', { l: 'SSH ' + r.sshFingerprint }) : '');
|
||||
return true;
|
||||
}
|
||||
async function testNodes() {
|
||||
for (const n of S.nodes) {
|
||||
const row = $$('#nodeList .citem').find((x) => $('.n span:first-child', x).textContent === n.cluster && $('.n span:last-child', x).textContent === n.node);
|
||||
await checkNode(n, row ? $('.chk', row) : null);
|
||||
}
|
||||
}
|
||||
async function discoverNodes() {
|
||||
busy(true);
|
||||
try {
|
||||
const d = await api('POST', 'api/nodes/discover');
|
||||
const errs = Object.entries(d.errors || {});
|
||||
toast(t('discovered', { n: d.found }) + (errs.length ? ' — ' + errs.map(([c, e]) => c + ' : ' + tm(e)).join(' ; ') : ''), errs.length > 0);
|
||||
} catch (e) {
|
||||
if (e.message !== 'auth') toast(e.message, true);
|
||||
} finally {
|
||||
busy(false);
|
||||
}
|
||||
loadNodes();
|
||||
}
|
||||
|
||||
let editingNode = null;
|
||||
function editNode(n) {
|
||||
editingNode = n;
|
||||
$('#nodeTitle').textContent = t('editNode', { n: n.key });
|
||||
$('#nHost').value = n.sshHost || '';
|
||||
$('#nHost').placeholder = n.ip || '';
|
||||
$('#nPort').value = n.sshPort && n.sshPort !== 22 ? n.sshPort : '';
|
||||
$('#nPassword').value = '';
|
||||
$('#nPassword').placeholder = t('nodePwdKeep', { w: t(n.hasPassword ? 'pwdOwn' : 'pwdCluster') });
|
||||
$('#nClear').checked = !n.hasPassword;
|
||||
$('#nKey').textContent = n.sshFingerprint || t('sshKeyNone');
|
||||
$('#nForget').hidden = !n.sshFingerprint;
|
||||
$('#nErr').hidden = true;
|
||||
$('#nodeDlg').showModal();
|
||||
$('#nHost').focus();
|
||||
}
|
||||
async function saveNode(ev) {
|
||||
ev.preventDefault();
|
||||
const n = editingNode;
|
||||
const pwd = $('#nPassword').value;
|
||||
try {
|
||||
await api('PUT', nodePath(n), { sshHost: $('#nHost').value.trim(), sshPort: Number($('#nPort').value) || 22,
|
||||
password: pwd, clearPassword: !pwd && $('#nClear').checked && n.hasPassword });
|
||||
$('#nodeDlg').close();
|
||||
toast(t('saved'));
|
||||
await loadNodes();
|
||||
checkNode(n, null);
|
||||
} catch (e) {
|
||||
$('#nErr').textContent = e.message;
|
||||
$('#nErr').hidden = false;
|
||||
}
|
||||
}
|
||||
async function forgetNode() {
|
||||
const n = editingNode;
|
||||
if (!n || !confirm(t('confirmForget', { c: n.key }))) return;
|
||||
try {
|
||||
await api('POST', nodePath(n) + '/forget');
|
||||
$('#nodeDlg').close();
|
||||
loadNodes();
|
||||
} catch (e) {
|
||||
$('#nErr').textContent = e.message;
|
||||
$('#nErr').hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
// profile editor
|
||||
let editingProfile = null;
|
||||
function editProfile(p) {
|
||||
editingProfile = p || null;
|
||||
p = p || { name: '', master: '', targets: [], items: ['network', 'dns'], prune: false, policies: {}, protect: {}, mapping: {} };
|
||||
$('#profileTitle').textContent = editingProfile ? t('editProfile', { p: p.name }) : t('newProfile');
|
||||
$('#pName').value = p.name;
|
||||
const keys = S.nodes.map((n) => n.key);
|
||||
if (p.master && !keys.includes(p.master)) keys.unshift(p.master);
|
||||
$('#pMaster').replaceChildren(h('option', { value: '', text: '—' }), ...keys.map((k) => h('option', { value: k, text: k })));
|
||||
$('#pMaster').value = p.master;
|
||||
renderTargetChips(p.targets || []);
|
||||
$('#pItems').replaceChildren(...NITEMS.map((k) => chip(k, t('kinds.' + k), (p.items || []).includes(k))));
|
||||
$('#pPrune').checked = !!p.prune;
|
||||
const pr = $('#pPolRows');
|
||||
pr.replaceChildren();
|
||||
for (const [k, fields] of Object.entries(p.policies || {})) for (const [f, x] of Object.entries(fields || {})) pr.appendChild(nodePolRow(k, f, x));
|
||||
$('#pProtect').replaceChildren(...NITEMS.filter((k) => k !== 'network' && k !== 'dns').map((k) => h('div', { class: 'pprot', dataset: { item: k } },
|
||||
h('label', { text: t('kinds.' + k) }),
|
||||
h('textarea', { class: 'inp', spellcheck: 'false', placeholder: k === 'chrony' ? '^server .* iburst$' : '^# host ' }, ((p.protect || {})[k] || []).join('\n')))));
|
||||
renderMapRows(p);
|
||||
$('#pDelete').hidden = !editingProfile;
|
||||
$('#pErr').hidden = true;
|
||||
$('#profileDlg').showModal();
|
||||
$('#pName').focus();
|
||||
}
|
||||
function chip(value, label, on, onclick) {
|
||||
return h('button', { type: 'button', class: 'chip' + (on ? ' on' : ''), dataset: { v: value }, 'aria-pressed': String(on),
|
||||
onclick: (ev) => {
|
||||
const b = ev.currentTarget;
|
||||
b.classList.toggle('on');
|
||||
b.setAttribute('aria-pressed', String(b.classList.contains('on')));
|
||||
if (onclick) onclick();
|
||||
} }, label);
|
||||
}
|
||||
const chipsOn = (sel) => $$(sel + ' .chip.on').map((c) => c.dataset.v);
|
||||
function renderTargetChips(targets) {
|
||||
const master = $('#pMaster').value;
|
||||
const keys = S.nodes.map((n) => n.key).filter((k) => k !== master);
|
||||
for (const x of targets) if (!keys.includes(x) && x !== master) keys.push(x);
|
||||
$('#pTargets').replaceChildren(...keys.map((k) => chip(k, k, targets.includes(k), () => renderMapRows(readProfile()))));
|
||||
}
|
||||
function nodePolRow(item, field, pol) {
|
||||
const ks = h('select', { class: 'field' }, ...['network', 'dns'].map((k) => h('option', { value: k, text: t('kindShort.' + k) })));
|
||||
ks.value = item || 'network';
|
||||
const fieldsOf = (k) => k === 'dns' ? ['search', 'dns1', 'dns2', 'dns3']
|
||||
: ['autostart', 'bond-primary', 'bond_mode', 'bond_xmit_hash_policy', 'bridge_ports', 'bridge_vids', 'bridge_vlan_aware', 'cidr', 'cidr6',
|
||||
'comments', 'gateway', 'gateway6', 'mtu', 'slaves', 'vlan-id', 'vlan-raw-device'];
|
||||
const [fdl, fList] = datalist(fieldsOf(ks.value));
|
||||
const ps = h('select', { class: 'field', 'data-k': 'policy' }, ...POLICIES.map((x) => h('option', { value: x, text: x })));
|
||||
ps.value = pol || 'ignore';
|
||||
const row = h('div', { class: 'erow pol' }, ks,
|
||||
h('input', { class: 'inp mono', list: fdl, value: field || '', placeholder: 'mtu', 'data-k': 'field' }),
|
||||
ps, iconBtn('trash', t('delete'), () => row.remove(), 'danger'), fList);
|
||||
ks.addEventListener('change', () => fList.replaceChildren(...fieldsOf(ks.value).map((v) => h('option', { value: v }))));
|
||||
return row;
|
||||
}
|
||||
// renderMapRows: one block per target, NIC names and own values
|
||||
function renderMapRows(p) {
|
||||
const box = $('#pMapRows');
|
||||
const prev = {};
|
||||
for (const b of $$('.pmap', box)) prev[b.dataset.target] = { nics: $('[data-k="nics"]', b).value, values: $('[data-k="values"]', b).value };
|
||||
box.replaceChildren();
|
||||
for (const tg of p.targets || []) {
|
||||
const m = (p.mapping || {})[tg] || {};
|
||||
const nics = prev[tg] ? prev[tg].nics : Object.entries(m.nics || {}).map(([a, b]) => a + '=' + b).join(', ');
|
||||
const vals = prev[tg] ? prev[tg].values : Object.entries(m.values || {}).flatMap(([key, fields]) => {
|
||||
const [item, id] = [key.slice(0, key.indexOf('/')), key.slice(key.indexOf('/') + 1)];
|
||||
return Object.entries(fields).map(([f, v]) => (item === 'dns' ? 'dns' : id) + ' ' + f + '=' + v);
|
||||
}).join('\n');
|
||||
box.appendChild(h('div', { class: 'pmap', dataset: { target: tg } },
|
||||
h('b', { text: tg }),
|
||||
h('label', { text: t('mapNics') }),
|
||||
h('input', { class: 'inp mono', 'data-k': 'nics', value: nics, placeholder: 'eno1=enp65s0f0, eno2=enp65s0f1', spellcheck: 'false' }),
|
||||
h('label', { text: t('mapValues') }),
|
||||
h('textarea', { class: 'inp', 'data-k': 'values', spellcheck: 'false', placeholder: 'vmbr1 cidr=10.5.0.12/24\ndns dns1=10.0.0.53' }, vals)));
|
||||
}
|
||||
}
|
||||
// readProfile reads the editor; bad lines of the mapping are reported in errs
|
||||
function readProfile(errs) {
|
||||
const p = {
|
||||
name: $('#pName').value.trim(), master: $('#pMaster').value, targets: chipsOn('#pTargets'), items: NITEMS.filter((k) => chipsOn('#pItems').includes(k)),
|
||||
prune: $('#pPrune').checked, policies: {}, protect: {}, mapping: {}, position: editingProfile ? editingProfile.position : S.profiles.length,
|
||||
};
|
||||
for (const row of $$('#pPolRows .erow')) {
|
||||
const k = $('select', row).value;
|
||||
const f = $('[data-k="field"]', row).value.trim();
|
||||
if (f) (p.policies[k] = p.policies[k] || {})[f] = $('[data-k="policy"]', row).value;
|
||||
}
|
||||
for (const b of $$('#pProtect .pprot')) {
|
||||
const l = $('textarea', b).value.split('\n').map((x) => x.trim()).filter(Boolean);
|
||||
if (l.length) p.protect[b.dataset.item] = l;
|
||||
}
|
||||
for (const b of $$('#pMapRows .pmap')) {
|
||||
const m = { nics: {}, values: {} };
|
||||
for (const pair of $('[data-k="nics"]', b).value.split(',').map((x) => x.trim()).filter(Boolean)) {
|
||||
const [a, c] = pair.split('=').map((x) => (x || '').trim());
|
||||
if (a && c) m.nics[a] = c; else if (errs) errs.push(b.dataset.target + ' : ' + pair);
|
||||
}
|
||||
for (const line of $('[data-k="values"]', b).value.split('\n').map((x) => x.trim()).filter(Boolean)) {
|
||||
const mm = line.match(/^(\S+)\s+([\w-]+)\s*=\s*(.*)$/);
|
||||
if (!mm) { if (errs) errs.push(b.dataset.target + ' : ' + line); continue; }
|
||||
const key = mm[1] === 'dns' ? 'dns/dns' : 'network/' + mm[1];
|
||||
(m.values[key] = m.values[key] || {})[mm[2]] = mm[3].trim();
|
||||
}
|
||||
if (Object.keys(m.nics).length || Object.keys(m.values).length) p.mapping[b.dataset.target] = m;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
async function saveProfile(ev) {
|
||||
ev.preventDefault();
|
||||
const errs = [];
|
||||
const p = readProfile(errs);
|
||||
if (errs.length) {
|
||||
$('#pErr').textContent = t('bulkBadLine', { l: errs.join(' ; ') });
|
||||
$('#pErr').hidden = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api('PUT', 'api/profiles/' + encodeURIComponent(editingProfile ? editingProfile.name : '-'), p);
|
||||
$('#profileDlg').close();
|
||||
toast(t('saved'));
|
||||
S.profile = p.name;
|
||||
store.set('profile', p.name);
|
||||
MODES.node.state = null;
|
||||
await loadNodes();
|
||||
loadNodeState(true);
|
||||
} catch (e) {
|
||||
$('#pErr').textContent = e.message;
|
||||
$('#pErr').hidden = false;
|
||||
}
|
||||
}
|
||||
async function deleteProfile() {
|
||||
if (!editingProfile || !confirm(t('confirmDeleteProfile', { p: editingProfile.name }))) return;
|
||||
try {
|
||||
await api('DELETE', 'api/profiles/' + encodeURIComponent(editingProfile.name));
|
||||
$('#profileDlg').close();
|
||||
MODES.node.state = null;
|
||||
await loadNodes();
|
||||
loadNodeState(false);
|
||||
} catch (e) {
|
||||
$('#pErr').textContent = e.message;
|
||||
$('#pErr').hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- events ----------
|
||||
function bind() {
|
||||
$('#refreshBtn').addEventListener('click', () => { if (S.view === 'diff') loadState(true); else if (S.view === 'runs') loadRuns(true); else loadAudit(true); });
|
||||
$('#refreshBtn').addEventListener('click', () => {
|
||||
if (S.view === 'diff') loadState(true);
|
||||
else if (S.view === 'runs') loadRuns(true);
|
||||
else if (S.view === 'clusters') loadClusters();
|
||||
else if (S.view === 'nodes') loadNodes();
|
||||
else loadAudit(true);
|
||||
});
|
||||
$$('#modeSw [data-mode]').forEach((b) => b.addEventListener('click', () => setMode(b.dataset.mode)));
|
||||
$('#profileSel').addEventListener('change', (e) => {
|
||||
S.profile = e.target.value;
|
||||
store.set('profile', S.profile);
|
||||
MODES.node.state = null;
|
||||
S.open.clear();
|
||||
loadNodeState(false);
|
||||
});
|
||||
$('#addProfile').addEventListener('click', () => editProfile(null));
|
||||
$('#discoverBtn').addEventListener('click', discoverNodes);
|
||||
$('#testNodes').addEventListener('click', testNodes);
|
||||
$('#nodeForm').addEventListener('submit', saveNode);
|
||||
$('#nForget').addEventListener('click', forgetNode);
|
||||
$('#profileForm').addEventListener('submit', saveProfile);
|
||||
$('#pDelete').addEventListener('click', deleteProfile);
|
||||
$('#pMaster').addEventListener('change', () => { const p = readProfile(); renderTargetChips(p.targets); renderMapRows(readProfile()); });
|
||||
$('#pPolAdd').addEventListener('click', () => $('#pPolRows').appendChild(nodePolRow('network', '', 'sync')));
|
||||
$('#settingsBtn').addEventListener('click', () => openSettings());
|
||||
$('#setupBtn').addEventListener('click', () => {
|
||||
if (isNode()) {
|
||||
setView('nodes');
|
||||
if (can.admin()) editProfile(null);
|
||||
return;
|
||||
}
|
||||
setView('clusters');
|
||||
if (S.setup === 'clusters' && can.admin()) editCluster(null);
|
||||
});
|
||||
@@ -1703,6 +2256,7 @@
|
||||
S.deps = d.deps || {};
|
||||
} catch (e) { /* the replicated items stay read only */ }
|
||||
if (S.view === 'clusters') loadClusters();
|
||||
if (S.view === 'nodes') loadNodes();
|
||||
await loadState(false);
|
||||
if (S.view === 'runs') loadRuns(true);
|
||||
if (S.view === 'audit') loadAudit(true);
|
||||
|
||||
+112
@@ -16,6 +16,20 @@
|
||||
<span id="demoBadge" class="demo-badge" hidden data-i18n="demo">DÉMO</span>
|
||||
</div>
|
||||
|
||||
<!-- the two replications: cluster to cluster (API), node to node (system files) -->
|
||||
<div id="modeSw" class="seg mode-sw" role="radiogroup" data-i18n-aria="modeAriaSw">
|
||||
<button type="button" role="radio" data-mode="cluster" data-i18n-title="modeClusterTitle">
|
||||
<!-- Lucide "layers" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg>
|
||||
<span data-i18n="modeCluster">Clusters (API)</span>
|
||||
</button>
|
||||
<button type="button" role="radio" data-mode="node" data-i18n-title="modeNodeTitle">
|
||||
<!-- Lucide "hard-drive" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22 12H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M6 16h.01M10 16h.01"/></svg>
|
||||
<span data-i18n="modeNode">Nœuds (fichiers)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||
<input id="q" type="search" autocomplete="off" spellcheck="false" data-i18n-ph="searchPh" data-i18n-aria="searchPh">
|
||||
@@ -56,6 +70,11 @@
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01M6 18h.01"/></svg>
|
||||
<span data-i18n="viewClusters">Clusters</span>
|
||||
</button>
|
||||
<button type="button" role="radio" data-view="nodes" hidden>
|
||||
<!-- Lucide "server" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01M6 18h.01"/></svg>
|
||||
<span data-i18n="viewNodes">Nœuds et profils</span>
|
||||
</button>
|
||||
<button type="button" role="radio" data-view="runs">
|
||||
<!-- Lucide "history" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
|
||||
@@ -67,6 +86,7 @@
|
||||
<span data-i18n="viewAudit">Audit</span>
|
||||
</button>
|
||||
</div>
|
||||
<select id="profileSel" data-diff data-node-only data-i18n-aria="profileAria"></select>
|
||||
<select id="kindSel" data-diff data-i18n-aria="kindAria"></select>
|
||||
<select id="statusSel" data-diff data-i18n-aria="statusAria"></select>
|
||||
<select id="runModeSel" data-runs data-i18n-aria="modeAria" hidden></select>
|
||||
@@ -132,6 +152,20 @@
|
||||
<div id="clusterList" class="clist"></div>
|
||||
</section>
|
||||
|
||||
<!-- Nodes (SSH access) and node replication profiles -->
|
||||
<section id="nodesView" class="cl-view" hidden>
|
||||
<div class="cl-tools">
|
||||
<p class="muted small" data-i18n="nodesHelp"></p>
|
||||
<button id="addProfile" class="btn tool primary" type="button" data-admin-only data-i18n="addProfile">+ Nouveau profil</button>
|
||||
<button id="discoverBtn" class="btn tool" type="button" data-admin-only data-i18n="discover">Découvrir les nœuds</button>
|
||||
<button id="testNodes" class="btn tool" type="button" data-admin-only data-i18n="testAll">Tout tester</button>
|
||||
</div>
|
||||
<h3 class="sec-title" data-i18n="profilesTitle">Profils de réplication</h3>
|
||||
<div id="profileList" class="clist"></div>
|
||||
<h3 class="sec-title" data-i18n="nodesTitle">Nœuds</h3>
|
||||
<div id="nodeList" class="clist"></div>
|
||||
</section>
|
||||
|
||||
<!-- Execution journal -->
|
||||
<section id="runsView" hidden>
|
||||
<div class="table runs">
|
||||
@@ -297,6 +331,84 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- SSH access of a node (administrators) -->
|
||||
<dialog id="nodeDlg" aria-labelledby="nodeTitle">
|
||||
<form class="dlg" id="nodeForm" method="dialog">
|
||||
<header>
|
||||
<h2 id="nodeTitle"></h2>
|
||||
<button class="icon-btn" type="button" data-close data-i18n-aria="close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
||||
</header>
|
||||
<div class="dlg-body">
|
||||
<p class="muted small" data-i18n="nodeHelp"></p>
|
||||
<div class="field-grid">
|
||||
<label for="nHost" data-i18n="nHost">Adresse SSH</label>
|
||||
<input id="nHost" class="inp mono" autocomplete="off" spellcheck="false">
|
||||
<label for="nPort" data-i18n="cSSHPort">Port SSH</label>
|
||||
<input id="nPort" class="inp mono" type="number" min="1" max="65535" placeholder="22">
|
||||
<label for="nPassword" data-i18n="nPassword">Mot de passe root</label>
|
||||
<input id="nPassword" class="inp mono" type="password" autocomplete="new-password" spellcheck="false">
|
||||
<span></span>
|
||||
<label class="switch-row"><input id="nClear" type="checkbox" class="switch"><span data-i18n="nClear">Utiliser le mot de passe du cluster</span></label>
|
||||
<span class="lbl" data-i18n="cSSHKey">Clé d'hôte SSH</span>
|
||||
<span id="nKey" class="mono small muted"></span>
|
||||
</div>
|
||||
<p id="nErr" class="login-err" role="alert" hidden></p>
|
||||
</div>
|
||||
<footer>
|
||||
<button id="nForget" class="btn ghost" type="button" data-i18n="forgetKey">Oublier la clé</button>
|
||||
<span class="sp"></span>
|
||||
<button class="btn ghost" type="button" data-close data-i18n="cancel">Annuler</button>
|
||||
<button class="btn primary" type="submit" data-i18n="save">Enregistrer</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Node replication profile (administrators) -->
|
||||
<dialog id="profileDlg" class="wide" aria-labelledby="profileTitle">
|
||||
<form class="dlg" id="profileForm" method="dialog">
|
||||
<header>
|
||||
<h2 id="profileTitle"></h2>
|
||||
<button class="icon-btn" type="button" data-close data-i18n-aria="close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
||||
</header>
|
||||
<div class="dlg-body">
|
||||
<div class="field-grid">
|
||||
<label for="pName" data-i18n="cName">Nom</label>
|
||||
<input id="pName" class="inp" required autocomplete="off" spellcheck="false">
|
||||
<label for="pMaster" data-i18n="pMaster">Nœud maître</label>
|
||||
<select id="pMaster" class="field" required></select>
|
||||
</div>
|
||||
<div class="sec-head mt"><h3 class="lbl" data-i18n="pTargets">Nœuds cibles</h3></div>
|
||||
<div id="pTargets" class="chip-field"></div>
|
||||
<div class="sec-head mt"><h3 class="lbl" data-i18n="pItems">Éléments répliqués</h3></div>
|
||||
<div id="pItems" class="chip-field"></div>
|
||||
<p class="muted small mt-s" data-i18n="pItemsHelp"></p>
|
||||
<label class="switch-row mt-s"><input id="pPrune" type="checkbox" class="switch"><span data-i18n="pPrune">Supprimer sur les cibles ce que le maître n'a pas</span></label>
|
||||
<p class="muted small" data-i18n="pPruneHelp"></p>
|
||||
|
||||
<div class="sec-head mt"><h3 class="lbl" data-i18n="polTitle">Politiques par champ</h3></div>
|
||||
<p class="muted small" data-i18n="pPolHelp"></p>
|
||||
<div class="erow-head pol"><span data-i18n="colKind">Type</span><span data-i18n="colField">Champ</span><span data-i18n="colPolicy">Politique</span><span></span></div>
|
||||
<div id="pPolRows" class="erows"></div>
|
||||
<div class="toolbar"><button id="pPolAdd" class="btn tool" type="button" data-i18n="addRow">+ Ajouter</button></div>
|
||||
|
||||
<div class="sec-head mt"><h3 class="lbl" data-i18n="pProtect">Lignes protégées des fichiers</h3></div>
|
||||
<p class="muted small" data-i18n="pProtectHelp"></p>
|
||||
<div id="pProtect" class="erows"></div>
|
||||
|
||||
<div class="sec-head mt"><h3 class="lbl" data-i18n="pMapping">Mapping par cible</h3></div>
|
||||
<p class="muted small" data-i18n="pMappingHelp"></p>
|
||||
<div id="pMapRows" class="erows"></div>
|
||||
<p id="pErr" class="login-err" role="alert" hidden></p>
|
||||
</div>
|
||||
<footer>
|
||||
<button id="pDelete" class="btn danger" type="button" data-i18n="delete">Supprimer</button>
|
||||
<span class="sp"></span>
|
||||
<button class="btn ghost" type="button" data-close data-i18n="cancel">Annuler</button>
|
||||
<button class="btn primary" type="submit" data-i18n="save">Enregistrer</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="settingsDlg" class="settings" aria-labelledby="settingsTitle">
|
||||
<div class="dlg">
|
||||
<header>
|
||||
|
||||
@@ -756,3 +756,53 @@ body.login-page { min-height: 100vh; padding: 16px; display: grid; place-items:
|
||||
.plan-kinds .seg button:disabled { cursor: default; }
|
||||
.plan-kinds .seg button:disabled:not([aria-checked="true"]) { opacity: .55; }
|
||||
@media (max-width: 560px) { .plan-kinds .pk-row { grid-template-columns: minmax(0, 1fr) auto; } .plan-kinds .pk-row .kind, .plan-kinds .pk-n { display: none; } }
|
||||
|
||||
/* ---------- node replication ---------- */
|
||||
/* the two replications, side by side in the top bar */
|
||||
.mode-sw { flex: none; }
|
||||
.mode-sw button { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; font-weight: 600; }
|
||||
.mode-sw svg { width: 15px; height: 15px; }
|
||||
.mode-sw button[aria-checked="true"] { background: var(--accent); color: #fff; }
|
||||
body.mode-node .brand svg rect { fill: var(--k-vnet); }
|
||||
|
||||
.kind.network { --k: var(--k-zone); }
|
||||
.kind.dns { --k: var(--k-vnet); }
|
||||
.kind.multipath, .kind.wwids { --k: var(--k-acc); }
|
||||
.kind.chrony { --k: var(--k-dc); }
|
||||
.kind.sysctl { --k: var(--k-subnet); }
|
||||
.kind.interfaces-d { --k: var(--k-zone); }
|
||||
.badge.network { --c: var(--k-zone); margin-left: 4px; }
|
||||
|
||||
/* unified diff of a file */
|
||||
.udiff { margin: 4px 0 0; padding: 6px 0; border: 1px solid var(--border); border-radius: 8px; background: var(--panel-2);
|
||||
font: 12px/1.45 var(--mono); overflow-x: auto; max-height: 420px; overflow-y: auto; }
|
||||
.udiff div { padding: 0 10px; white-space: pre; min-height: 1.45em; }
|
||||
.udiff .d-add { color: var(--op-create); background: color-mix(in srgb, var(--op-create) 10%, transparent); }
|
||||
.udiff .d-del { color: var(--op-delete); background: color-mix(in srgb, var(--op-delete) 10%, transparent); }
|
||||
.udiff .d-hunk { color: var(--info); padding-top: 4px; }
|
||||
.plan .udiff { margin-left: 1.2rem; }
|
||||
.plan .reloads { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.sec-title { margin: 18px 0 8px; font-size: 11px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
|
||||
.sec-title:first-of-type { margin-top: 6px; }
|
||||
.citem.node { grid-template-columns: minmax(9rem, max-content) minmax(0, 1fr) max-content; }
|
||||
.citem.prof { grid-template-columns: minmax(9rem, max-content) minmax(0, 1fr) max-content; }
|
||||
.citem .items { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.citem .items .kind { width: auto; }
|
||||
dialog.wide { width: min(980px, calc(100vw - 32px)); }
|
||||
|
||||
/* mapping of one target in the profile editor */
|
||||
.pmap { display: grid; grid-template-columns: 11rem minmax(0, 1fr); gap: 6px 10px; align-items: start; padding: 8px 10px;
|
||||
border: 1px solid var(--border); border-radius: 8px; }
|
||||
.pmap > b { grid-column: 1 / -1; font: 600 12.5px var(--mono); }
|
||||
.pmap label { font-size: 12px; color: var(--muted); padding-top: 6px; }
|
||||
.pmap textarea { min-height: 3.2em; font-family: var(--mono); font-size: 12px; }
|
||||
.pprot { display: grid; grid-template-columns: 11rem minmax(0, 1fr); gap: 6px 10px; align-items: start; }
|
||||
.pprot textarea { min-height: 2.4em; font-family: var(--mono); font-size: 12px; }
|
||||
@media (max-width: 1100px) {
|
||||
.mode-sw span { display: none; }
|
||||
.mode-sw button { padding: 0 9px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.pmap, .pprot { grid-template-columns: 1fr; }
|
||||
}
|
||||
Reference in new issue
Block a user