fix(sdlc-rename): cutover: ожидание порта после bootout, честное состояние шага 2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 12:13:24 +04:00
parent 40cb66158f
commit 1394f39763
2 changed files with 47 additions and 9 deletions

View File

@ -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 ["ничего не изменено"]

View File

@ -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):