patchcenter/app/services/snapshot_mgmt_service.py
Admin MPCZ c63b3a9119 feat(snapshots): page de gestion snapshots VM (listing + filtre auteur/age + suppression)
Service snapshot_mgmt_service.py:
- list_snapshots(db, vcenter_filter_id=None): itere les vCenters actifs, walk recursif
  des snapshot tree de chaque VM, retourne (vcenter, vm, snap_name, snap_id, vm_moid,
  created_at, age_days, author, description, is_current)
- delete_snapshot(db, vcenter_id, vm_moid, snap_id, remove_children=False): supprime
  un snapshot par moRef, attend la fin de la task vCenter
- Auteur deduit du prefixe du nom (format PatchCenter '<auteur>_YYYY-MM-DD_<suffixe>')

Router /snapshots:
- GET /snapshots: page principale (filtres + table)
- POST /snapshots/list: AJAX scan vCenters, retourne JSON
- POST /snapshots/delete: AJAX suppression batch, double confirmation cote UI

Template snapshots.html:
- Filtres: vCenter, auteur, 'Mes snapshots uniquement' (preselectionne user courant),
  age min en jours (defaut 3)
- Table avec checkboxes, sel-all, badge age (vert <3j, orange 3-7j, rouge >7j)
- Bouton 'Charger/Refresh' (lazy load, eviter scan auto au pageload)
- Bouton 'Supprimer la selection' avec 2 confirmations + liste des snapshots
- Recharge auto apres suppression

Nav: lien '📸 Snapshots VM' ajoute dans le menu Patching.
2026-05-07 20:13:29 +02:00

210 lines
8.1 KiB
Python

