"""SSH / rsync vers les nœuds via le LAN 192.168.2.x (le SSH inter-nœuds Tailscale est bloqué par ACL).""" import os import shlex import subprocess from . import config SSH_OPTS = [ "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", "ServerAliveInterval=15", "-i", config.SSH_KEY, ] # PATH complet sur le nœud (les shells non interactifs n'ont pas Homebrew) REMOTE_PATH = "export PATH=/opt/homebrew/bin:/opt/homebrew/sbin:$HOME/bin:$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; " def target(ip): return "%s@%s" % (config.USER, ip) def run(ip, cmd, timeout=120, stdin=None, path=True): """Exécute `cmd` (bash) sur le nœud. Retourne (rc, stdout, stderr).""" full = (REMOTE_PATH if path else "") + cmd try: p = subprocess.run(["ssh"] + SSH_OPTS + [target(ip), "bash -s"], input=full if stdin is None else stdin, capture_output=True, text=True, timeout=timeout) return p.returncode, p.stdout, p.stderr except subprocess.TimeoutExpired: return 124, "", "timeout after %ss" % timeout def run_script(ip, script, timeout=300): """Envoie un script complet sur stdin de `bash -s`.""" return run(ip, "", timeout=timeout, stdin=REMOTE_PATH + "\n" + script) def ok(ip, cmd, timeout=60): return run(ip, cmd, timeout)[0] == 0 def rsync_push(local_dir, ip, remote_dir, excludes=(), delete=True, timeout=7200, extra=()): """Passerelle -> nœud. remote_dir peut contenir ~ (résolu côté nœud).""" args = ["rsync", "-a", "--stats", "-e", "ssh " + " ".join(shlex.quote(o) for o in SSH_OPTS)] if delete: args.append("--delete") for e in excludes: args += ["--exclude", e] args += list(extra) args += ["--rsync-path", "mkdir -p %s && rsync" % shlex.quote(remote_dir), local_dir.rstrip("/") + "/", "%s:%s/" % (target(ip), remote_dir)] p = subprocess.run(args, capture_output=True, text=True, timeout=timeout) return p.returncode, p.stdout, p.stderr def rsync_pull(ip, remote_dir, local_dir, excludes=(), delete=True, timeout=7200): """Nœud -> passerelle (staging).""" os.makedirs(local_dir, exist_ok=True) args = ["rsync", "-a", "--stats", "-e", "ssh " + " ".join(shlex.quote(o) for o in SSH_OPTS)] if delete: args.append("--delete") for e in excludes: args += ["--exclude", e] args += ["%s:%s/" % (target(ip), remote_dir), local_dir.rstrip("/") + "/"] p = subprocess.run(args, capture_output=True, text=True, timeout=timeout) return p.returncode, p.stdout, p.stderr def scp_to(ip, local_file, remote_path): p = subprocess.run(["scp"] + SSH_OPTS + [local_file, "%s:%s" % (target(ip), remote_path)], capture_output=True, text=True) return p.returncode == 0 def write_remote_file(ip, remote_path, content, mode="644"): """Écrit un fichier sur le nœud via heredoc (contenu texte).""" q = shlex.quote(remote_path) script = "mkdir -p $(dirname %s) && cat > %s <<'__MLD_EOF__'\n%s\n__MLD_EOF__\nchmod %s %s" % (q, q, content, mode, q) return run(ip, script, timeout=60)