fix(sdlc-rename): cutover: откат при сбое, проверка инструментов, порядок add/remove
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7ac525699a
commit
40cb66158f
@ -62,14 +62,21 @@ class Config:
|
||||
return self.bridge / "sdlc-hub"
|
||||
|
||||
|
||||
def _git_env() -> dict:
|
||||
return dict(os.environ, GIT_OPTIONAL_LOCKS="0", GIT_TERMINAL_PROMPT="0")
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
env = dict(os.environ, GIT_OPTIONAL_LOCKS="0")
|
||||
return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, env=env)
|
||||
return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, env=_git_env(),
|
||||
stdin=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def preflight(cfg: Config) -> List[str]:
|
||||
"""Проверки шага 1. Только чтение."""
|
||||
problems: List[str] = []
|
||||
for tool in ("uv", "make", "claude", "launchctl", "node"):
|
||||
if not shutil.which(tool):
|
||||
problems.append("не найден инструмент %s в PATH (нужен для шагов переключения)" % tool)
|
||||
old = cfg.old_checkout
|
||||
if not old.is_dir():
|
||||
problems.append("старый checkout %s не найден" % old)
|
||||
@ -90,7 +97,8 @@ def preflight(cfg: Config) -> List[str]:
|
||||
problems.append("не удалось получить origin/main из %s" % old)
|
||||
elif remote_head != head:
|
||||
problems.append("HEAD не совпадает с origin/main: сделайте git pull --ff-only в %s" % old)
|
||||
lr = subprocess.run(["git", "ls-remote", cfg.remote], capture_output=True, text=True)
|
||||
lr = subprocess.run(["git", "ls-remote", cfg.remote], capture_output=True, text=True, env=_git_env(),
|
||||
stdin=subprocess.DEVNULL)
|
||||
if lr.returncode != 0:
|
||||
problems.append("репозиторий %s недоступен: переименуйте aep/jira-hub -> aep/sdlc-hub в Gitea (%s)"
|
||||
% (cfg.remote, lr.stderr.strip().splitlines()[-1] if lr.stderr.strip() else "нет ответа"))
|
||||
@ -188,6 +196,14 @@ def _run(*cmd: str, cwd: Path = None, check: bool = True) -> subprocess.Complete
|
||||
return subprocess.run(list(cmd), cwd=str(cwd) if cwd else None, check=check)
|
||||
|
||||
|
||||
def _port_open(port: int) -> bool:
|
||||
try:
|
||||
socket.create_connection(("127.0.0.1", port), timeout=1).close()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _domain() -> str:
|
||||
return "gui/%d" % os.getuid()
|
||||
|
||||
@ -217,6 +233,8 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None)
|
||||
_run("launchctl", "bootout", "%s/%s" % (_domain(), OLD_LABEL), check=False) # не загружен - не ошибка
|
||||
if old_plist.exists():
|
||||
old_plist.unlink()
|
||||
if _port_open(cfg.port):
|
||||
raise RuntimeError("после остановки %s порт %d всё ещё занят: хаб не остановлен" % (OLD_LABEL, cfg.port))
|
||||
|
||||
def move_data() -> None:
|
||||
shutil.move(str(cfg.data_root / "jira-hub"), str(cfg.data_root / "sdlc-hub"))
|
||||
@ -237,12 +255,11 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None)
|
||||
_run("launchctl", "bootstrap", _domain(), str(new_plist))
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
socket.create_connection(("127.0.0.1", cfg.port), timeout=1).close()
|
||||
if _port_open(cfg.port):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
raise RuntimeError("хаб не ответил на порту %d за 30 с (лог %s)" % (cfg.port, log))
|
||||
time.sleep(1)
|
||||
raise RuntimeError("хаб не ответил на порту %d за 30 с (лог %s); остановить: launchctl bootout %s/%s"
|
||||
% (cfg.port, log, _domain(), NEW_LABEL))
|
||||
|
||||
def consumers() -> None:
|
||||
for project in cfg.projects:
|
||||
@ -264,9 +281,12 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None)
|
||||
leftovers.extend("%s: %s" % (f, ln) for ln in rewrite_text(f))
|
||||
|
||||
def user_scope() -> None:
|
||||
_run("claude", "mcp", "remove", "-s", "user", "jira-hub")
|
||||
_run("claude", "mcp", "add", "-s", "user", "sdlc-jira", "--",
|
||||
"uv", "run", "--project", str(new_co), "sdlc-hub", "mcp", "jira")
|
||||
r = _run("claude", "mcp", "remove", "-s", "user", "jira-hub", check=False)
|
||||
if r.returncode != 0:
|
||||
print(" ПРЕДУПРЕЖДЕНИЕ: claude mcp remove -s user jira-hub не выполнился (код %d): удалите запись руками"
|
||||
% r.returncode)
|
||||
|
||||
return [
|
||||
("2. остановить launchd %s и удалить %s" % (OLD_LABEL, old_plist), stop),
|
||||
@ -276,7 +296,7 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None)
|
||||
("5. поставить и запустить %s (лог %s), дождаться порта %d" % (NEW_LABEL, log, cfg.port), start),
|
||||
("6. править проекты %s (.mcp.json, .claude/settings*.json, CLAUDE.md, .claude/skills; копии *%s)"
|
||||
% (", ".join(str(p) for p in cfg.projects), SUFFIX), consumers),
|
||||
("7. user scope Claude: remove jira-hub, add sdlc-jira", user_scope),
|
||||
("7. user scope Claude: add sdlc-jira, затем remove jira-hub", user_scope),
|
||||
]
|
||||
|
||||
|
||||
@ -303,17 +323,50 @@ def main(argv: List[str]) -> int:
|
||||
if problems:
|
||||
print("Остановлено: исправьте проблемы шага 1, ничего не изменено.")
|
||||
return 1
|
||||
done: List[str] = []
|
||||
for desc, fn in plan:
|
||||
print("-> " + desc)
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
print("остановлено на шаге %s: %s" % (desc.split(".")[0], e))
|
||||
print("Выполнено: " + ("; ".join(done) if done else "ничего"))
|
||||
print("Промежуточное состояние (по выполненным шагам; упавший шаг мог выполниться частично, проверьте):")
|
||||
for line in _state_lines(done):
|
||||
print(" " + line)
|
||||
print("Откат: остановить ru.aepif.sdlc-hub (launchctl bootout %s/%s), перенести каталоги обратно "
|
||||
"(данные %s -> %s, checkout %s -> %s, origin вернуть на aep/jira-hub), вернуть plist %s и "
|
||||
"launchctl bootstrap %s; раздел «Переключение с jira-hub» в README." % (
|
||||
_domain(), NEW_LABEL, cfg.data_root / "sdlc-hub", cfg.data_root / "jira-hub",
|
||||
cfg.new_checkout, cfg.old_checkout, cfg.launch_agents / (OLD_LABEL + ".plist"), _domain()))
|
||||
_print_tail(backups, leftovers)
|
||||
return 1
|
||||
done.append(desc)
|
||||
print(" готово")
|
||||
print("\nОсталось владельцу:")
|
||||
print(" - /mcp reconnect в открытых сессиях Claude")
|
||||
print(" - codegraph init в %s и в каждом worktree (.wt/*)" % cfg.new_checkout)
|
||||
_print_tail(backups, leftovers)
|
||||
return 0
|
||||
|
||||
|
||||
def _state_lines(done: List[str]) -> List[str]:
|
||||
n = {d.split(".")[0] for d in done}
|
||||
out = []
|
||||
if "2" in n:
|
||||
out.append("хаб ru.aepif.jira-hub остановлен, его plist удалён")
|
||||
if "3" in n:
|
||||
out.append("каталог данных перенесён в sdlc-hub")
|
||||
if "4" in n:
|
||||
out.append("checkout перенесён в sdlc-hub, origin переключён")
|
||||
if "5" in n:
|
||||
out.append("ru.aepif.sdlc-hub установлен и запущен")
|
||||
if "6" in n:
|
||||
out.append("файлы проектов изменены (копии *%s)" % SUFFIX)
|
||||
return out or ["ничего не изменено"]
|
||||
|
||||
|
||||
def _print_tail(backups: List[Path], leftovers: List[str]) -> None:
|
||||
print(" - проверить правки и удалить копии *%s:" % SUFFIX)
|
||||
for b in backups:
|
||||
print(" " + str(b))
|
||||
@ -321,7 +374,6 @@ def main(argv: List[str]) -> int:
|
||||
print(" - проверить руками (остались упоминания jira-hub):")
|
||||
for ln in leftovers:
|
||||
print(" " + ln)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -60,6 +60,7 @@ def env(tmp_path, monkeypatch):
|
||||
"CUTOVER_LOGS": tmp_path / "logs", "CUTOVER_REMOTE": bare, "CUTOVER_PROJECTS": proj,
|
||||
}.items():
|
||||
monkeypatch.setenv(k, str(v))
|
||||
monkeypatch.setattr(cutover.shutil, "which", lambda name: "/usr/bin/" + name)
|
||||
return {"tmp": tmp_path, "bridge": bridge, "old": old, "data": data, "bare": bare, "proj": proj}
|
||||
|
||||
|
||||
@ -148,3 +149,60 @@ def test_dirty_checkout_refused(env):
|
||||
|
||||
def test_preflight_clean(env):
|
||||
assert cutover.preflight(cutover.Config.from_env()) == []
|
||||
|
||||
|
||||
def test_preflight_reports_missing_tool(env, monkeypatch):
|
||||
monkeypatch.setattr(cutover.shutil, "which", lambda name: None if name in ("claude", "make") else "/x/" + name)
|
||||
probs = cutover.preflight(cutover.Config.from_env())
|
||||
assert any("claude" in s for s in probs) and any("make" in s for s in probs)
|
||||
assert not any("uv " in s for s in probs)
|
||||
|
||||
|
||||
class _Fake:
|
||||
def __init__(self, rc_by_cmd=None):
|
||||
self.calls = []
|
||||
self.rc_by_cmd = rc_by_cmd or {}
|
||||
|
||||
def __call__(self, cmd, **kw):
|
||||
self.calls.append((list(cmd), kw))
|
||||
rc = self.rc_by_cmd.get(cmd[2] if len(cmd) > 2 else "", 0)
|
||||
r = subprocess.CompletedProcess(cmd, rc)
|
||||
if kw.get("check") and rc:
|
||||
raise subprocess.CalledProcessError(rc, cmd)
|
||||
return r
|
||||
|
||||
|
||||
def test_step7_add_before_remove_and_remove_not_fatal(env, monkeypatch, capsys):
|
||||
fake = _Fake({"remove": 1})
|
||||
monkeypatch.setattr(cutover.subprocess, "run", fake)
|
||||
cfg = cutover.Config.from_env()
|
||||
desc, fn = cutover.steps(cfg)[-1]
|
||||
fn()
|
||||
cmds = [c for c, _ in fake.calls]
|
||||
assert cmds[0][:3] == ["claude", "mcp", "add"] and "sdlc-jira" in cmds[0]
|
||||
assert cmds[1][:3] == ["claude", "mcp", "remove"]
|
||||
assert fake.calls[1][1].get("check") is False
|
||||
assert "ПРЕДУПРЕЖДЕНИЕ" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_step2_fails_if_port_still_open(env, monkeypatch):
|
||||
monkeypatch.setattr(cutover.subprocess, "run", _Fake())
|
||||
monkeypatch.setattr(cutover, "_port_open", lambda port: True)
|
||||
cfg = cutover.Config.from_env()
|
||||
with pytest.raises(RuntimeError, match="порт"):
|
||||
cutover.steps(cfg)[0][1]()
|
||||
|
||||
|
||||
def test_failure_prints_state_and_rollback(env, monkeypatch, capsys):
|
||||
def boom():
|
||||
raise RuntimeError("сломалось")
|
||||
|
||||
plan = [("2. a", lambda: None), ("3. b", lambda: None), ("4. c", boom)]
|
||||
monkeypatch.setattr(cutover, "preflight", lambda cfg: [])
|
||||
monkeypatch.setattr(cutover, "steps", lambda cfg, leftovers=None, backups=None: plan)
|
||||
assert cutover.main(["--apply"]) == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "остановлено на шаге 4: сломалось" in out
|
||||
assert "2. a" in out and "3. b" in out
|
||||
assert "хаб ru.aepif.jira-hub остановлен" in out and "каталог данных перенесён" in out
|
||||
assert "Откат" in out and "README" in out
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user