"""Service de gestion des snapshots VM existants — listing + suppression.
- list_snapshots(db) : itère les vCenters actifs et retourne tous les snapshots
avec leurs métadonnées (VM, nom snap, créé le, taille, description, créateur).
- delete_snapshot(db, vcenter_id, vm_moid, snapshot_id) : supprime un snapshot
spécifique par son moRef.
Identification de l'auteur : on parse le préfixe du nom du snapshot
(format PatchCenter : `<intervenant>_YYYY-MM-DD_avant_patch`).
"""
import re
import ssl
import logging
from datetime import datetime, timezone
from typing import Dict, Any, List
log = logging.getLogger("patchcenter.snapshot_mgmt")
try:
from pyVim.connect import SmartConnect, Disconnect
from pyVmomi import vim
PYVMOMI_OK = True
except ImportError:
PYVMOMI_OK = False
log.warning("pyvmomi non disponible — listing snapshots impossible")
SNAP_NAME_RE = re.compile(
r"^(?P<author>[A-Za-z0-9_\-\.]+)_(?P<date>\d{4}-\d{2}-\d{2})(?:_(?P<suffix>.+))?$"
)
def _get_vcenter_creds(db):
from .secrets_service import get_secret
user = (get_secret(db, "vcenter_user") or get_secret(db, "vsphere_user") or "").strip()
pwd = (get_secret(db, "vcenter_pass") or get_secret(db, "vsphere_pass") or "").strip()
return user, pwd
def _connect(endpoint, user, password):
try:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return SmartConnect(host=endpoint, user=user, pwd=password, sslContext=ctx)
except Exception as e:
log.warning(f"Connexion vCenter {endpoint} échouée: {e}")
return None
def _walk_snapshots(snapshot_list, vm, vcenter_name, vcenter_id, vm_moid, parent_path=""):
"""Walk récursif de l'arbre des snapshots d'une VM. Yield des dicts."""
for s in snapshot_list:
snap = s.snapshot # ManagedObject (vim.vm.Snapshot)
snap_moid = snap._moId # string ex 'snapshot-1234'
name = s.name or ""
path = f"{parent_path}/{name}" if parent_path else name
# Création (createTime peut être absent dans certaines versions)
created = getattr(s, "createTime", None)
if created is None:
created_iso = None
age_days = None
else:
try:
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
created_iso = created.isoformat()
age_days = (datetime.now(timezone.utc) - created).total_seconds() / 86400.0
except Exception:
created_iso = str(created)
age_days = None
# Auteur déduit du préfixe du nom (format PatchCenter)
author = None
m = SNAP_NAME_RE.match(name)
if m:
author = m.group("author")
yield {
"vcenter_id": vcenter_id,
"vcenter_name": vcenter_name,
"vm_name": vm.name,
"vm_moid": vm_moid,
"snap_id": snap_moid,
"snap_name": name,
"snap_path": path,
"description": s.description or "",
"created_at": created_iso,
"age_days": round(age_days, 2) if age_days is not None else None,
"author": author,
"is_current": bool(getattr(s, "id", None) and vm.snapshot and vm.snapshot.currentSnapshot
and vm.snapshot.currentSnapshot._moId == snap_moid),
}
# Récurse enfants
if s.childSnapshotList:
yield from _walk_snapshots(s.childSnapshotList, vm, vcenter_name, vcenter_id,
vm_moid, parent_path=path)
def list_snapshots(db, vcenter_filter_id: int = None) -> Dict[str, Any]:
"""Itère les vCenters actifs et retourne tous les snapshots existants.
Si `vcenter_filter_id` est fourni, ne scanne que ce vCenter.
Retourne {ok, snapshots: list, errors: list}."""
if not PYVMOMI_OK:
return {"ok": False, "msg": "pyvmomi non disponible côté serveur PatchCenter",
"snapshots": [], "errors": []}
from sqlalchemy import text as sqlt
user, pwd = _get_vcenter_creds(db)
if not user or not pwd:
return {"ok": False, "msg": "Credentials vCenter manquants (Settings > vSphere)",
"snapshots": [], "errors": []}
where = "WHERE is_active = true"
params = {}
if vcenter_filter_id:
where += " AND id = :id"
params["id"] = vcenter_filter_id
vcs = db.execute(sqlt(f"SELECT id, name, endpoint FROM vcenters {where} ORDER BY name"),
params).fetchall()
if not vcs:
return {"ok": False, "msg": "Aucun vCenter actif", "snapshots": [], "errors": []}
all_snaps = []
errors = []
for vc in vcs:
si = _connect(vc.endpoint, user, pwd)
if not si:
errors.append({"vcenter": vc.name, "msg": "Connexion KO"})
continue
try:
content = si.RetrieveContent()
container = content.viewManager.CreateContainerView(
content.rootFolder, [vim.VirtualMachine], True)
try:
for vm in container.view:
try:
if not vm.snapshot or not vm.snapshot.rootSnapshotList:
continue
for snap in _walk_snapshots(vm.snapshot.rootSnapshotList,
vm, vc.name, vc.id, vm._moId):
all_snaps.append(snap)
except Exception as e:
# VM inaccessible / template / config invalide → ignore
log.debug(f"VM scan err on {vc.name}: {e}")
finally:
container.Destroy()
except Exception as e:
errors.append({"vcenter": vc.name, "msg": str(e)[:200]})
finally:
try:
Disconnect(si)
except Exception:
pass
# Tri : plus ancien en haut
all_snaps.sort(key=lambda s: (s.get("age_days") or 0), reverse=True)
return {"ok": True, "snapshots": all_snaps, "errors": errors,
"vcenter_count": len(vcs), "snap_count": len(all_snaps)}
def delete_snapshot(db, vcenter_id: int, vm_moid: str, snap_id: str,
remove_children: bool = False) -> Dict[str, Any]:
"""Supprime un snapshot identifié par (vcenter_id, vm_moid, snap_id).
remove_children=True supprime aussi les snapshots enfants."""
if not PYVMOMI_OK:
return {"ok": False, "msg": "pyvmomi non disponible"}
from sqlalchemy import text as sqlt
vc = db.execute(sqlt("SELECT id, name, endpoint FROM vcenters WHERE id=:id"),
{"id": vcenter_id}).fetchone()
if not vc:
return {"ok": False, "msg": f"vCenter id={vcenter_id} introuvable"}
user, pwd = _get_vcenter_creds(db)
if not user or not pwd:
return {"ok": False, "msg": "Credentials vCenter manquants (Settings > vSphere)"}
si = _connect(vc.endpoint, user, pwd)
if not si:
return {"ok": False, "msg": f"Connexion vCenter {vc.name} KO"}
try:
# On reconstruit le ManagedObject directement par ses moRef
snap_mo = vim.vm.Snapshot(snap_id, si._stub)
task = snap_mo.RemoveSnapshot_Task(removeChildren=remove_children)
# Wait task
from pyVmomi import vim as _vim
while task.info.state in (_vim.TaskInfo.State.queued, _vim.TaskInfo.State.running):
import time as _t
_t.sleep(1)
if task.info.state == _vim.TaskInfo.State.success:
return {"ok": True, "msg": f"Snapshot supprimé sur {vc.name}",
"vcenter": vc.name, "vm_moid": vm_moid, "snap_id": snap_id}
else:
err = task.info.error
err_msg = err.msg if err else "task failed"
return {"ok": False, "msg": f"Échec suppression: {err_msg}",
"vcenter": vc.name}
except Exception as e:
return {"ok": False, "msg": f"Exception: {type(e).__name__}: {e}",
"vcenter": vc.name}
finally:
try:
Disconnect(si)
except Exception:
pass