Add ssh_system_update.py
This commit is contained in:
375
ssh_system_update.py
Normal file
375
ssh_system_update.py
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SSH Updater
|
||||||
|
===========
|
||||||
|
|
||||||
|
Czyta listę hostów z pliku INI i przez SSH:
|
||||||
|
1) wykrywa dystrybucję Linuksa,
|
||||||
|
2) wykonuje update/upgrade odpowiednim menedżerem pakietów,
|
||||||
|
3) czytelnie pokazuje postęp,
|
||||||
|
4) sprząta po aktualizacji (np. apt-get clean),
|
||||||
|
5) opcjonalnie działa równolegle na wielu hostach (--parallel).
|
||||||
|
|
||||||
|
Wspierane: Debian/Ubuntu (apt), openSUSE (zypper), Arch Linux (pacman), RHEL/CentOS/Alma/Rocky (dnf/yum).
|
||||||
|
|
||||||
|
Przykładowy plik hosts.ini:
|
||||||
|
|
||||||
|
[global] ; domyślne ustawienia dla wszystkich hostów (opcjonalne)
|
||||||
|
user = root
|
||||||
|
port = 22
|
||||||
|
password = supersecret
|
||||||
|
keyfile = /home/me/.ssh/id_rsa
|
||||||
|
use_sudo = false
|
||||||
|
|
||||||
|
[server1]
|
||||||
|
host = 192.0.2.10
|
||||||
|
user = ubuntu
|
||||||
|
port = 22
|
||||||
|
keyfile = /home/me/.ssh/id_rsa
|
||||||
|
use_sudo = true
|
||||||
|
|
||||||
|
[server2]
|
||||||
|
host = example.org ; odziedziczy user/port/hasło z [global], chyba że nadpiszesz je poniżej
|
||||||
|
|
||||||
|
Uwaga: możesz pominąć "password" i skorzystać z kluczy SSH. Jeśli
|
||||||
|
"use_sudo = true" i konto nie ma NOPASSWD, podaj --ask-sudo przy uruchomieniu.
|
||||||
|
|
||||||
|
Uruchomienie:
|
||||||
|
python ssh_updater.py --hosts hosts.ini --ask-sudo
|
||||||
|
|
||||||
|
Równoległość (np. 8 jednocześnie):
|
||||||
|
python ssh_updater.py --hosts hosts.ini --parallel 8
|
||||||
|
|
||||||
|
Zasada dziedziczenia:
|
||||||
|
- wartości z sekcji hosta **nadpisują** globalne, ale tylko jeśli są **podane i niepuste**,
|
||||||
|
- pusta wartość (np. `port =`) oznacza „użyj globalnej”.
|
||||||
|
|
||||||
|
Wymagania:
|
||||||
|
pip install paramiko rich
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import configparser
|
||||||
|
import getpass
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
SUPPORTED_IDS = {
|
||||||
|
"debian": "apt",
|
||||||
|
"ubuntu": "apt",
|
||||||
|
"opensuse": "zypper",
|
||||||
|
"opensuse-leap": "zypper",
|
||||||
|
"opensuse-tumbleweed": "zypper",
|
||||||
|
"sles": "zypper",
|
||||||
|
"arch": "pacman",
|
||||||
|
"rhel": "dnf",
|
||||||
|
"centos": "dnf",
|
||||||
|
"rocky": "dnf",
|
||||||
|
"almalinux": "dnf",
|
||||||
|
}
|
||||||
|
|
||||||
|
PKG_COMMANDS = {
|
||||||
|
"apt": [
|
||||||
|
"apt-get update",
|
||||||
|
"DEBIAN_FRONTEND=noninteractive apt-get -y upgrade",
|
||||||
|
"DEBIAN_FRONTEND=noninteractive apt-get -y autoremove",
|
||||||
|
"apt-get clean",
|
||||||
|
],
|
||||||
|
"zypper": [
|
||||||
|
"zypper --non-interactive refresh",
|
||||||
|
"zypper --non-interactive update",
|
||||||
|
"zypper clean --all",
|
||||||
|
],
|
||||||
|
"pacman": [
|
||||||
|
"pacman -Syu --noconfirm",
|
||||||
|
"pacman -Sc --noconfirm",
|
||||||
|
],
|
||||||
|
"dnf": [
|
||||||
|
"dnf -y update",
|
||||||
|
"dnf -y autoremove",
|
||||||
|
"dnf clean all",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Host:
|
||||||
|
name: str
|
||||||
|
host: str
|
||||||
|
user: str
|
||||||
|
port: int = 22
|
||||||
|
keyfile: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
use_sudo: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hosts(path: Path) -> list[Host]:
|
||||||
|
cfg = configparser.ConfigParser()
|
||||||
|
with path.open() as f:
|
||||||
|
cfg.read_file(f)
|
||||||
|
|
||||||
|
def _get_default(key: str, fallback: Optional[str] = None) -> Optional[str]:
|
||||||
|
for sect in ("global", "ssh"):
|
||||||
|
if cfg.has_section(sect) and cfg.has_option(sect, key):
|
||||||
|
val = cfg.get(sect, key)
|
||||||
|
if val is not None and str(val).strip() != "":
|
||||||
|
return val
|
||||||
|
val = cfg.defaults().get(key, fallback)
|
||||||
|
if val is not None and str(val).strip() == "":
|
||||||
|
return fallback
|
||||||
|
return val
|
||||||
|
|
||||||
|
def _get_opt(section: str, key: str) -> Optional[str]:
|
||||||
|
if cfg.has_option(section, key):
|
||||||
|
val = cfg.get(section, key)
|
||||||
|
if val is not None and str(val).strip() != "":
|
||||||
|
return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
global_user = _get_default("user", fallback="root")
|
||||||
|
try:
|
||||||
|
global_port = int(_get_default("port", fallback="22"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
global_port = 22
|
||||||
|
global_keyfile = _get_default("keyfile")
|
||||||
|
global_password = _get_default("password")
|
||||||
|
global_use_sudo_str = _get_default("use_sudo", fallback="true") or "true"
|
||||||
|
global_use_sudo = str(global_use_sudo_str).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
hosts: list[Host] = []
|
||||||
|
for section in cfg.sections():
|
||||||
|
if section.lower() in {"ssh", "global", "defaults"}:
|
||||||
|
continue
|
||||||
|
host_addr = cfg.get(section, "host")
|
||||||
|
|
||||||
|
user = _get_opt(section, "user") or global_user
|
||||||
|
|
||||||
|
port_opt = _get_opt(section, "port")
|
||||||
|
if port_opt is not None:
|
||||||
|
try:
|
||||||
|
port = int(port_opt)
|
||||||
|
except ValueError:
|
||||||
|
port = global_port
|
||||||
|
else:
|
||||||
|
port = global_port
|
||||||
|
|
||||||
|
keyfile = _get_opt(section, "keyfile") or global_keyfile
|
||||||
|
password = _get_opt(section, "password") or global_password
|
||||||
|
|
||||||
|
use_sudo_opt = _get_opt(section, "use_sudo")
|
||||||
|
if use_sudo_opt is not None:
|
||||||
|
use_sudo = str(use_sudo_opt).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
else:
|
||||||
|
use_sudo = global_use_sudo
|
||||||
|
|
||||||
|
hosts.append(Host(section, host_addr, user, port, keyfile, password, use_sudo))
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
def connect(h: Host) -> paramiko.SSHClient:
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
h.host,
|
||||||
|
port=h.port,
|
||||||
|
username=h.user,
|
||||||
|
key_filename=h.keyfile,
|
||||||
|
password=h.password,
|
||||||
|
look_for_keys=True,
|
||||||
|
allow_agent=True,
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"[{h.name}] Błąd połączenia: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def run(client: paramiko.SSHClient, command: str, sudo_password: Optional[str] = None) -> Tuple[int, str, str]:
|
||||||
|
"""Uruchom zdalne polecenie. Jeśli w komendzie jest 'sudo', a podano hasło, wyślij je na stdin."""
|
||||||
|
stdin, stdout, stderr = client.exec_command(command, get_pty=True)
|
||||||
|
if sudo_password and re.search(r"\\bsudo\\b", command):
|
||||||
|
try:
|
||||||
|
stdin.write(sudo_password + "\n")
|
||||||
|
stdin.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
exit_status = stdout.channel.recv_exit_status()
|
||||||
|
out = stdout.read().decode(errors="ignore")
|
||||||
|
err = stderr.read().decode(errors="ignore")
|
||||||
|
return exit_status, out, err
|
||||||
|
|
||||||
|
|
||||||
|
def has_sudo(client: paramiko.SSHClient) -> bool:
|
||||||
|
code, out, _ = run(client, "command -v sudo >/dev/null 2>&1; echo $?")
|
||||||
|
try:
|
||||||
|
return int(out.strip().splitlines()[-1]) == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def detect_os(client: paramiko.SSHClient) -> Tuple[str, str]:
|
||||||
|
code, out, err = run(client, "cat /etc/os-release || uname -a")
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(f"Nie można odczytać /etc/os-release: {err}")
|
||||||
|
os_id = ""
|
||||||
|
pretty = ""
|
||||||
|
for line in out.splitlines():
|
||||||
|
if line.startswith("ID="):
|
||||||
|
os_id = line.split("=", 1)[1].strip().strip('"')
|
||||||
|
if line.startswith("PRETTY_NAME="):
|
||||||
|
pretty = line.split("=", 1)[1].strip().strip('"')
|
||||||
|
if not os_id:
|
||||||
|
for line in out.splitlines():
|
||||||
|
if line.startswith("NAME="):
|
||||||
|
os_id = line.split("=", 1)[1].strip().strip('"').lower()
|
||||||
|
if not pretty:
|
||||||
|
pretty = os_id
|
||||||
|
return os_id, pretty
|
||||||
|
|
||||||
|
|
||||||
|
def pkg_manager(os_id: str) -> Optional[str]:
|
||||||
|
os_id = os_id.lower()
|
||||||
|
if os_id in SUPPORTED_IDS:
|
||||||
|
return SUPPORTED_IDS[os_id]
|
||||||
|
if any(k in os_id for k in ["debian", "ubuntu"]):
|
||||||
|
return "apt"
|
||||||
|
if "suse" in os_id:
|
||||||
|
return "zypper"
|
||||||
|
if "arch" in os_id:
|
||||||
|
return "pacman"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_host(h: Host, ask_sudo_pw: Optional[str]) -> Tuple[str, str]:
|
||||||
|
"""Zwraca (status, message)."""
|
||||||
|
try:
|
||||||
|
client = connect(h)
|
||||||
|
except Exception as e:
|
||||||
|
return ("error", str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
os_id, pretty = detect_os(client)
|
||||||
|
mgr = pkg_manager(os_id)
|
||||||
|
if not mgr:
|
||||||
|
return ("skip", f"[{h.name}] Nieobsługiwany system: {pretty} (ID: {os_id})")
|
||||||
|
|
||||||
|
sudo_available = has_sudo(client)
|
||||||
|
effective_use_sudo = h.use_sudo and h.user != "root" and sudo_available
|
||||||
|
if h.use_sudo and h.user != "root" and not sudo_available:
|
||||||
|
return ("error", f"[{h.name}] Brak polecenia sudo, a użytkownik {h.user} nie jest root. Ustaw use_sudo=false lub użyj konta root.")
|
||||||
|
|
||||||
|
sudo_pw = ask_sudo_pw if effective_use_sudo else None
|
||||||
|
|
||||||
|
steps = PKG_COMMANDS[mgr]
|
||||||
|
for step in steps:
|
||||||
|
cmd = f"sudo -S -p '' {step}" if effective_use_sudo else step
|
||||||
|
code, out, err = run(client, cmd, sudo_password=sudo_pw)
|
||||||
|
if code != 0:
|
||||||
|
return ("error", f"[{h.name}] Błąd przy komendzie: {cmd}\n{err or out}")
|
||||||
|
return ("ok", f"[{h.name}] Zaktualizowano: {pretty} ({mgr})")
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="SSH updater dla wielu hostów z pliku INI")
|
||||||
|
parser.add_argument("--hosts", required=True, help="Ścieżka do pliku INI z hostami")
|
||||||
|
parser.add_argument("--ask-pass", action="store_true", help="Zapytaj o hasło SSH (dla hostów bez klucza)")
|
||||||
|
parser.add_argument("--ask-sudo", action="store_true", help="Zapytaj o hasło do sudo (używane gdy user != root)")
|
||||||
|
parser.add_argument("--parallel", type=int, default=1, help="Liczba hostów aktualizowanych równolegle (domyślnie 1)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
hosts = parse_hosts(Path(args.hosts))
|
||||||
|
if not hosts:
|
||||||
|
console.print("[red]Brak hostów w pliku.[/red]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
ssh_pw: Optional[str] = None
|
||||||
|
sudo_pw: Optional[str] = None
|
||||||
|
if args.ask_pass:
|
||||||
|
ssh_pw = getpass.getpass("Hasło SSH: ")
|
||||||
|
for h in hosts:
|
||||||
|
if h.password is None:
|
||||||
|
h.password = ssh_pw
|
||||||
|
if args.ask_sudo:
|
||||||
|
sudo_pw = getpass.getpass("Hasło sudo: ")
|
||||||
|
|
||||||
|
# (inicjalizacja hostów i haseł została przeniesiona powyżej)
|
||||||
|
|
||||||
|
table = Table(title="Aktualizacja systemów przez SSH", show_lines=False)
|
||||||
|
table.add_column("Host", style="bold")
|
||||||
|
table.add_column("Adres")
|
||||||
|
table.add_column("Użytkownik")
|
||||||
|
table.add_column("Sudo")
|
||||||
|
for h in hosts:
|
||||||
|
table.add_row(h.name, h.host, h.user, "tak" if h.use_sudo else "nie")
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
results: list[Tuple[str, str]] = []
|
||||||
|
|
||||||
|
with Progress(
|
||||||
|
SpinnerColumn(),
|
||||||
|
TextColumn("[progress.description]{task.description}"),
|
||||||
|
BarColumn(),
|
||||||
|
TimeElapsedColumn(),
|
||||||
|
transient=True,
|
||||||
|
) as progress:
|
||||||
|
task = progress.add_task("Praca…", total=len(hosts))
|
||||||
|
|
||||||
|
if args.parallel <= 1:
|
||||||
|
# sekwencyjnie
|
||||||
|
for h in hosts:
|
||||||
|
progress.update(task, description=f"[bold]{h.name}[/bold]: łączenie i aktualizacja…")
|
||||||
|
status, msg = upgrade_host(h, sudo_pw)
|
||||||
|
results.append((status, msg))
|
||||||
|
progress.advance(task)
|
||||||
|
else:
|
||||||
|
# równolegle
|
||||||
|
max_workers = max(1, args.parallel)
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as ex:
|
||||||
|
future_map = {ex.submit(upgrade_host, h, sudo_pw): h for h in hosts}
|
||||||
|
for fut in as_completed(future_map):
|
||||||
|
h = future_map[fut]
|
||||||
|
progress.update(task, description=f"[bold]{h.name}[/bold]: łączenie i aktualizacja…")
|
||||||
|
try:
|
||||||
|
status, msg = fut.result()
|
||||||
|
except Exception as e:
|
||||||
|
status, msg = ("error", f"[{h.name}] Wyjątek: {e}")
|
||||||
|
results.append((status, msg))
|
||||||
|
progress.advance(task)
|
||||||
|
|
||||||
|
# Podsumowanie
|
||||||
|
ok = sum(1 for s, _ in results if s == "ok")
|
||||||
|
skip = sum(1 for s, _ in results if s == "skip")
|
||||||
|
err = [m for s, m in results if s == "error"]
|
||||||
|
|
||||||
|
summary = Table(title="Podsumowanie", show_lines=False)
|
||||||
|
summary.add_column("OK")
|
||||||
|
summary.add_column("Pominięte")
|
||||||
|
summary.add_column("Błędy")
|
||||||
|
summary.add_row(str(ok), str(skip), str(len(err)))
|
||||||
|
console.print(summary)
|
||||||
|
|
||||||
|
if err:
|
||||||
|
console.print("[red]Błędy:[/red]")
|
||||||
|
for m in err:
|
||||||
|
console.print(f"• {m}")
|
||||||
|
|
||||||
|
sys.exit(0 if not err else 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
Reference in New Issue
Block a user