SPB Git forge
29commits 1branches 0releases
684.0 KBsize
maindefault branch
3 days agolast push
Python 95.8% Shell 4.2%

nœuds hors LAN (REMOTE_NODES) : M1M64 Mac Studio M1 Max loué, user simon, IP publique ; user_of/home_of/sudo_pw_file par nœud ; discover direct ; réservé (--node)

Simon-Pierre Boucher committed 16 days ago (Sep 8, 2026) parent 575d5c3

8 changed files +90 −39

modified README.md +7 −0
@@ -75,6 +75,13 @@ Personne ne code plus d'emplacement en dur : les consommateurs lisent le registr
75 75 2. `echo <alias> > ~/.maclustr-node` sur le nœud ; ajouter l'alias dans `mld/config.py` (`NODES`).
76 76 3. `mld discover` → `mld bootstrap <alias>` (si Mac vierge) → `mld prepare <alias>`.
77 77
78 +Nœud **hors LAN** (IP publique, autre utilisateur — ex. M1M64, Mac Studio loué, 2026-09-08) : le déclarer aussi
79 +dans `REMOTE_NODES` (`host`, `user`, `sudo_pw_file`) ; `discover` s'y connecte directement au lieu de balayer
80 +192.168.2.x, `ssh.target`/`home_of`/`sudo_pw_file` suivent l'utilisateur du nœud. Ces nœuds sont `RESERVED`
81 +(pas de LAN vers les apps sœurs, pas de gardiens Ka) : déploiement seulement avec `--node`. Si le sshd du
82 +fournisseur a `AllowUsers` (ex. `/etc/ssh/sshd_config.d/100-rental.conf`), ajouter un fichier
83 +`000-maclustr.conf` avec `AllowUsers <user>` (les directives se cumulent).
84 +
78 85 ## Fichiers
79 86
80 87 - `mld/config.py` inventaire statique, rôles, chemins, détection des runtimes
modified mld/config.py +34 −1
@@ -44,10 +44,43 @@ NODES = {
44 44 "m2m8a": {"cores": 8, "ram_gb": 8, "model": "Mac mini"},
45 45 "m2m8b": {"cores": 8, "ram_gb": 8, "model": "Mac mini"},
46 46 "m4md": {"cores": 10, "ram_gb": 16, "model": "Mac mini"}, # ajouté 2026-09-04 (Mac16,10, M4, 100.78.227.79)
47 + "M1M64": {"cores": 10, "ram_gb": 64, "model": "Mac Studio"}, # ajouté 2026-09-08 (Mac13,1, M1 Max, loué, hors LAN — voir REMOTE_NODES)
47 48 }
48 49
50 +# Nœuds HORS LAN : IP publique fixe + autre utilisateur + autre mot de passe sudo. Ils ne sont pas trouvés par le
51 +# balayage 192.168.2.x : `discover` s'y connecte directement et vérifie le marqueur ~/.maclustr-node.
52 +REMOTE_NODES = {
53 + "M1M64": {"host": "45.74.241.221", "user": "simon", "sudo_pw_file": os.path.join(STATE, ".sudo-M1M64")},
54 +}
55 +
56 +
57 +def _remote(key):
58 + for alias, r in REMOTE_NODES.items():
59 + if key in (alias, r["host"]):
60 + return r
61 + return None
62 +
63 +
64 +def user_of(key=None):
65 + """Utilisateur SSH d'un nœud (alias ou IP) — simon-pierreboucher partout sauf REMOTE_NODES."""
66 + r = _remote(key) if key else None
67 + return r["user"] if r else USER
68 +
69 +
70 +def home_of(key=None):
71 + return "/Users/%s" % user_of(key)
72 +
73 +
74 +def sudo_pw_file(key=None):
75 + r = _remote(key) if key else None
76 + return r["sudo_pw_file"] if r else SUDO_PW_FILE
77 +
78 +
49 79 # Nœuds réservés : inéligibles au placement automatique (sauf app épinglée dessus).
50 −RESERVED = {"M3U96b": "réservé à hfmarketdata (lac FirstRate Data) depuis le 2026-09-04"}
80 +RESERVED = {
81 + "M3U96b": "réservé à hfmarketdata (lac FirstRate Data) depuis le 2026-09-04",
82 + "M1M64": "nœud distant hors LAN (loué, user simon) : pas de LAN 192.168.2.x ni de gardiens Ka → placement seulement avec --node",
83 +}
51 84
52 85 # Rôles : gateway = jamais d'app ; worker = éligible. Un nœud absent du scan est « offline ».
53 86 ROLES = {alias: ("gateway" if alias == GATEWAY else "worker") for alias in NODES}
modified mld/deploy.py +22 −20
@@ -14,11 +14,12 @@ class DeployError(Exception):
14 14
15 15 def _ctx(alias, ip):
16 16 lan = nodes.load_lan()["ips"]
17 − return {"HOME": "/Users/%s" % config.USER, "NODE": alias, "LAN_IP": ip, "ips": lan, "USER": config.USER}
17 + return {"HOME": config.home_of(ip), "NODE": alias, "LAN_IP": ip, "ips": lan, "USER": config.user_of(ip)}
18 18
19 19
20 −def _abs(p):
21 − return p.replace("~", "/Users/%s" % config.USER, 1) if p.startswith("~") else p
20 +def _abs(p, ip=None):
21 + """~ → home du nœud (dépend de l'utilisateur : simon-pierreboucher, ou celui d'un REMOTE_NODE)."""
22 + return p.replace("~", config.home_of(ip), 1) if p.startswith("~") else p
22 23
23 24
24 25 def _log(app, msg):
@@ -39,10 +40,11 @@ def _ensure_dir(ip, d):
39 40 """Crée le répertoire cible ; sous /opt (racine root) passe par sudo -S avec le mot de passe de ~/dispatch/.sudo."""
40 41 if ssh.ok(ip, "mkdir -p %s 2>/dev/null && test -w %s" % (shlex.quote(d), shlex.quote(d))):
41 42 return
42 − pw = open(config.SUDO_PW_FILE).read().strip() if os.path.exists(config.SUDO_PW_FILE) else None
43 + pwf = config.sudo_pw_file(ip)
44 + pw = open(pwf).read().strip() if os.path.exists(pwf) else None
43 45 if not pw:
44 − raise DeployError("impossible de créer %s (sudo requis, %s absent)" % (d, config.SUDO_PW_FILE))
45 − rc, out, err = ssh.run(ip, "echo %s | sudo -S -p '' mkdir -p %s && echo %s | sudo -S -p '' chown %s %s" % (shlex.quote(pw), shlex.quote(d), shlex.quote(pw), config.USER, shlex.quote(d)))
46 + raise DeployError("impossible de créer %s (sudo requis, %s absent)" % (d, pwf))
47 + rc, out, err = ssh.run(ip, "echo %s | sudo -S -p '' mkdir -p %s && echo %s | sudo -S -p '' chown %s %s" % (shlex.quote(pw), shlex.quote(d), shlex.quote(pw), config.user_of(ip), shlex.quote(d)))
46 48 if rc != 0:
47 49 raise DeployError("création de %s impossible : %s" % (d, err.strip()[-200:]))
48 50
@@ -57,24 +59,24 @@ def fetch(app, from_alias, final=False):
57 59 ip = _ip(from_alias)
58 60 _log(app, "fetch %s depuis %s (%s)%s" % (m["dir"], from_alias, ip, " [final]" if final else ""))
59 61 excl = SYNC_BASE_EXCLUDES + list(m.get("sync_excludes") or [])
60 − rc, out, err = ssh.rsync_pull(ip, _abs(m["dir"]), os.path.join(stage_dir(app), "dir"), excludes=excl)
62 + rc, out, err = ssh.rsync_pull(ip, _abs(m["dir"], ip), os.path.join(stage_dir(app), "dir"), excludes=excl)
61 63 if rc == 23 and not final:
62 64 _log(app, " avertissement pré-copie à chaud (fichiers en cours d'écriture, repris au delta final) : %s" % err.strip().splitlines()[0][:120])
63 65 elif rc not in (0, 24):
64 66 raise DeployError("rsync fetch a échoué (%s): %s" % (rc, err.strip()[-300:]))
65 67 for extra in m.get("extra_paths") or []:
66 68 dest = os.path.join(stage_dir(app), "extra", extra.strip("~/").replace("/", "__"))
67 − rc2, out2, err2 = ssh.run(ip, "test -e %s" % shlex.quote(_abs(extra)))
69 + rc2, out2, err2 = ssh.run(ip, "test -e %s" % shlex.quote(_abs(extra, ip)))
68 70 if rc2 != 0:
69 71 _log(app, " extra absent sur la source, ignoré : %s" % extra)
70 72 continue
71 − isdir = ssh.ok(ip, "test -d %s" % shlex.quote(_abs(extra)))
73 + isdir = ssh.ok(ip, "test -d %s" % shlex.quote(_abs(extra, ip)))
72 74 if isdir:
73 − rc2, out2, err2 = ssh.rsync_pull(ip, _abs(extra), dest, delete=True)
75 + rc2, out2, err2 = ssh.rsync_pull(ip, _abs(extra, ip), dest, delete=True)
74 76 else:
75 77 os.makedirs(dest, exist_ok=True)
76 78 import subprocess
77 − subprocess.run(["scp", "-p"] + ssh.SSH_OPTS + ["%s:%s" % (ssh.target(ip), _abs(extra)), os.path.join(dest, os.path.basename(extra))], capture_output=True)
79 + subprocess.run(["scp", "-p"] + ssh.SSH_OPTS + ["%s:%s" % (ssh.target(ip), _abs(extra, ip)), os.path.join(dest, os.path.basename(extra))], capture_output=True)
78 80 json.dump({"app": app, "from": from_alias, "ip": ip, "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "final": final},
79 81 open(os.path.join(stage_dir(app), "fetch.json"), "w"), indent=2)
80 82 stat = [l for l in out.splitlines() if "Total transferred file size" in l or "Number of regular files transferred" in l or "Number of files transferred" in l or "Total bytes sent" in l]
@@ -112,7 +114,7 @@ def stop(app, alias, keep_dir=True):
112 114 def _install_hooks(m, ip, ctx):
113 115 """Commandes post-sync : réécritures .env, hooks, inscription pousseur."""
114 116 script = ""
115 − d = _abs(m["dir"])
117 + d = _abs(m["dir"], ip)
116 118 for fname, kv in (m.get("env_overrides") or {}).items():
117 119 target = os.path.join(d, fname)
118 120 kv = manifest.render(kv, ctx)
@@ -140,7 +142,7 @@ def deploy(app, alias, reinstall=False, skip_sync=False):
140 142 if missing:
141 143 raise DeployError("runtimes manquants sur %s : %s (→ `mld prepare %s`)" % (alias, ", ".join(missing), alias))
142 144 # 2. sync
143 − d = _abs(m["dir"])
145 + d = _abs(m["dir"], ip)
144 146 if not skip_sync:
145 147 _ensure_dir(ip, d)
146 148 excl = SYNC_BASE_EXCLUDES + list(m.get("sync_excludes") or [])
@@ -154,11 +156,11 @@ def deploy(app, alias, reinstall=False, skip_sync=False):
154 156 if not os.path.exists(src):
155 157 continue
156 158 if os.path.isdir(src) and not os.path.exists(os.path.join(src, os.path.basename(extra))):
157 − ssh.rsync_push(src, ip, _abs(extra), delete=False)
159 + ssh.rsync_push(src, ip, _abs(extra, ip), delete=False)
158 160 else:
159 161 import subprocess
160 − ssh.run(ip, "mkdir -p %s" % shlex.quote(os.path.dirname(_abs(extra))))
161 − subprocess.run(["scp", "-p"] + ssh.SSH_OPTS + [os.path.join(src, os.path.basename(extra)), "%s:%s" % (ssh.target(ip), _abs(extra))], capture_output=True)
162 + ssh.run(ip, "mkdir -p %s" % shlex.quote(os.path.dirname(_abs(extra, ip))))
163 + subprocess.run(["scp", "-p"] + ssh.SSH_OPTS + [os.path.join(src, os.path.basename(extra)), "%s:%s" % (ssh.target(ip), _abs(extra, ip))], capture_output=True)
162 164 _log(app, " extra: %s" % extra)
163 165 # 3. install (optionnel) + hooks
164 166 script = "cd %s || exit 3\n" % shlex.quote(d)
@@ -197,7 +199,7 @@ def start(app, alias, m=None, ctx=None, ip=None):
197 199 ip = ip or _ip(alias)
198 200 ctx = ctx or _ctx(alias, ip)
199 201 _log(app, "start sur %s" % alias)
200 − run_dir = _abs(config.RUN_DIR)
202 + run_dir = _abs(config.RUN_DIR, ip)
201 203 eco = render.ecosystem(m, ctx)
202 204 eco_path = "%s/%s.config.cjs" % (run_dir, app)
203 205 ssh.write_remote_file(ip, eco_path, eco, mode="600")
@@ -212,7 +214,7 @@ def start(app, alias, m=None, ctx=None, ip=None):
212 214 ssh.write_remote_file(ip, ppath, pl, mode="644")
213 215 script += "launchctl bootout gui/$(id -u)/%s >/dev/null 2>&1; launchctl bootstrap gui/$(id -u) %s && echo ' launchd: %s chargé' || echo ' launchd: %s ÉCHEC'\n" % (item["label"], shlex.quote(ppath), item["label"], item["label"])
214 216 for hook in (m.get("hooks") or {}).get("post_start") or []:
215 − script += "cd %s && { %s ; }\n" % (shlex.quote(_abs(m["dir"])), manifest.render(hook, ctx))
217 + script += "cd %s && { %s ; }\n" % (shlex.quote(_abs(m["dir"], ip)), manifest.render(hook, ctx))
216 218 rc, out, err = ssh.run(ip, script, timeout=300)
217 219 for l in out.splitlines():
218 220 _log(app, l)
@@ -262,8 +264,8 @@ def retire(app, alias, remove_dir=True):
262 264 ip = _ip(alias)
263 265 stop(app, alias)
264 266 if remove_dir:
265 − d = _abs(m["dir"])
266 − rc, out, err = ssh.run(ip, "rm -rf %s && echo ' rm -rf %s'; rm -f %s/%s.config.cjs; grep -v -x %s ~/.ka-pousseur-repos > ~/.ka-pousseur-repos.tmp 2>/dev/null && mv ~/.ka-pousseur-repos.tmp ~/.ka-pousseur-repos; true" % (shlex.quote(d), d, _abs(config.RUN_DIR), app, shlex.quote(m["dir"])), timeout=600)
267 + d = _abs(m["dir"], ip)
268 + rc, out, err = ssh.run(ip, "rm -rf %s && echo ' rm -rf %s'; rm -f %s/%s.config.cjs; grep -v -x %s ~/.ka-pousseur-repos > ~/.ka-pousseur-repos.tmp 2>/dev/null && mv ~/.ka-pousseur-repos.tmp ~/.ka-pousseur-repos; true" % (shlex.quote(d), d, _abs(config.RUN_DIR, ip), app, shlex.quote(m["dir"])), timeout=600)
267 269 for l in out.splitlines():
268 270 _log(app, l)
269 271 registry.log(app, "retired", node=alias)
modified mld/importer.py +1 −1
@@ -70,7 +70,7 @@ def import_node(alias, apps_filter=None):
70 70 m = json.loads(json.dumps(manifest.DEFAULTS))
71 71 mo = re.match(r"(/Users/[^/]+/(?:apps/)?[^/]+|/opt/[^/]+)", cwd)
72 72 d = mo.group(1) if mo else cwd
73 − m.update({"app": app, "label": app, "dir": d.replace("/Users/%s" % config.USER, "~"), "processes": g["processes"], "ngrok": g["ngrok"],
73 + m.update({"app": app, "label": app, "dir": d.replace(config.home_of(alias), "~"), "processes": g["processes"], "ngrok": g["ngrok"],
74 74 "domain": (g["ngrok"] or {}).get("url"), "port": (g["ngrok"] or {}).get("port"), "ram_mb_observed": max(g["ram"], 128)})
75 75 m["requires"]["ports"] = [m["port"]] if m["port"] else []
76 76 m["placement"]["reason"] = "importé de %s" % alias
modified mld/nodes.py +6 −1
@@ -47,7 +47,7 @@ def _identify(ip):
47 47
48 48
49 49 def discover(rng=range(1, 255), verbose=True):
50 − """Balaye 192.168.2.0/24 en SSH (clé passerelle) et lit le marqueur ~/.maclustr-node."""
50 + """Balaye 192.168.2.0/24 en SSH (clé passerelle) et lit le marqueur ~/.maclustr-node ; ajoute les REMOTE_NODES joignables."""
51 51 ips = [config.LAN_PREFIX + str(i) for i in rng]
52 52 found = {}
53 53 with ThreadPoolExecutor(max_workers=48) as ex:
@@ -59,6 +59,11 @@ def discover(rng=range(1, 255), verbose=True):
59 59 alias = a
60 60 break
61 61 found[alias] = ip
62 + # nœuds hors LAN : connexion directe à l'IP publique, le marqueur doit confirmer l'alias
63 + for alias, r in config.REMOTE_NODES.items():
64 + _, seen = _identify(r["host"])
65 + if seen and seen.lower() == alias.lower():
66 + found[alias] = r["host"]
62 67 lan = load_lan()
63 68 lan["ips"] = found
64 69 save_lan(lan)
modified mld/prepare.py +16 −12
@@ -4,9 +4,10 @@ import shlex
4 4 from . import config, nodes, ssh
5 5
6 6
7 −def _sudo_pw():
8 − if os.path.exists(config.SUDO_PW_FILE):
9 − return open(config.SUDO_PW_FILE).read().strip()
7 +def _sudo_pw(alias=None):
8 + pwf = config.sudo_pw_file(alias)
9 + if os.path.exists(pwf):
10 + return open(pwf).read().strip()
10 11 return None
11 12
12 13
@@ -32,14 +33,14 @@ def prepare(alias, runtimes=None, ka_helpers=False):
32 33 # config ngrok (authtoken) depuis la passerelle
33 34 if "ngrok" in missing or not have.get("ngrok"):
34 35 if os.path.exists(config.NGROK_CFG):
35 − rc0, _, _ = ssh.write_remote_file(ip, "/Users/%s/Library/Application Support/ngrok/ngrok.yml" % config.USER, open(config.NGROK_CFG).read(), mode="600")
36 + rc0, _, _ = ssh.write_remote_file(ip, "%s/Library/Application Support/ngrok/ngrok.yml" % config.home_of(alias), open(config.NGROK_CFG).read(), mode="600")
36 37 script += "echo ' ngrok.yml %s'\n" % ("copié" if rc0 == 0 else "ÉCHEC de copie")
37 38 # marqueur
38 39 script += "[ -f ~/.maclustr-node ] || echo %s > ~/.maclustr-node\n" % alias
39 40 # pm2 startup (launchd) — nécessite sudo
40 − pw = _sudo_pw()
41 − script += "if ! ls ~/Library/LaunchAgents 2>/dev/null | grep -qi pm2; then cmd=$(pm2 startup launchd -u %s --hp /Users/%s 2>/dev/null | grep '^sudo' | head -1); if [ -n \"$cmd\" ]; then %s echo \" pm2 startup: $( ${cmd#sudo } >/dev/null 2>&1 && echo ok || echo 'à faire manuellement')\"; fi; fi\n" % (
42 − config.USER, config.USER, ("echo %s | sudo -S -v 2>/dev/null;" % shlex.quote(pw)) if pw else "")
41 + pw = _sudo_pw(alias)
42 + script += "if ! ls ~/Library/LaunchAgents 2>/dev/null | grep -qi pm2; then cmd=$(pm2 startup launchd -u %s --hp %s 2>/dev/null | grep '^sudo' | head -1); if [ -n \"$cmd\" ]; then %s echo \" pm2 startup: $( ${cmd#sudo } >/dev/null 2>&1 && echo ok || echo 'à faire manuellement')\"; fi; fi\n" % (
43 + config.user_of(alias), config.home_of(alias), ("echo %s | sudo -S -v 2>/dev/null;" % shlex.quote(pw)) if pw else "")
43 44 rc, out, err = ssh.run(ip, script, timeout=1800)
44 45 if ka_helpers:
45 46 out += ka_helpers_install(alias, ip)
@@ -55,9 +56,11 @@ def prepare(alias, runtimes=None, ka_helpers=False):
55 56 def bootstrap(alias):
56 57 """Installe Xcode CLT + Homebrew sans GUI. Active un sudo NOPASSWD temporaire (retiré à la fin). Long (plusieurs Go)."""
57 58 ip = nodes.ip_of(alias)
58 − pw = _sudo_pw()
59 + if not ip:
60 + raise SystemExit("nœud %s injoignable" % alias)
61 + pw = _sudo_pw(alias)
59 62 if not pw:
60 − raise SystemExit("mot de passe sudo requis dans %s" % config.SUDO_PW_FILE)
63 + raise SystemExit("mot de passe sudo requis dans %s" % config.sudo_pw_file(alias))
61 64 script = r'''
62 65 export LC_ALL=C
63 66 echo %s | sudo -S -p '' sh -c 'echo "%s ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/mld-bootstrap && chmod 440 /etc/sudoers.d/mld-bootstrap'
@@ -77,7 +80,7 @@ if [ ! -x /opt/homebrew/bin/brew ]; then
77 80 grep -q 'opt/homebrew/bin/brew shellenv' ~/.zprofile 2>/dev/null || echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
78 81 fi
79 82 echo " CLT: $(xcode-select -p 2>/dev/null || echo absent) brew: $(/opt/homebrew/bin/brew --version 2>/dev/null | head -1 || echo absent)"
80 −''' % (shlex.quote(pw), config.USER)
83 +''' % (shlex.quote(pw), config.user_of(alias))
81 84 rc, out, err = ssh.run(ip, script, timeout=5400)
82 85 print(out)
83 86 if err.strip():
@@ -95,8 +98,9 @@ def ka_helpers_install(alias, ip):
95 98 """Pousseur Ka (launchd zsh → git push vers spbgit) + alias SSH `gitsrv` → passerelle + clé autorisée sur la passerelle."""
96 99 assets = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets")
97 100 out = [" ka-helpers sur %s :" % alias]
98 − ssh.write_remote_file(ip, "/Users/%s/ka-pousseur.sh" % config.USER, open(os.path.join(assets, "ka-pousseur.sh")).read(), mode="755")
99 − ssh.write_remote_file(ip, "/Users/%s/Library/LaunchAgents/com.ka.pousseur.plist" % config.USER, open(os.path.join(assets, "com.ka.pousseur.plist")).read(), mode="644")
101 + home = config.home_of(alias)
102 + ssh.write_remote_file(ip, "%s/ka-pousseur.sh" % home, open(os.path.join(assets, "ka-pousseur.sh")).read(), mode="755")
103 + ssh.write_remote_file(ip, "%s/Library/LaunchAgents/com.ka.pousseur.plist" % home, open(os.path.join(assets, "com.ka.pousseur.plist")).read(), mode="644")
100 104 mdns = gateway_mdns()
101 105 block = "Host gitsrv\n HostName %s\n User %s\n IdentityFile ~/.ssh/ka_guardian_ed25519\n IdentitiesOnly yes\n ControlMaster no\n ControlPath none\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n LogLevel ERROR\n\n" % (mdns, config.USER)
102 106 script = r"""
modified mld/registry.py +3 −3
@@ -88,8 +88,8 @@ def save_subscribers(subs):
88 88 os.replace(tmp, SUBSCRIBERS)
89 89
90 90
91 −def _abs(p):
92 − return p.replace("~", "/Users/%s" % config.USER, 1) if p.startswith("~") else p
91 +def _abs(p, ip=None):
92 + return p.replace("~", config.home_of(ip), 1) if p.startswith("~") else p
93 93
94 94
95 95 def resolve_subscriber(sub, r):
@@ -114,7 +114,7 @@ def resolve_subscriber(sub, r):
114 114 path = sub["path"]
115 115 if not (path.startswith("/") or path.startswith("~")):
116 116 path = base.rstrip("/") + "/" + path
117 − return label, node, ip, _abs(path)
117 + return label, node, ip, _abs(path, ip)
118 118
119 119
120 120 def push_to_subscribers(r=None, verbose=True):
modified mld/ssh.py +1 −1
@@ -14,7 +14,7 @@ REMOTE_PATH = "export PATH=/opt/homebrew/bin:/opt/homebrew/sbin:$HOME/bin:$HOME/
14 14
15 15
16 16 def target(ip):
17 − return "%s@%s" % (config.USER, ip)
17 + return "%s@%s" % (config.user_of(ip), ip)
18 18
19 19
20 20 def run(ip, cmd, timeout=120, stdin=None, path=True):
21 21