| 126 |
126 |
return script |
| 127 |
127 |
|
| 128 |
128 |
|
| 129 |
|
−def deploy(app, alias, reinstall=False, skip_sync=False): |
| 130 |
|
− """staging -> nœud, puis démarrage (PM2 + launchd) et healthcheck.""" |
|
129 |
+def _node_git_state(ip, d): |
|
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 |
151 |
m = manifest.load(app) |
| 132 |
152 |
ip = _ip(alias) |
| 133 |
153 |
ctx = _ctx(alias, ip) |
| 143 |
163 |
# 2. sync |
| 144 |
164 |
d = _abs(m["dir"], ip) |
| 145 |
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 |
173 |
_ensure_dir(ip, d) |
| 147 |
174 |
excl = SYNC_BASE_EXCLUDES + list(m.get("sync_excludes") or []) |
| 148 |
175 |
rc, out, err = ssh.rsync_push(sd, ip, d, excludes=excl, delete=True) |
| 382 |
409 |
out.append((a, v["node"], v.get("domain") or "", "OK" if ok else "KO", det)) |
| 383 |
410 |
registry.update_many(changes) |
| 384 |
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 |
|