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é)
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 | ||||