SPB Git
24.8 KB · 476 lines python
1"""Cycle de vie d'une app : fetch (nœud -> staging), deploy (staging -> nœud), stop, start, retire, move, health.23Exposition publique = MacLustr Tunnel (module tunnel) : au deploy, la route Caddy https://<domaine> de la passerelle est4pointée vers le nœud choisi (après le healthcheck local), donc elle suit automatiquement les `mld move`."""5import json6import os7import shlex8import time9from . import config, manifest, nodes, registry, render, ssh, tunnel1011SYNC_BASE_EXCLUDES = [".DS_Store", "*.log", "__pycache__/", ".pytest_cache/", ".mypy_cache/"]121314class DeployError(Exception):15    pass161718def _ctx(alias, ip):19    lan = nodes.load_lan()["ips"]20    return {"HOME": config.home_of(ip), "NODE": alias, "LAN_IP": ip, "ips": lan, "USER": config.user_of(ip)}212223def _abs(p, ip=None):24    """~ → home du nœud (dépend de l'utilisateur : simon-pierreboucher, ou celui d'un REMOTE_NODE)."""25    return p.replace("~", config.home_of(ip), 1) if p.startswith("~") else p262728def _log(app, msg):29    line = "%s %s" % (time.strftime("%H:%M:%S"), msg)30    print(line, flush=True)31    with open(os.path.join(config.LOG_DIR, app + ".log"), "a") as f:32        f.write(time.strftime("%Y-%m-%d ") + line + "\n")333435def _ip(alias):36    ip = nodes.ip_of(alias)37    if not ip:38        raise DeployError("nœud %s injoignable sur le LAN" % alias)39    return ip404142def _ensure_dir(ip, d):43    """Crée le répertoire cible ; sous /opt (racine root) passe par sudo -S avec le mot de passe de ~/dispatch/.sudo."""44    if ssh.ok(ip, "mkdir -p %s 2>/dev/null && test -w %s" % (shlex.quote(d), shlex.quote(d))):45        return46    pwf = config.sudo_pw_file(ip)47    pw = open(pwf).read().strip() if os.path.exists(pwf) else None48    if not pw:49        raise DeployError("impossible de créer %s (sudo requis, %s absent)" % (d, pwf))50    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)))51    if rc != 0:52        raise DeployError("création de %s impossible : %s" % (d, err.strip()[-200:]))535455def stage_dir(app):56    return os.path.join(config.STAGE_DIR, app)575859# ---------------------------------------------------------------- fetch : nœud -> staging60def fetch(app, from_alias, final=False):61    m = manifest.load(app)62    ip = _ip(from_alias)63    _log(app, "fetch %s depuis %s (%s)%s" % (m["dir"], from_alias, ip, " [final]" if final else ""))64    excl = SYNC_BASE_EXCLUDES + list(m.get("sync_excludes") or [])65    rc, out, err = ssh.rsync_pull(ip, _abs(m["dir"], ip), os.path.join(stage_dir(app), "dir"), excludes=excl)66    if rc == 23 and not final:67        _log(app, "  avertissement pré-copie à chaud (fichiers en cours d'écriture, repris au delta final) : %s" % err.strip().splitlines()[0][:120])68    elif rc not in (0, 24):69        raise DeployError("rsync fetch a échoué (%s): %s" % (rc, err.strip()[-300:]))70    for extra in m.get("extra_paths") or []:71        dest = os.path.join(stage_dir(app), "extra", extra.strip("~/").replace("/", "__"))72        rc2, out2, err2 = ssh.run(ip, "test -e %s" % shlex.quote(_abs(extra, ip)))73        if rc2 != 0:74            _log(app, "  extra absent sur la source, ignoré : %s" % extra)75            continue76        isdir = ssh.ok(ip, "test -d %s" % shlex.quote(_abs(extra, ip)))77        if isdir:78            rc2, out2, err2 = ssh.rsync_pull(ip, _abs(extra, ip), dest, delete=True)79        else:80            os.makedirs(dest, exist_ok=True)81            import subprocess82            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)83    json.dump({"app": app, "from": from_alias, "ip": ip, "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "final": final},84              open(os.path.join(stage_dir(app), "fetch.json"), "w"), indent=2)85    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]86    _log(app, "  ok — " + " ; ".join(s.strip() for s in stat))87    return True888990# ---------------------------------------------------------------- stop / start / status sur un nœud91def stop(app, alias, keep_dir=True):92    m = manifest.load(app)93    ip = _ip(alias)94    _log(app, "stop sur %s" % alias)95    names = manifest.pm2_names(m) + manifest.legacy_pm2_names(m)96    script = ""97    if names:98        script += "for n in %s; do pm2 delete $n >/dev/null 2>&1 && echo \"  pm2 delete $n\"; done; pm2 save --force >/dev/null 2>&1\n" % " ".join(names)99    for lbl in manifest.launchd_labels(m):100        script += "launchctl bootout gui/$(id -u)/%s >/dev/null 2>&1 && echo '  bootout %s'; rm -f ~/Library/LaunchAgents/%s.plist\n" % (lbl, lbl, lbl)101    for hook in (m.get("hooks") or {}).get("pre_stop") or []:102        script += hook + "\n"103    # tue ce qui écoute encore sur les ports de l'app (orphelins)104    for p in m["requires"].get("ports") or []:105        script += "sleep 1; for pid in $(lsof -nP -iTCP:%s -sTCP:LISTEN -t 2>/dev/null); do kill $pid 2>/dev/null && echo '  kill port %s pid '$pid; done\n" % (p, p)106    rc, out, err = ssh.run(ip, script, timeout=180)107    for l in out.splitlines():108        _log(app, l)109    registry.log(app, "stopped", node=alias)110    return rc == 0111112113def _install_hooks(m, ip, ctx):114    """Commandes post-sync : réécritures .env, hooks, inscription pousseur."""115    script = ""116    d = _abs(m["dir"], ip)117    for fname, kv in (m.get("env_overrides") or {}).items():118        target = os.path.join(d, fname)119        kv = manifest.render(kv, ctx)120        py = "import re,sys,os\np=%r\nkv=%r\ns=open(p).read() if os.path.exists(p) else ''\nfor k,v in kv.items():\n    line=k+'='+v\n    if re.search(r'^'+re.escape(k)+r'=.*$', s, re.M): s=re.sub(r'^'+re.escape(k)+r'=.*$', line.replace('\\\\','\\\\\\\\'), s, flags=re.M)\n    else: s=s.rstrip('\\n')+'\\n'+line+'\\n'\nopen(p,'w').write(s)\nprint('  .env réécrit: '+', '.join(kv))" % (target, kv)121        script += "python3 - <<'PYEOF'\n%s\nPYEOF\n" % py122    if m.get("ka_repo"):123        script += "grep -qx %s ~/.ka-pousseur-repos 2>/dev/null || echo %s >> ~/.ka-pousseur-repos; echo '  pousseur: %s inscrit'\n" % (shlex.quote(m["dir"]), shlex.quote(m["dir"]), m["dir"])124    for hook in (m.get("hooks") or {}).get("post_sync") or []:125        script += "cd %s && { %s ; }\n" % (shlex.quote(d), manifest.render(hook, ctx))126    return script127128129def _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, 0135    return lines[0], int(lines[1] or 0)136137138def _stage_git_head(sd):139    import subprocess140    p = subprocess.run(["git", "-C", sd, "rev-parse", "HEAD"], capture_output=True, text=True)141    return p.stdout.strip() if p.returncode == 0 else None142143144def deploy(app, alias, reinstall=False, skip_sync=False, force=False):145    """staging -> nœud, puis démarrage (PM2 + launchd) et healthcheck.146147    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 rsync149    `--delete` écraserait du travail : on refuse, sauf `--force`. `mld move`/`mld fetch --from <nœud>` repartent du nœud150    (le staging contient alors le même .git) et passent donc la garde."""151    m = manifest.load(app)152    ip = _ip(alias)153    ctx = _ctx(alias, ip)154    sd = os.path.join(stage_dir(app), "dir")155    if not skip_sync and not os.path.isdir(sd):156        raise DeployError("staging vide pour %s — faire `mld fetch %s --from <nœud>` ou `mld stage`" % (app, app))157    _log(app, "deploy → %s (%s)" % (alias, ip))158    # 1. runtimes159    scan = nodes.load_scan().get(alias) or nodes.probe(alias, ip)160    missing = [r for r in m["requires"]["runtimes"] if not scan.get("runtimes", {}).get(r)]161    if missing:162        raise DeployError("runtimes manquants sur %s : %s (→ `mld prepare %s`)" % (alias, ", ".join(missing), alias))163    # 2. sync164    d = _abs(m["dir"], ip)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))173        _ensure_dir(ip, d)174        excl = SYNC_BASE_EXCLUDES + list(m.get("sync_excludes") or [])175        rc, out, err = ssh.rsync_push(sd, ip, d, excludes=excl, delete=True)176        if rc not in (0, 24):177            raise DeployError("rsync deploy a échoué (%s): %s" % (rc, err.strip()[-300:]))178        stat = [l.strip() for l in out.splitlines() if "Total transferred file size" in l or "Total bytes sent" in l]179        _log(app, "  sync ok — %s" % (stat[0] if stat else ""))180        for extra in m.get("extra_paths") or []:181            src = os.path.join(stage_dir(app), "extra", extra.strip("~/").replace("/", "__"))182            if not os.path.exists(src):183                continue184            if os.path.isdir(src) and not os.path.exists(os.path.join(src, os.path.basename(extra))):185                ssh.rsync_push(src, ip, _abs(extra, ip), delete=False)186            else:187                import subprocess188                ssh.run(ip, "mkdir -p %s" % shlex.quote(os.path.dirname(_abs(extra, ip))))189                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)190            _log(app, "  extra: %s" % extra)191    # 3. install (optionnel) + hooks192    script = "cd %s || exit 3\n" % shlex.quote(d)193    if reinstall:194        script += "if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; elif [ -f package-lock.json ]; then npm ci; elif [ -f package.json ]; then npm install; fi\n"195        script += "if [ -f requirements.txt ] && [ -x .venv/bin/pip ]; then .venv/bin/pip install -q -r requirements.txt; fi\n"196    elif m.get("rebuild", True) and os.path.exists(os.path.join(sd, "package.json")) and os.path.isdir(os.path.join(sd, "node_modules")):197        script += "npm rebuild >/dev/null 2>&1 && echo '  npm rebuild ok' || echo '  npm rebuild: avertissement'\n"198    script += _install_hooks(m, ip, ctx)199    rc, out, err = ssh.run(ip, script, timeout=1800)200    for l in out.splitlines()[-15:]:201        _log(app, l)202    if rc != 0:203        raise DeployError("post-sync a échoué (%s): %s" % (rc, (err or out).strip()[-400:]))204    # 4. lancement205    start(app, alias, m=m, ctx=ctx, ip=ip)206    # 5. santé locale, puis route publique (MacLustr Tunnel) pointée vers CE nœud, puis santé publique207    local_ok, code = _health_local(ip, m)208    tun = None209    if local_ok and tunnel.spec(m, alias):210        tun = _publish(app, alias, m, ip)211    healthy, detail = health(app, alias, ip=ip, m=m, timeout=30, public=bool(tun))212    registry.set_app(app, node=alias, ip=ip, port=m.get("port"), domain=m.get("domain"), dir=m["dir"], label=m.get("label") or app,213                     health_path=m.get("health_path") or "/",214                     processes=manifest.pm2_names(m), launchd=manifest.launchd_labels(m), deployed=time.strftime("%Y-%m-%d %H:%M"),215                     tunnel=tun, status="online" if healthy else "unhealthy", health=detail)216    registry.log(app, "deployed", node=alias, healthy=healthy)217    _log(app, "  santé : %s" % detail)218    if not healthy:219        names = manifest.pm2_names(m)220        rc, out, _ = ssh.run(ip, "pm2 logs %s --nostream --lines 6 2>/dev/null | grep -v '^\\[TAILING' | tail -12" % " ".join(names), timeout=40)221        for l in out.splitlines():222            _log(app, "  log: " + l[:160])223        raise DeployError("healthcheck KO sur %s : %s" % (alias, detail))224    return True225226227def start(app, alias, m=None, ctx=None, ip=None):228    m = m or manifest.load(app)229    ip = ip or _ip(alias)230    ctx = ctx or _ctx(alias, ip)231    _log(app, "start sur %s" % alias)232    run_dir = _abs(config.RUN_DIR, ip)233    eco = render.ecosystem(m, ctx)234    eco_path = "%s/%s.config.cjs" % (run_dir, app)235    ssh.write_remote_file(ip, eco_path, eco, mode="600")236    script = "mkdir -p %s\n" % run_dir237    names = manifest.pm2_names(m)238    if names or manifest.legacy_pm2_names(m):239        script += "for n in %s; do pm2 delete $n >/dev/null 2>&1; done\n" % " ".join(names + manifest.legacy_pm2_names(m))240    if names:241        script += "pm2 start %s --update-env 2>&1 | grep -E 'error|Error|\\[PM2\\]' | head -5; pm2 save --force >/dev/null 2>&1; pm2 jlist | python3 -c \"import json,sys; L=json.load(sys.stdin); print('  pm2: '+', '.join(p['name']+'='+p['pm2_env']['status'] for p in L if p['name'] in %s))\" 2>/dev/null || pm2 ls | grep -E '%s'\n" % (shlex.quote(eco_path), json.dumps(names), "|".join(names))242    for item in m.get("launchd") or []:243        pl = render.plist(item, ctx)244        ppath = "%s/Library/LaunchAgents/%s.plist" % (ctx["HOME"], item["label"])245        ssh.write_remote_file(ip, ppath, pl, mode="644")246        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"])247    for hook in (m.get("hooks") or {}).get("post_start") or []:248        script += "cd %s && { %s ; }\n" % (shlex.quote(_abs(m["dir"], ip)), manifest.render(hook, ctx))249    rc, out, err = ssh.run(ip, script, timeout=300)250    for l in out.splitlines():251        _log(app, l)252    if err.strip():253        _log(app, "  stderr: " + err.strip()[-200:])254    return rc == 0255256257def _publish(app, alias, m, ip):258    """Raccorde le nœud au tunnel si besoin et pointe la route publique de l'app dessus. Retourne le spec de route ou None."""259    log = lambda s: _log(app, s)260    try:261        st = None262        try:263            st = tunnel.state((m.get("tunnel") or {}).get("gateway"))264        except tunnel.TunnelError:265            pass266        if not tunnel.ensure_peer(alias, (m.get("tunnel") or {}).get("gateway"), log=log, st=st):267            _log(app, "  tunnel : %s n'a pas de handshake avec la passerelle — route pointée quand même" % alias)268        s = tunnel.ensure_route(m, alias, log=log)269        if not s:270            return None271        return {"gateway": s["gateway"], "domain": s["domain"], "upstream": s["upstreams"][0], "url": "https://%s" % s["domain"]}272    except tunnel.TunnelError as e:273        _log(app, "  tunnel : %s" % e)274        return None275276277def _health_local(ip, m, timeout=90):278    """(ok, code|texte) : HTTP local sur le port de l'app, ou processus PM2 online si l'app n'a pas de port."""279    if not m.get("port"):280        names = manifest.pm2_names(m)281        if not names:282            return True, "aucun port/processus à vérifier"283        rc, out, _ = ssh.run(ip, "pm2 jlist | python3 -c \"import json,sys; L=json.load(sys.stdin); print(sum(1 for p in L if p['name'] in %s and p['pm2_env']['status']=='online'))\"" % json.dumps(names))284        n = int((out.strip() or "0").splitlines()[-1] or 0)285        return n == len(names), "%d/%d processus PM2 online" % (n, len(names))286    url = "http://127.0.0.1:%s%s" % (m["port"], m.get("health_path") or "/")287    deadline = time.time() + timeout288    code = "000"289    while True:290        rc, out, _ = ssh.run(ip, "curl -s -o /dev/null -m 8 -w '%%{http_code}' %s" % shlex.quote(url), timeout=20)291        code = out.strip()[-3:] if out.strip() else "000"292        if code.isdigit() and code not in ("000",) and int(code) < 500:293            break294        if time.time() >= deadline:295            break296        time.sleep(4)297    return code.isdigit() and code != "000" and int(code) < 500, code298299300def _health_public(m, tries=20):301    """Code HTTP de https://<domaine><health_path> à travers la passerelle (tries × 5 s max)."""302    import subprocess303    pub = ""304    for _ in range(tries):305        p = subprocess.run(["curl", "-s", "-o", "/dev/null", "-m", "12", "-w", "%{http_code}", "https://%s%s" % (m["domain"], m.get("health_path") or "/")], capture_output=True, text=True)306        pub = p.stdout.strip()307        if pub.isdigit() and int(pub) < 500 and pub != "404":308            break309        time.sleep(5)310    return pub311312313def health(app, alias, ip=None, m=None, timeout=90, public=True):314    """Santé locale (port/PM2) puis publique (via MacLustr Tunnel) si l'app a un domaine. Retourne (ok, détail)."""315    m = m or manifest.load(app)316    ip = ip or _ip(alias)317    local_ok, code = _health_local(ip, m, timeout=timeout)318    if not m.get("port"):319        return local_ok, code320    pub = ""321    if public and m.get("domain") and local_ok and tunnel.spec(m, alias):322        pub = _health_public(m, tries=max(2, min(20, timeout // 5)))323    detail = "local %s" % code + (" / public https://%s %s" % (m["domain"], pub) if pub else "")324    ok = local_ok and (not pub or (pub.isdigit() and int(pub) < 500 and pub != "404"))325    return ok, detail326327328def retire(app, alias, remove_dir=True):329    """Arrête et efface la copie d'un nœud (après migration réussie)."""330    m = manifest.load(app)331    ip = _ip(alias)332    stop(app, alias)333    if remove_dir:334        d = _abs(m["dir"], ip)335        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)336        for l in out.splitlines():337            _log(app, l)338    registry.log(app, "retired", node=alias)339    return True340341342def move(app, to_alias, from_alias=None, keep_source=False, reinstall=False):343    """Migration complète : pré-sync à chaud, arrêt, sync final, déploiement, santé, retrait de la source (ou rollback)."""344    from_alias = from_alias or registry.node_of(app) or manifest.load(app).get("source_node")345    if not from_alias:346        raise DeployError("nœud source inconnu pour %s (préciser --from)" % app)347    if from_alias == to_alias:348        _log(app, "déjà sur %s → redéploiement en place" % to_alias)349        fetch(app, from_alias)350        stop(app, from_alias)351        fetch(app, from_alias, final=True)352        return deploy(app, to_alias, reinstall=reinstall)353    _log(app, "MOVE %s : %s → %s" % (app, from_alias, to_alias))354    fetch(app, from_alias)                 # 1. copie à chaud (gros volume)355    stop(app, from_alias)                  # 2. arrêt source356    fetch(app, from_alias, final=True)     # 3. delta final (cohérence des bases)357    try:358        deploy(app, to_alias, reinstall=reinstall)   # 4. cible359    except DeployError as e:360        _log(app, "ÉCHEC sur %s : %s — rollback sur %s" % (to_alias, e, from_alias))361        try:362            stop(app, to_alias)363        except Exception:364            pass365        start(app, from_alias)366        m = manifest.load(app)367        ip0 = _ip(from_alias)368        tun = _publish(app, from_alias, m, ip0) if tunnel.spec(m, from_alias) else None   # la route publique revient sur la source369        ok, det = health(app, from_alias, ip=ip0, m=m)370        registry.set_app(app, node=from_alias, ip=ip0, tunnel=tun, status="online" if ok else "unhealthy", health=det)371        raise372    if not keep_source:373        retire(app, from_alias)            # 5. nettoyage source374    registry.log(app, "moved", **{"from": from_alias, "to": to_alias})375    _log(app, "MOVE terminé : %s tourne sur %s" % (app, to_alias))376    return True377378379def status(app=None):380    r = registry.load()381    apps = [app] if app else sorted(r["apps"])382    rows = []383    for a in apps:384        v = r["apps"].get(a)385        if not v:386            rows.append((a, "—", "", "", "non déployée", ""))387            continue388        rows.append((a, v.get("node"), v.get("port"), v.get("domain") or "", v.get("status"), v.get("deployed")))389    return rows390391392def live_status(app=None, public=True):393    """Vérifie en live (PM2 + port + domaine via le tunnel) chaque app du registre. Une seule sauvegarde du registre à la fin."""394    r = registry.load()395    apps = [app] if app else sorted(r["apps"])396    out = []397    changes = {}398    for a in apps:399        v = r["apps"].get(a)400        if not v:401            continue402        m = manifest.load(a)403        try:404            ip = _ip(v["node"])405            ok, det = health(a, v["node"], ip=ip, m=m, timeout=10, public=public)406        except DeployError as e:407            ok, det = False, str(e)408        changes[a] = {"status": "online" if ok else "unhealthy", "health": det}409        out.append((a, v["node"], v.get("domain") or "", "OK" if ok else "KO", det))410    registry.update_many(changes)411    return out412413414# ---------------------------------------------------------------------------415# REMOTE-FIRST (2026-10-02) : `mld adopt <app>` fait du dossier déployé sur le nœud un clone git416# (origin = spbgit sur M1M32), l'état du nœud devenant la vérité (commit « adopt »). Les fichiers417# suivis absents du nœud (exclus du rsync : tests, exemples…) sont restaurés depuis origin/main.418# ---------------------------------------------------------------------------419ADOPT_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"420421422def 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 app428    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 subprocess435            subprocess.run(["git", "init", "-q", "--bare", bare], check=True)436    url = "gitsrv:srv/git/%s.git" % repo437    if alias == "M1M32":438        url = bare                           # la passerelle héberge spbgit : chemin local439    script = """set -e440cd %(d)s || { echo "ADOPT_ERR dossier absent %(d)s"; exit 3; }441if [ ! -d .git ]; then git init -q; echo "init"; fi442old=$(git remote get-url origin 2>/dev/null || true)443if [ -n "$old" ] && [ "$old" != %(url)s ]; then git remote rename origin old-origin 2>/dev/null || true; echo "ancien origin conservé sous old-origin : $old"; fi444git remote get-url origin >/dev/null 2>&1 || git remote add origin %(url)s445[ -f .gitignore ] || printf '%(gi)s' > .gitignore446git fetch -q origin main 2>/dev/null && remote_main=1 || remote_main=0447if [ $remote_main = 1 ]; then448  git reset -q origin/main 2>/dev/null || git reset -q FETCH_HEAD449  git symbolic-ref HEAD refs/heads/main 2>/dev/null || true450  # fichiers suivis absents du nœud (exclus du rsync) : on les reprend de main451  git status --porcelain | awk '$1=="D"{print substr($0,4)}' | while IFS= read -r f; do git checkout -q -- "$f" 2>/dev/null || true; done452fi453git add -A454big=$(git diff --cached --name-only -z | xargs -0 stat -f '%%z %%N' 2>/dev/null | awk '$1>52428800{print $2}' | head -5)455if [ -n "$big" ]; then echo "ADOPT_ERR fichiers > 50 Mo à ignorer d'abord :"; echo "$big"; git reset -q; exit 4; fi456n=$(git diff --cached --name-only | wc -l | tr -d ' ')457echo "diff_vs_main=$n"458%(dry)s459if [ "$n" != 0 ] || ! git rev-parse HEAD >/dev/null 2>&1; then460  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"461fi462git branch -M main 2>/dev/null || true463git push -q -u origin main 2>&1 | tail -1 || true464git branch --set-upstream-to=origin/main main >/dev/null 2>&1 || true465echo "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 True476
Files · 64a890b816bf99803e375175165a159ed076854b
Menu

Theme