SPB Git

remote-first : garde dans mld deploy (dépôt git du nœud ≠ staging ou non commité → refus sauf --force) + commande mld adopt <app> (dossier du nœud → clone git origin spbgit, état du nœud = vérité)

simon-pierre boucher committed (Oct 2, 2026) parent 5eaeaf7 Browse files

2 changed files +104 −4

modified mld/cli.py +11 −2
@@ -87,7 +87,15 @@ def cmd_deploy(a):
87 node = rows[0]["node"] 87 node = rows[0]["node"]
88 print("nœud choisi : %s (score %.3f)" % (node, rows[0]["score"])) 88 print("nœud choisi : %s (score %.3f)" % (node, rows[0]["score"]))
89 try: 89 try:
90− deploy.deploy(a.app, node, reinstall=a.reinstall, skip_sync=a.skip_sync) 90+ deploy.deploy(a.app, node, reinstall=a.reinstall, skip_sync=a.skip_sync, force=a.force)
91+ except deploy.DeployError as e:
92+ raise SystemExit("ÉCHEC : %s" % e)
93+
94+
95+def cmd_adopt(a):
96+ try:
97+ for app in a.apps:
98+ deploy.adopt(app, alias=a.node, repo=a.repo if len(a.apps) == 1 else None, dry_run=a.dry_run)
91 except deploy.DeployError as e: 99 except deploy.DeployError as e:
92 raise SystemExit("ÉCHEC : %s" % e) 100 raise SystemExit("ÉCHEC : %s" % e)
93 101
@@ -266,11 +274,12 @@ def main(argv=None):
266 s = sp.add_parser("pick", help="classe les nœuds pour une app (ou un besoin ad hoc)"); s.add_argument("app", nargs="?"); s.add_argument("--ram", type=float); s.add_argument("--runtime", action="append"); s.add_argument("--exclude", action="append"); s.add_argument("--rescan", action="store_true"); s.add_argument("--json", action="store_true"); s.set_defaults(f=cmd_pick) 274 s = sp.add_parser("pick", help="classe les nœuds pour une app (ou un besoin ad hoc)"); s.add_argument("app", nargs="?"); s.add_argument("--ram", type=float); s.add_argument("--runtime", action="append"); s.add_argument("--exclude", action="append"); s.add_argument("--rescan", action="store_true"); s.add_argument("--json", action="store_true"); s.set_defaults(f=cmd_pick)
267 s = sp.add_parser("plan", help="plan de placement pour toutes les apps (ou une liste)"); s.add_argument("apps", nargs="*"); s.add_argument("--exclude", action="append"); s.add_argument("--rescan", action="store_true"); s.add_argument("--json", action="store_true"); s.set_defaults(f=cmd_plan) 275 s = sp.add_parser("plan", help="plan de placement pour toutes les apps (ou une liste)"); s.add_argument("apps", nargs="*"); s.add_argument("--exclude", action="append"); s.add_argument("--rescan", action="store_true"); s.add_argument("--json", action="store_true"); s.set_defaults(f=cmd_plan)
268 s = sp.add_parser("fetch", help="copie l'app d'un nœud vers le staging"); s.add_argument("app"); s.add_argument("--from", dest="from_node", required=True); s.add_argument("--final", action="store_true"); s.set_defaults(f=cmd_fetch) 276 s = sp.add_parser("fetch", help="copie l'app d'un nœud vers le staging"); s.add_argument("app"); s.add_argument("--from", dest="from_node", required=True); s.add_argument("--final", action="store_true"); s.set_defaults(f=cmd_fetch)
269− s = sp.add_parser("deploy", help="staging -> nœud (choisi automatiquement sans --node)"); s.add_argument("app"); s.add_argument("--node"); s.add_argument("--reinstall", action="store_true"); s.add_argument("--skip-sync", action="store_true"); s.set_defaults(f=cmd_deploy) 277+ s = sp.add_parser("deploy", help="staging -> nœud (choisi automatiquement sans --node)"); s.add_argument("app"); s.add_argument("--node"); s.add_argument("--reinstall", action="store_true"); s.add_argument("--skip-sync", action="store_true"); s.add_argument("--force", action="store_true", help="écrase un dépôt git du nœud différent du staging (contourne la garde remote-first)"); s.set_defaults(f=cmd_deploy)
270 s = sp.add_parser("move", help="migre une app (fetch, stop, deploy, retire)"); s.add_argument("app"); s.add_argument("--to"); s.add_argument("--from", dest="from_node"); s.add_argument("--away", action="store_true", help="exclut le nœud actuel"); s.add_argument("--keep-source", action="store_true"); s.add_argument("--reinstall", action="store_true"); s.set_defaults(f=cmd_move) 278 s = sp.add_parser("move", help="migre une app (fetch, stop, deploy, retire)"); s.add_argument("app"); s.add_argument("--to"); s.add_argument("--from", dest="from_node"); s.add_argument("--away", action="store_true", help="exclut le nœud actuel"); s.add_argument("--keep-source", action="store_true"); s.add_argument("--reinstall", action="store_true"); s.set_defaults(f=cmd_move)
271 s = sp.add_parser("stop"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_stop) 279 s = sp.add_parser("stop"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_stop)
272 s = sp.add_parser("start"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_start) 280 s = sp.add_parser("start"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_start)
273 s = sp.add_parser("restart"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_restart) 281 s = sp.add_parser("restart"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_restart)
282+ s = sp.add_parser("adopt", help="REMOTE-FIRST : fait du dossier déployé sur le nœud un dépôt git (origin spbgit), état du nœud = vérité"); s.add_argument("apps", nargs="+"); s.add_argument("--node"); s.add_argument("--repo", help="nom du dépôt spbgit (défaut : nom de l'app)"); s.add_argument("--dry-run", action="store_true"); s.set_defaults(f=cmd_adopt)
274 s = sp.add_parser("retire", help="arrête + efface la copie d'un nœud"); s.add_argument("app"); s.add_argument("--node"); s.add_argument("--keep-dir", action="store_true"); s.add_argument("--keep-registry", action="store_true"); s.add_argument("--note"); s.set_defaults(f=cmd_retire) 283 s = sp.add_parser("retire", help="arrête + efface la copie d'un nœud"); s.add_argument("app"); s.add_argument("--node"); s.add_argument("--keep-dir", action="store_true"); s.add_argument("--keep-registry", action="store_true"); s.add_argument("--note"); s.set_defaults(f=cmd_retire)
275 s = sp.add_parser("status"); s.add_argument("app", nargs="?"); s.add_argument("--live", action="store_true"); s.set_defaults(f=cmd_status) 284 s = sp.add_parser("status"); s.add_argument("app", nargs="?"); s.add_argument("--live", action="store_true"); s.set_defaults(f=cmd_status)
276 s = sp.add_parser("health"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_health) 285 s = sp.add_parser("health"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_health)
modified mld/deploy.py +93 −2
@@ -126,8 +126,28 @@ def _install_hooks(m, ip, ctx):
126 return script 126 return script
127 127
128 128
129−def deploy(app, alias, reinstall=False, skip_sync=False): 129+def _node_git_state(ip, d):
130− """staging -> nœud, puis démarrage (PM2 + launchd) et healthcheck.""" 130+ """(head, dirty) du dépôt git éventuel dans `d` sur le nœud ; (None, 0) si ce n'est pas un dépôt."""
131+ rc, out, err = ssh.run(ip, "cd %s 2>/dev/null && git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git rev-parse HEAD && git status --porcelain | wc -l" % shlex.quote(d))
132+ lines = [l.strip() for l in out.splitlines() if l.strip()]
133+ if rc != 0 or len(lines) < 2:
134+ return None, 0
135+ return lines[0], int(lines[1] or 0)
136+
137+
138+def _stage_git_head(sd):
139+ import subprocess
140+ p = subprocess.run(["git", "-C", sd, "rev-parse", "HEAD"], capture_output=True, text=True)
141+ return p.stdout.strip() if p.returncode == 0 else None
142+
143+
144+def deploy(app, alias, reinstall=False, skip_sync=False, force=False):
145+ """staging -> nœud, puis démarrage (PM2 + launchd) et healthcheck.
146+
147+ Règle REMOTE-FIRST (2026-10-02) : le dossier déployé sur le nœud est la source de vérité (dépôt git, origin spbgit).
148+ Si ce dossier est un dépôt dont le HEAD diffère du staging, ou qui a des modifications non commitées, le rsync
149+ `--delete` écraserait du travail : on refuse, sauf `--force`. `mld move`/`mld fetch --from <nœud>` repartent du nœud
150+ (le staging contient alors le même .git) et passent donc la garde."""
131 m = manifest.load(app) 151 m = manifest.load(app)
132 ip = _ip(alias) 152 ip = _ip(alias)
133 ctx = _ctx(alias, ip) 153 ctx = _ctx(alias, ip)
@@ -143,6 +163,13 @@ def deploy(app, alias, reinstall=False, skip_sync=False):
143 # 2. sync 163 # 2. sync
144 d = _abs(m["dir"], ip) 164 d = _abs(m["dir"], ip)
145 if not skip_sync: 165 if not skip_sync:
166+ node_head, node_dirty = _node_git_state(ip, d)
167+ if node_head and not force:
168+ stage_head = _stage_git_head(sd)
169+ if node_dirty:
170+ raise DeployError("REMOTE-FIRST : %s:%s a %d modification(s) non commitée(s) — committer/pousser sur le nœud (ou `mld fetch %s --from %s`), ou --force pour écraser" % (alias, d, node_dirty, app, alias))
171+ if stage_head != node_head:
172+ raise DeployError("REMOTE-FIRST : %s:%s est un dépôt git (HEAD %s) différent du staging (%s) — faire `mld fetch %s --from %s` avant `mld deploy`, ou --force pour écraser" % (alias, d, node_head[:8], (stage_head or "sans git")[:8], app, alias))
146 _ensure_dir(ip, d) 173 _ensure_dir(ip, d)
147 excl = SYNC_BASE_EXCLUDES + list(m.get("sync_excludes") or []) 174 excl = SYNC_BASE_EXCLUDES + list(m.get("sync_excludes") or [])
148 rc, out, err = ssh.rsync_push(sd, ip, d, excludes=excl, delete=True) 175 rc, out, err = ssh.rsync_push(sd, ip, d, excludes=excl, delete=True)
@@ -382,3 +409,67 @@ def live_status(app=None, public=True):
382 out.append((a, v["node"], v.get("domain") or "", "OK" if ok else "KO", det)) 409 out.append((a, v["node"], v.get("domain") or "", "OK" if ok else "KO", det))
383 registry.update_many(changes) 410 registry.update_many(changes)
384 return out 411 return out
412+
413+
414+# ---------------------------------------------------------------------------
415+# REMOTE-FIRST (2026-10-02) : `mld adopt <app>` fait du dossier déployé sur le nœud un clone git
416+# (origin = spbgit sur M1M32), l'état du nœud devenant la vérité (commit « adopt »). Les fichiers
417+# suivis absents du nœud (exclus du rsync : tests, exemples…) sont restaurés depuis origin/main.
418+# ---------------------------------------------------------------------------
419+ADOPT_GITIGNORE = "node_modules/\n.venv/\nvenv/\n.next/\n__pycache__/\n*.pyc\n*.log\n.DS_Store\n.env\n.env.local\n.pm2/\n"
420+
421+
422+def adopt(app, alias=None, repo=None, dry_run=False):
423+ m = manifest.load(app)
424+ alias = alias or registry.node_of(app)
425+ if not alias:
426+ raise DeployError("app %s inconnue du registre : préciser --node" % app)
427+ repo = repo or app
428+ ip = _ip(alias)
429+ d = _abs(m["dir"], ip)
430+ bare = os.path.expanduser("~/srv/git/%s.git" % repo)
431+ if not os.path.isdir(bare):
432+ _log(app, "adopt : création du dépôt spbgit %s" % bare)
433+ if not dry_run:
434+ import subprocess
435+ subprocess.run(["git", "init", "-q", "--bare", bare], check=True)
436+ url = "gitsrv:srv/git/%s.git" % repo
437+ if alias == "M1M32":
438+ url = bare # la passerelle héberge spbgit : chemin local
439+ script = """set -e
440+cd %(d)s || { echo "ADOPT_ERR dossier absent %(d)s"; exit 3; }
441+if [ ! -d .git ]; then git init -q; echo "init"; fi
442+old=$(git remote get-url origin 2>/dev/null || true)
443+if [ -n "$old" ] && [ "$old" != %(url)s ]; then git remote rename origin old-origin 2>/dev/null || true; echo "ancien origin conservé sous old-origin : $old"; fi
444+git remote get-url origin >/dev/null 2>&1 || git remote add origin %(url)s
445+[ -f .gitignore ] || printf '%(gi)s' > .gitignore
446+git fetch -q origin main 2>/dev/null && remote_main=1 || remote_main=0
447+if [ $remote_main = 1 ]; then
448+ git reset -q origin/main 2>/dev/null || git reset -q FETCH_HEAD
449+ git symbolic-ref HEAD refs/heads/main 2>/dev/null || true
450+ # fichiers suivis absents du nœud (exclus du rsync) : on les reprend de main
451+ git status --porcelain | awk '$1=="D"{print substr($0,4)}' | while IFS= read -r f; do git checkout -q -- "$f" 2>/dev/null || true; done
452+fi
453+git add -A
454+big=$(git diff --cached --name-only -z | xargs -0 stat -f '%%z %%N' 2>/dev/null | awk '$1>52428800{print $2}' | head -5)
455+if [ -n "$big" ]; then echo "ADOPT_ERR fichiers > 50 Mo à ignorer d'abord :"; echo "$big"; git reset -q; exit 4; fi
456+n=$(git diff --cached --name-only | wc -l | tr -d ' ')
457+echo "diff_vs_main=$n"
458+%(dry)s
459+if [ "$n" != 0 ] || ! git rev-parse HEAD >/dev/null 2>&1; then
460+ git -c user.name="mld" -c user.email="mld@maclustr.io" commit -q -m "adopt: état déployé sur %(alias)s adopté comme source de vérité (remote-first, $(date +%%Y-%%m-%%d))" && echo "commit ok"
461+fi
462+git branch -M main 2>/dev/null || true
463+git push -q -u origin main 2>&1 | tail -1 || true
464+git branch --set-upstream-to=origin/main main >/dev/null 2>&1 || true
465+echo "HEAD=$(git rev-parse --short HEAD) upstream=$(git rev-parse --abbrev-ref @{u} 2>/dev/null) dirty=$(git status --porcelain | wc -l | tr -d ' ')"
466+""" % {"d": shlex.quote(d), "url": shlex.quote(url), "gi": ADOPT_GITIGNORE, "alias": alias,
467+ "dry": 'git diff --cached --stat | tail -3; git reset -q; echo "DRY-RUN : rien commité"; exit 0' if dry_run else ""}
468+ rc, out, err = ssh.run(ip, script, timeout=900)
469+ for l in (out + err).splitlines()[-12:]:
470+ _log(app, " " + l)
471+ if rc != 0 or "ADOPT_ERR" in out:
472+ raise DeployError("adopt %s sur %s a échoué (rc=%s)" % (app, alias, rc))
473+ if not dry_run:
474+ registry.set_app(app, git_remote=url, adopted=time.strftime("%Y-%m-%dT%H:%M:%S"))
475+ return True
385 476
Menu

Theme