patchcenter/app/services/quickwin_service.py
Admin MPCZ 677f621c81 Admin applications + correspondance cleanup + tools presentation DSI
- Admin applications: CRUD module (list/add/edit/delete/assign/multi-app)
  avec push iTop bidirectionnel (applications.py + 3 templates)
- Correspondance prod<->hors-prod: migration vers server_correspondance
  globale, suppression ancien code quickwin, ajout filtre environnement
  et solution applicative, colonne environnement dans builder
- Servers page: colonne application_name + equivalent(s) via get_links_bulk,
  filtre application_id, push iTop sur changement application
- Patching: bulk_update_application, bulk_update_excludes, validations
- Fix paramiko sftp.put (remote_path -> positional arg)
- Tools: wiki_to_pdf.py (DokuWiki -> PDF) + generate_ppt.py (PPTX 19 slides
  DSI patching) + contenu source (processus_patching.txt, script_presentation.txt)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:11:58 +02:00

688 lines
30 KiB
Python

"""Service QuickWin — gestion des campagnes + exclusions par serveur"""
import json
from sqlalchemy import text
from .quickwin_log_service import log_info, log_warn, log_error, log_success
# Exclusions generales par defaut (reboot packages + middleware/apps)
DEFAULT_GENERAL_EXCLUDES = (
"dbus* dracut* glibc* grub2* kernel* kexec-tools* "
"libselinux* linux-firmware* microcode_ctl* mokutil* "
"net-snmp* NetworkManager* network-scripts* nss* openssl-libs* "
"polkit* selinux-policy* shim* systemd* tuned*"
)
def get_server_configs(db, server_ids=None):
"""Retourne les configs QuickWin pour les serveurs (ou tous)"""
if server_ids:
rows = db.execute(text("""
SELECT qc.*, s.hostname, s.os_family, s.tier,
d.name as domaine, e.name as environnement,
z.name as zone
FROM quickwin_server_config qc
JOIN servers s ON qc.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE qc.server_id = ANY(:ids)
ORDER BY s.hostname
"""), {"ids": server_ids}).fetchall()
else:
rows = db.execute(text("""
SELECT qc.*, s.hostname, s.os_family, s.tier,
d.name as domaine, e.name as environnement,
z.name as zone
FROM quickwin_server_config qc
JOIN servers s ON qc.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
ORDER BY s.hostname
""")).fetchall()
return rows
def upsert_server_config(db, server_id, general_excludes=None, specific_excludes="", notes=""):
"""Cree ou met a jour la config QuickWin d'un serveur.
Si general_excludes est vide lors de la creation, applique DEFAULT_GENERAL_EXCLUDES."""
existing = db.execute(text(
"SELECT id FROM quickwin_server_config WHERE server_id = :sid"
), {"sid": server_id}).fetchone()
if existing:
ge = general_excludes if general_excludes is not None else DEFAULT_GENERAL_EXCLUDES
db.execute(text("""
UPDATE quickwin_server_config
SET general_excludes = :ge, specific_excludes = :se, notes = :n, updated_at = now()
WHERE server_id = :sid
"""), {"sid": server_id, "ge": ge, "se": specific_excludes, "n": notes})
else:
ge = general_excludes if general_excludes else DEFAULT_GENERAL_EXCLUDES
db.execute(text("""
INSERT INTO quickwin_server_config (server_id, general_excludes, specific_excludes, notes)
VALUES (:sid, :ge, :se, :n)
"""), {"sid": server_id, "ge": ge, "se": specific_excludes, "n": notes})
db.commit()
def delete_server_config(db, config_id):
db.execute(text("DELETE FROM quickwin_server_config WHERE id = :id"), {"id": config_id})
db.commit()
def get_eligible_servers(db):
"""Serveurs Linux production, patch_os_owner=secops"""
return db.execute(text("""
SELECT s.id, s.hostname, s.os_family, s.os_version, s.machine_type,
s.tier, s.etat, s.patch_excludes, s.is_flux_libre, s.is_podman,
d.name as domaine, d.code as domain_code,
e.name as environnement, e.code as env_code,
COALESCE(qc.general_excludes, '') as qw_general_excludes,
COALESCE(qc.specific_excludes, '') as qw_specific_excludes
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
WHERE s.os_family = 'linux'
AND s.etat = 'production'
AND s.patch_os_owner = 'secops'
ORDER BY e.display_order, d.display_order, s.hostname
""")).fetchall()
# -- Runs --
def list_runs(db):
return db.execute(text("""
SELECT r.*,
u.display_name as created_by_name,
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id) as total_entries,
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.status = 'patched') as patched_count,
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.status = 'failed') as failed_count,
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.branch = 'hprod') as hprod_count,
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.branch = 'prod') as prod_count
FROM quickwin_runs r
LEFT JOIN users u ON r.created_by = u.id
ORDER BY r.year DESC, r.week_number DESC, r.id DESC
""")).fetchall()
def get_run(db, run_id):
return db.execute(text("""
SELECT r.*, u.display_name as created_by_name
FROM quickwin_runs r LEFT JOIN users u ON r.created_by = u.id
WHERE r.id = :id
"""), {"id": run_id}).fetchone()
def get_run_entries(db, run_id):
return db.execute(text("""
SELECT qe.*, s.hostname, s.fqdn, s.os_family, s.machine_type,
d.name as domaine, e.name as environnement
FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE qe.run_id = :rid
ORDER BY qe.branch, s.hostname
"""), {"rid": run_id}).fetchall()
def create_run(db, year, week_number, label, user_id, server_ids, notes=""):
"""Cree un run QuickWin avec les serveurs selectionnes.
Classe auto en hprod/prod selon l'environnement du serveur."""
row = db.execute(text("""
INSERT INTO quickwin_runs (year, week_number, label, created_by, notes)
VALUES (:y, :w, :l, :uid, :n) RETURNING id
"""), {"y": year, "w": week_number, "l": label, "uid": user_id, "n": notes}).fetchone()
run_id = row.id
# Lire les reboot packages globaux (source: app_secrets)
from .secrets_service import get_secret
reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES
for sid in server_ids:
srv = db.execute(text("""
SELECT s.id, e.name as env_name, COALESCE(s.patch_excludes, '') as pe
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE s.id = :sid
"""), {"sid": sid}).fetchone()
if not srv:
continue
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
# QuickWin : reboot globaux + exclusions iTop du serveur
db.execute(text("""
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
VALUES (:rid, :sid, :br, :ge, :se)
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe})
db.commit()
return run_id
def delete_run(db, run_id):
db.execute(text("DELETE FROM quickwin_entries WHERE run_id = :rid"), {"rid": run_id})
db.execute(text("DELETE FROM quickwin_runs WHERE id = :rid"), {"rid": run_id})
db.commit()
def get_available_servers(db, run_id, search="", domains=None, envs=None, zones=None):
"""Serveurs eligibles PAS encore dans ce run, avec multi-filtres.
domains/envs/zones: listes de noms (multi-select)."""
rows = db.execute(text("""
SELECT s.id, s.hostname, s.os_family, s.machine_type, s.domain_ltd,
d.name as domaine, e.name as environnement, e.code as env_code,
z.name as zone
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE s.os_family = 'linux'
AND s.etat = 'production'
AND s.patch_os_owner = 'secops'
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
ORDER BY d.name, e.name, s.hostname
"""), {"rid": run_id}).fetchall()
filtered = rows
if search:
filtered = [r for r in filtered if search.lower() in r.hostname.lower()]
if domains:
filtered = [r for r in filtered if (r.domaine or '') in domains]
if envs:
filtered = [r for r in filtered if (r.environnement or '') in envs]
if zones:
filtered = [r for r in filtered if (r.zone or '') in zones]
return filtered
def get_available_filters(db, run_id):
"""Retourne les domaines, envs et zones disponibles (avec compteurs)."""
rows = db.execute(text("""
SELECT d.name as domaine, e.name as environnement, z.name as zone
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE s.os_family = 'linux'
AND s.etat = 'production'
AND s.patch_os_owner = 'secops'
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
"""), {"rid": run_id}).fetchall()
domains = sorted(set(r.domaine for r in rows if r.domaine))
envs = sorted(set(r.environnement for r in rows if r.environnement))
zones = sorted(set(r.zone for r in rows if r.zone))
# Compteurs
dom_counts = {}
for r in rows:
k = r.domaine or '?'
dom_counts[k] = dom_counts.get(k, 0) + 1
env_counts = {}
for r in rows:
k = r.environnement or '?'
env_counts[k] = env_counts.get(k, 0) + 1
zone_counts = {}
for r in rows:
k = r.zone or '?'
zone_counts[k] = zone_counts.get(k, 0) + 1
return domains, envs, zones, dom_counts, env_counts, zone_counts
def add_entries_to_run(db, run_id, server_ids, user=None):
"""Ajoute des serveurs a un run existant. Determine auto hprod/prod.
Retourne le nombre de serveurs ajoutes."""
existing = set(r.server_id for r in db.execute(text(
"SELECT server_id FROM quickwin_entries WHERE run_id = :rid"
), {"rid": run_id}).fetchall())
by = user.get("display_name", user.get("username", "")) if user else ""
from .secrets_service import get_secret
reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES
added = 0
hostnames = []
for sid in server_ids:
if sid in existing:
continue
srv = db.execute(text("""
SELECT s.id, s.hostname, e.name as env_name, COALESCE(s.patch_excludes, '') as pe
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE s.id = :sid
"""), {"sid": sid}).fetchone()
if not srv:
continue
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
db.execute(text("""
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
VALUES (:rid, :sid, :br, :ge, :se)
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe})
added += 1
hostnames.append(srv.hostname)
if added:
log_info(db, run_id, "servers", f"{added} serveur(s) ajoute(s)",
detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""),
created_by=by)
db.commit()
return added
def remove_entries_from_run(db, run_id, entry_ids, user=None):
"""Supprime des entries d'un run. Retourne le nombre de suppressions."""
if not entry_ids:
return 0
by = user.get("display_name", user.get("username", "")) if user else ""
# Log hostnames avant suppression
rows = db.execute(text("""
SELECT s.hostname FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id
WHERE qe.run_id = :rid AND qe.id = ANY(:ids)
"""), {"rid": run_id, "ids": entry_ids}).fetchall()
hostnames = [r.hostname for r in rows]
result = db.execute(text(
"DELETE FROM quickwin_entries WHERE run_id = :rid AND id = ANY(:ids)"
), {"rid": run_id, "ids": entry_ids})
removed = result.rowcount
if removed:
log_warn(db, run_id, "servers", f"{removed} serveur(s) supprime(s)",
detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""),
created_by=by)
db.commit()
return removed
def get_campaign_scope(db, run_id):
"""Retourne domaines, envs et zones presents dans le run avec compteurs."""
rows = db.execute(text("""
SELECT d.name as domaine, e.name as environnement, z.name as zone, qe.status
FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE qe.run_id = :rid
"""), {"rid": run_id}).fetchall()
dom_counts = {}
env_counts = {}
zone_counts = {}
dom_active = {} # non-excluded count
zone_active = {} # non-excluded count
for r in rows:
d = r.domaine or '?'
e = r.environnement or '?'
z = r.zone or '?'
dom_counts[d] = dom_counts.get(d, 0) + 1
env_counts[e] = env_counts.get(e, 0) + 1
zone_counts[z] = zone_counts.get(z, 0) + 1
if r.status != 'excluded':
dom_active[d] = dom_active.get(d, 0) + 1
zone_active[z] = zone_active.get(z, 0) + 1
domains = sorted(k for k in dom_counts if k != '?')
envs = sorted(k for k in env_counts if k != '?')
zones = sorted(k for k in zone_counts if k != '?')
return {
"domains": domains, "envs": envs, "zones": zones,
"dom_counts": dom_counts, "env_counts": env_counts, "zone_counts": zone_counts,
"dom_active": dom_active, "zone_active": zone_active,
}
def apply_scope(db, run_id, keep_domains=None, keep_zones=None, user=None):
"""Applique le perimetre: serveurs hors domaines/zones selectionnes -> excluded.
Serveurs dans le perimetre -> pending (si etaient excluded)."""
by = user.get("display_name", user.get("username", "")) if user else ""
# Recuperer toutes les entries avec leur domaine/zone
entries = db.execute(text("""
SELECT qe.id, qe.status, s.hostname, d.name as domaine, z.name as zone
FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE qe.run_id = :rid
"""), {"rid": run_id}).fetchall()
included = 0
excluded = 0
for e in entries:
in_scope = True
if keep_domains and (e.domaine or '') not in keep_domains:
in_scope = False
if keep_zones and (e.zone or '') not in keep_zones:
in_scope = False
if in_scope and e.status == 'excluded':
db.execute(text("UPDATE quickwin_entries SET status='pending', updated_at=now() WHERE id=:id"),
{"id": e.id})
included += 1
elif not in_scope and e.status != 'excluded':
db.execute(text("UPDATE quickwin_entries SET status='excluded', updated_at=now() WHERE id=:id"),
{"id": e.id})
excluded += 1
scope_desc = []
if keep_domains:
scope_desc.append(f"domaines: {', '.join(keep_domains)}")
if keep_zones:
scope_desc.append(f"zones: {', '.join(keep_zones)}")
log_info(db, run_id, "scope",
f"Perimetre applique: {included} inclus, {excluded} exclus",
detail=" | ".join(scope_desc) if scope_desc else "Tous",
created_by=by)
db.commit()
return included, excluded
def update_entry_status(db, entry_id, status, patch_output="", packages_count=0,
packages="", reboot_required=False, notes=""):
db.execute(text("""
UPDATE quickwin_entries SET
status = :st, patch_output = :po, patch_packages_count = :pc,
patch_packages = :pp, reboot_required = :rb, notes = :n,
patch_date = CASE WHEN :st IN ('patched','failed') THEN now() ELSE patch_date END,
updated_at = now()
WHERE id = :id
"""), {"id": entry_id, "st": status, "po": patch_output, "pc": packages_count,
"pp": packages, "rb": reboot_required, "n": notes})
db.commit()
# Création auto d'une entrée patch_validation (en_attente) pour les serveurs patchés
if status == "patched":
row = db.execute(text("SELECT server_id, run_id FROM quickwin_entries WHERE id=:id"),
{"id": entry_id}).fetchone()
if row:
# Éviter les doublons (même run + même server dans la dernière heure)
existing = db.execute(text("""SELECT id FROM patch_validation
WHERE server_id=:sid AND campaign_id=:cid AND campaign_type='quickwin'
AND patch_date >= NOW() - INTERVAL '1 hour'"""),
{"sid": row.server_id, "cid": row.run_id}).fetchone()
if not existing:
db.execute(text("""INSERT INTO patch_validation (server_id, campaign_id,
campaign_type, patch_date, status)
VALUES (:sid, :cid, 'quickwin', NOW(), 'en_attente')"""),
{"sid": row.server_id, "cid": row.run_id})
db.commit()
def update_entry_field(db, entry_id, field, value):
"""Mise a jour d'un champ unique (pour inline edit)"""
allowed = ("general_excludes", "specific_excludes", "notes", "status",
"snap_done", "prereq_ok", "prereq_detail", "dryrun_output")
if field not in allowed:
return False
db.execute(text(f"UPDATE quickwin_entries SET {field} = :val, updated_at = now() WHERE id = :id"),
{"val": value, "id": entry_id})
db.commit()
return True
def can_start_prod(db, run_id):
"""Verifie que tous les hprod sont termines avant d'autoriser le prod"""
pending = db.execute(text("""
SELECT COUNT(*) as cnt FROM quickwin_entries
WHERE run_id = :rid AND branch = 'hprod' AND status IN ('pending', 'in_progress')
"""), {"rid": run_id}).fetchone()
return pending.cnt == 0
def check_prod_validations(db, run_id):
"""Vérifie que chaque prod du run a ses non-prod liés validés (via server_correspondance + patch_validation).
Retourne (ok, blockers) où blockers = liste [{prod_hostname, nonprod_hostname, status}].
Ignore les prods sans non-prod lié (OK par défaut)."""
rows = db.execute(text("""
SELECT qe.id as entry_id, ps.id as prod_id, ps.hostname as prod_host
FROM quickwin_entries qe JOIN servers ps ON qe.server_id = ps.id
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status NOT IN ('excluded','skipped','patched')
"""), {"rid": run_id}).fetchall()
blockers = []
for r in rows:
corrs = db.execute(text("""SELECT ns.hostname,
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
ORDER BY pv.patch_date DESC LIMIT 1) as last_status
FROM server_correspondance sc JOIN servers ns ON sc.nonprod_server_id = ns.id
WHERE sc.prod_server_id = :pid"""), {"pid": r.prod_id}).fetchall()
for c in corrs:
if c.last_status not in ("validated_ok", "forced"):
blockers.append({"prod_hostname": r.prod_host,
"nonprod_hostname": c.hostname,
"status": c.last_status or "aucun_patching"})
return (len(blockers) == 0), blockers
def get_run_stats(db, run_id):
return db.execute(text("""
SELECT
COUNT(*) FILTER (WHERE status != 'excluded') as total,
COUNT(*) FILTER (WHERE branch = 'hprod' AND status != 'excluded') as hprod_total,
COUNT(*) FILTER (WHERE branch = 'prod' AND status != 'excluded') as prod_total,
COUNT(*) FILTER (WHERE status = 'patched') as patched,
COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) FILTER (WHERE status = 'pending') as pending,
COUNT(*) FILTER (WHERE status = 'pending' AND branch = 'hprod') as hprod_pending,
COUNT(*) FILTER (WHERE status = 'excluded') as excluded,
COUNT(*) FILTER (WHERE status = 'skipped') as skipped,
COUNT(*) FILTER (WHERE branch = 'hprod' AND status = 'patched') as hprod_patched,
COUNT(*) FILTER (WHERE branch = 'prod' AND status = 'patched') as prod_patched,
COUNT(*) FILTER (WHERE reboot_required AND status != 'excluded') as reboot_count
FROM quickwin_entries WHERE run_id = :rid
"""), {"rid": run_id}).fetchone()
def advance_run_status(db, run_id, target_status, user=None):
"""Avance le statut du run vers l'etape suivante"""
VALID_TRANSITIONS = {
"draft": "prereq",
"prereq": "snapshot",
"snapshot": "patching",
"patching": "result",
"result": "completed",
}
run = db.execute(text("SELECT status FROM quickwin_runs WHERE id = :id"),
{"id": run_id}).fetchone()
if not run:
return False
by = user.get("display_name", user.get("username", "")) if user else ""
if target_status == "draft":
db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"),
{"st": target_status, "id": run_id})
log_info(db, run_id, "workflow", f"Retour a l'etape brouillon", created_by=by)
db.commit()
return True
expected = VALID_TRANSITIONS.get(run.status)
if expected != target_status:
log_warn(db, run_id, "workflow",
f"Transition refusee: {run.status} -> {target_status}", created_by=by)
db.commit()
return False
db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"),
{"st": target_status, "id": run_id})
log_info(db, run_id, "workflow", f"Passage a l'etape: {target_status}", created_by=by)
db.commit()
return True
def get_step_stats(db, run_id, branch=None):
"""Stats par etape pour un run (optionnel: filtre par branch)"""
where = "run_id = :rid"
params = {"rid": run_id}
if branch:
where += " AND branch = :br"
params["br"] = branch
return db.execute(text(f"""
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status NOT IN ('excluded','skipped')) as active,
COUNT(*) FILTER (WHERE prereq_ok = true) as prereq_ok,
COUNT(*) FILTER (WHERE prereq_ok = false) as prereq_ko,
COUNT(*) FILTER (WHERE prereq_ok IS NULL AND status NOT IN ('excluded','skipped')) as prereq_pending,
COUNT(*) FILTER (WHERE snap_done = true) as snap_ok,
COUNT(*) FILTER (WHERE snap_done = false AND status NOT IN ('excluded','skipped')) as snap_pending,
COUNT(*) FILTER (WHERE status = 'patched') as patched,
COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) FILTER (WHERE status = 'pending') as pending,
COUNT(*) FILTER (WHERE reboot_required = true) as reboot_count
FROM quickwin_entries WHERE {where}
"""), params).fetchone()
def check_prereqs(db, run_id, branch, user=None):
"""Lance les verifications prerequis pour tous les serveurs d'une branche.
Etapes par serveur: DNS resolution, SSH, espace disque, satellite."""
from .quickwin_prereq_service import check_server_prereqs
entries = db.execute(text("""
SELECT qe.id, s.hostname, s.domain_ltd, s.ssh_method,
e.code as env_code
FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE qe.run_id = :rid AND qe.branch = :br
AND qe.status NOT IN ('excluded','skipped')
ORDER BY s.hostname
"""), {"rid": run_id, "br": branch}).fetchall()
by = user.get("display_name", user.get("username", "")) if user else ""
log_info(db, run_id, "prereq",
f"Lancement check prerequis {branch} ({len(entries)} serveurs)", created_by=by)
results = []
ok_count = 0
for e in entries:
try:
r = check_server_prereqs(
hostname=e.hostname,
db=db,
domain_ltd=e.domain_ltd,
env_code=e.env_code,
ssh_method=e.ssh_method or "ssh_key",
)
except Exception as ex:
r = {"dns_ok": False, "ssh_ok": False, "satellite_ok": False,
"disk_ok": False, "detail": str(ex), "skip": True}
prereq_ok = (r.get("dns_ok", False) and r.get("ssh_ok", False)
and r.get("satellite_ok", False) and r.get("disk_ok", True))
db.execute(text("""
UPDATE quickwin_entries SET
prereq_ok = :ok, prereq_ssh = :ssh, prereq_satellite = :sat, prereq_disk = :disk,
prereq_detail = :detail, prereq_date = now(), updated_at = now()
WHERE id = :id
"""), {
"id": e.id, "ok": prereq_ok,
"ssh": r.get("ssh_ok", False), "sat": r.get("satellite_ok", False),
"disk": r.get("disk_ok", True), "detail": r.get("detail", ""),
})
# Log par serveur
detail_str = r.get("detail", "")
if prereq_ok:
ok_count += 1
log_success(db, run_id, "prereq", f"OK: {e.hostname}",
detail=detail_str, entry_id=e.id, hostname=e.hostname)
else:
log_error(db, run_id, "prereq", f"KO: {e.hostname}",
detail=detail_str, entry_id=e.id, hostname=e.hostname)
results.append({"hostname": e.hostname, "ok": prereq_ok, "detail": detail_str})
ko_count = len(results) - ok_count
log_info(db, run_id, "prereq",
f"Fin check {branch}: {ok_count} OK, {ko_count} KO sur {len(results)}", created_by=by)
db.commit()
return results
def mark_snapshot(db, entry_id, done=True):
"""Marque un serveur comme snapshot fait/pas fait"""
db.execute(text("""
UPDATE quickwin_entries SET snap_done = :done,
snap_date = CASE WHEN :done THEN now() ELSE NULL END,
updated_at = now() WHERE id = :id
"""), {"id": entry_id, "done": done})
db.commit()
def mark_all_snapshots(db, run_id, branch, done=True):
"""Marque tous les serveurs d'une branche comme snapshot fait"""
db.execute(text("""
UPDATE quickwin_entries SET snap_done = :done,
snap_date = CASE WHEN :done THEN now() ELSE NULL END,
updated_at = now()
WHERE run_id = :rid AND branch = :br AND status NOT IN ('excluded','skipped')
"""), {"rid": run_id, "br": branch, "done": done})
db.commit()
def build_yum_commands(db, run_id, branch):
"""Construit les commandes yum pour chaque serveur d'une branche.
Inclut tous les serveurs actifs (non excluded/skipped) avec snap fait."""
entries = db.execute(text("""
SELECT qe.id, s.hostname, qe.general_excludes, qe.specific_excludes
FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id
WHERE qe.run_id = :rid AND qe.branch = :br
AND qe.status NOT IN ('excluded','skipped')
AND qe.snap_done = true
ORDER BY s.hostname
"""), {"rid": run_id, "br": branch}).fetchall()
commands = []
for e in entries:
excludes = (e.general_excludes or "") + " " + (e.specific_excludes or "")
parts = [p.strip() for p in excludes.split() if p.strip()]
exclude_args = " ".join(f"--exclude={p}" for p in parts)
cmd = f"yum update -y {exclude_args}".strip()
db.execute(text("UPDATE quickwin_entries SET patch_command = :cmd WHERE id = :id"),
{"cmd": cmd, "id": e.id})
commands.append({"id": e.id, "hostname": e.hostname, "command": cmd})
db.commit()
return commands
def inject_yum_history(db, data):
"""Injecte l'historique yum dans quickwin_server_config.
data = [{"server": "hostname", "yum_commands": [...]}]"""
updated = 0
inserted = 0
for item in data:
hostname = item.get("server", item.get("server_name", "")).strip()
if not hostname:
continue
srv = db.execute(text("SELECT id FROM servers WHERE hostname = :h"), {"h": hostname}).fetchone()
if not srv:
continue
cmds = json.dumps(item.get("yum_commands", item.get("last_yum_commands", [])), ensure_ascii=False)
existing = db.execute(text(
"SELECT id FROM quickwin_server_config WHERE server_id = :sid"
), {"sid": srv.id}).fetchone()
if existing:
db.execute(text("""
UPDATE quickwin_server_config SET last_yum_commands = :cmds::jsonb, updated_at = now()
WHERE server_id = :sid
"""), {"sid": srv.id, "cmds": cmds})
updated += 1
else:
db.execute(text("""
INSERT INTO quickwin_server_config (server_id, last_yum_commands)
VALUES (:sid, :cmds::jsonb)
"""), {"sid": srv.id, "cmds": cmds})
inserted += 1
db.commit()
return updated, inserted
# Correspondance HPROD ↔ PROD : logique déplacée vers server_correspondance (global)
# Les fonctions obsolètes ont été supprimées : compute_correspondance, get_correspondance,
# get_available_prod_entries, set_prod_pair, clear_all_pairs.
# La colonne prod_pair_entry_id de quickwin_entries est laissée en place pour compatibilité
# mais n'est plus utilisée. Les liens sont désormais dans server_correspondance.