From 1394f39763ee3440bde39ce11051d05b237aa27b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 12:13:24 +0400 Subject: [PATCH] =?UTF-8?q?fix(sdlc-rename):=20cutover:=20=D0=BE=D0=B6?= =?UTF-8?q?=D0=B8=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20bootout,=20=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D1=88=D0=B0=D0=B3=D0=B0=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- scripts/cutover.py | 29 ++++++++++++++++++++++++----- tests/test_cutover.py | 27 +++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/scripts/cutover.py b/scripts/cutover.py index 7aaf54f..0d1600b 100644 --- a/scripts/cutover.py +++ b/scripts/cutover.py @@ -204,6 +204,19 @@ def _port_open(port: int) -> bool: return False +_sleep = time.sleep +_clock = time.monotonic + + +def _wait_port_closed(port: int, timeout: float = 10.0, interval: float = 0.5) -> bool: + deadline = _clock() + timeout + while _port_open(port): + if _clock() >= deadline: + return False + _sleep(interval) + return True + + def _domain() -> str: return "gui/%d" % os.getuid() @@ -233,7 +246,7 @@ 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): + if not _wait_port_closed(cfg.port): raise RuntimeError("после остановки %s порт %d всё ещё занят: хаб не остановлен" % (OLD_LABEL, cfg.port)) def move_data() -> None: @@ -332,13 +345,14 @@ def main(argv: List[str]) -> int: print("остановлено на шаге %s: %s" % (desc.split(".")[0], e)) print("Выполнено: " + ("; ".join(done) if done else "ничего")) print("Промежуточное состояние (по выполненным шагам; упавший шаг мог выполниться частично, проверьте):") - for line in _state_lines(done): + for line in _state_lines(done, desc): 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." % ( + "launchctl bootstrap %s %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())) + cfg.new_checkout, cfg.old_checkout, cfg.launch_agents / (OLD_LABEL + ".plist"), _domain(), + cfg.launch_agents / (OLD_LABEL + ".plist"))) _print_tail(backups, leftovers) return 1 done.append(desc) @@ -350,7 +364,7 @@ def main(argv: List[str]) -> int: return 0 -def _state_lines(done: List[str]) -> List[str]: +def _state_lines(done: List[str], failed: str = "") -> List[str]: n = {d.split(".")[0] for d in done} out = [] if "2" in n: @@ -363,6 +377,11 @@ def _state_lines(done: List[str]) -> List[str]: out.append("ru.aepif.sdlc-hub установлен и запущен") if "6" in n: out.append("файлы проектов изменены (копии *%s)" % SUFFIX) + if failed: + if failed.startswith("2."): + out.append("упавший шаг 2: хаб ru.aepif.jira-hub мог быть выгружен, его plist удалён") + else: + out.append("упавший шаг %s мог выполниться частично, проверьте" % failed.split(".")[0]) return out or ["ничего не изменено"] diff --git a/tests/test_cutover.py b/tests/test_cutover.py index 659101c..ace66ae 100644 --- a/tests/test_cutover.py +++ b/tests/test_cutover.py @@ -185,12 +185,31 @@ def test_step7_add_before_remove_and_remove_not_fatal(env, monkeypatch, capsys): assert "ПРЕДУПРЕЖДЕНИЕ" in capsys.readouterr().out -def test_step2_fails_if_port_still_open(env, monkeypatch): +def test_step2_fails_if_port_still_open(env, monkeypatch, capsys): 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]() + t = [0.0] + monkeypatch.setattr(cutover, "_clock", lambda: t[0]) + monkeypatch.setattr(cutover, "_sleep", lambda d: t.__setitem__(0, t[0] + d)) + monkeypatch.setattr(cutover, "preflight", lambda cfg: []) + assert cutover.main(["--apply"]) == 1 + out = capsys.readouterr().out + assert "порт" in out and "остановлено на шаге 2" in out + assert "мог быть выгружен" in out and "plist удалён" in out + assert "ничего не изменено" not in out + assert "launchctl bootstrap gui/%d %s" % (os.getuid(), env["tmp"] / "la" / "ru.aepif.jira-hub.plist") in out + assert t[0] >= 10 + + +def test_step2_waits_for_port_to_close(env, monkeypatch): + monkeypatch.setattr(cutover.subprocess, "run", _Fake()) + answers = iter([True, True, True, False]) + monkeypatch.setattr(cutover, "_port_open", lambda port: next(answers)) + t = [0.0] + monkeypatch.setattr(cutover, "_clock", lambda: t[0]) + monkeypatch.setattr(cutover, "_sleep", lambda d: t.__setitem__(0, t[0] + d)) + cutover.steps(cutover.Config.from_env())[0][1]() + assert 0 < t[0] < 10 def test_failure_prints_state_and_rollback(env, monkeypatch, capsys):