"""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 ========== def compute_correspondance(db, run_id, user=None): """Auto-apparie chaque serveur hprod avec son homologue prod (2e lettre → p). Retourne (matched, unmatched, anomalies).""" by = user.get("display_name", user.get("username", "")) if user else "" hprod_rows = db.execute(text(""" SELECT qe.id, LOWER(s.hostname) as hostname FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id WHERE qe.run_id = :rid AND qe.branch = 'hprod' AND qe.status != 'excluded' """), {"rid": run_id}).fetchall() prod_rows = db.execute(text(""" SELECT qe.id, LOWER(s.hostname) as hostname FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded' """), {"rid": run_id}).fetchall() prod_by_host = {r.hostname: r.id for r in prod_rows} matched = 0 unmatched = 0 anomalies = 0 skipped = 0 # Existing pairs — ne pas toucher existing = {r.id for r in db.execute(text(""" SELECT id FROM quickwin_entries WHERE run_id = :rid AND branch = 'hprod' AND prod_pair_entry_id IS NOT NULL """), {"rid": run_id}).fetchall()} for h in hprod_rows: if h.id in existing: skipped += 1 continue if len(h.hostname) < 2: unmatched += 1 continue candidate = h.hostname[0] + 'p' + h.hostname[2:] if candidate == h.hostname: anomalies += 1 if candidate in prod_by_host: db.execute(text(""" UPDATE quickwin_entries SET prod_pair_entry_id = :pid WHERE id = :hid """), {"pid": prod_by_host[candidate], "hid": h.id}) matched += 1 else: unmatched += 1 log_info(db, run_id, "correspondance", f"Auto-appariement: {matched} nouveaux, {skipped} conservés, {unmatched} sans homologue, {anomalies} anomalies", created_by=by) db.commit() return matched, unmatched, anomalies def get_correspondance(db, run_id, search=None, pair_filter=None, env_filter=None, domain_filter=None): """Retourne la liste des hprod avec leur homologue prod (ou NULL).""" rows = db.execute(text(""" SELECT hp.id as hprod_id, sh.hostname as hprod_hostname, dh.name as hprod_domaine, eh.name as hprod_env, SUBSTRING(LOWER(sh.hostname), 2, 1) as letter2, hp.prod_pair_entry_id, pp.id as prod_id, sp.hostname as prod_hostname, dp.name as prod_domaine FROM quickwin_entries hp JOIN servers sh ON hp.server_id = sh.id LEFT JOIN domain_environments deh ON sh.domain_env_id = deh.id LEFT JOIN domains dh ON deh.domain_id = dh.id LEFT JOIN environments eh ON deh.environment_id = eh.id LEFT JOIN quickwin_entries pp ON hp.prod_pair_entry_id = pp.id LEFT JOIN servers sp ON pp.server_id = sp.id LEFT JOIN domain_environments dep ON sp.domain_env_id = dep.id LEFT JOIN domains dp ON dep.domain_id = dp.id WHERE hp.run_id = :rid AND hp.branch = 'hprod' AND hp.status != 'excluded' ORDER BY sh.hostname """), {"rid": run_id}).fetchall() result = [] for r in rows: candidate = "" if len(r.hprod_hostname) >= 2: candidate = r.hprod_hostname[0] + 'p' + r.hprod_hostname[2:] is_anomaly = (r.letter2 == 'p') is_matched = r.prod_pair_entry_id is not None if pair_filter == "matched" and not is_matched: continue if pair_filter == "unmatched" and is_matched: continue if pair_filter == "anomaly" and not is_anomaly: continue if env_filter: env_map = {"preprod": "i", "recette": "r", "dev": "d", "test": "vt"} allowed_letters = env_map.get(env_filter, "") if r.letter2 not in allowed_letters: continue if domain_filter and (r.hprod_domaine or '') != domain_filter: continue if search and search.lower() not in r.hprod_hostname.lower(): if not (r.prod_hostname and search.lower() in r.prod_hostname.lower()): continue result.append({ "hprod_id": r.hprod_id, "hprod_hostname": r.hprod_hostname, "hprod_domaine": r.hprod_domaine or "", "hprod_env": r.hprod_env or "", "letter2": r.letter2, "candidate": candidate, "is_anomaly": is_anomaly, "prod_id": r.prod_id, "prod_hostname": r.prod_hostname or "", "prod_domaine": r.prod_domaine or "", "is_matched": is_matched, }) return result def get_available_prod_entries(db, run_id): """Retourne toutes les entries prod (un prod peut etre apparie a plusieurs hprod).""" return db.execute(text(""" SELECT qe.id, s.hostname, d.name as domaine 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 WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded' ORDER BY s.hostname """), {"rid": run_id}).fetchall() def set_prod_pair(db, hprod_entry_id, prod_entry_id): """Associe manuellement un hprod à un prod (ou NULL pour dissocier).""" pid = prod_entry_id if prod_entry_id else None db.execute(text(""" UPDATE quickwin_entries SET prod_pair_entry_id = :pid, updated_at = now() WHERE id = :hid """), {"pid": pid, "hid": hprod_entry_id}) db.commit() def clear_all_pairs(db, run_id): """Supprime tous les appariements d'un run.""" db.execute(text(""" UPDATE quickwin_entries SET prod_pair_entry_id = NULL, updated_at = now() WHERE run_id = :rid AND branch = 'hprod' """), {"rid": run_id}) db.commit()