import http.client import shutil import threading from pathlib import Path from urllib.parse import urlencode import pytest import yaml from rtut import project as J, projectrun as PR, run as R1, run2, ui ROOT = Path(__file__).resolve().parent.parent def v2_pack(pid, manual_only_last=False): steps = [{"id": "first", "title": "First step", "actor": "op", "owner": "t", "uses": ["site"], "command": "echo {{site}}", "verify": {"done_when": "you saw the site", "expect": "manual"}}, {"id": "second", "title": "Second step", "actor": "op", "owner": "t", "verify": {"done_when": "second done", "expect": "manual"}}] if manual_only_last: steps.append({"id": "careful", "title": "Careful step", "actor": "op", "owner": "t", "auto_confirm": "manual-only", "verify": {"done_when": "you did it", "expect": "manual"}}) return {"spec": "runbook-pack/v0.2", "id": pid, "title": f"Pack {pid}", "owner": "t", "engine": "native", "framing": ["keep in mind"], "actors": [{"id": "op", "role": "Operator", "description": "does it"}], "datapoints": [{"id": "site", "label": "Site", "type": "string", "pattern": "[a-z]+", "default": "north", "ask_in": "main"}], "stages": [{"id": "main", "title": "Main", "goal": "do the things", "steps": steps}]} def write(path, data): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(yaml.safe_dump(data, sort_keys=False)) @pytest.fixture def srv(tmp_path): write(tmp_path / "runbooks/alpha/pack.yaml", v2_pack("t.alpha")) write(tmp_path / "runbooks/beta/pack.yaml", v2_pack("t.beta", manual_only_last=True)) write(tmp_path / "projects/demo/project.yaml", { "spec": "project/v0.1", "id": "demo", "title": "Demo project", "environment": "rehearsal", "attributes": {"site": "south"}, "runbooks": [{"pack": "t.alpha"}, {"pack": "t.beta", "after": ["t.alpha"]}]}) s = ui.serve([tmp_path], 0) threading.Thread(target=s.serve_forever, daemon=True).start() s.repo = tmp_path yield s s.shutdown() def req(s, method, path, form=None): c = http.client.HTTPConnection("127.0.0.1", s.server_address[1]) body = urlencode(form) if form is not None else None c.request(method, path, body, {"Host": "127.0.0.1", "Content-Type": "application/x-www-form-urlencoded"}) r = c.getresponse() return r.status, r.getheader("Location"), r.read().decode() def post(s, path, **form): return req(s, "POST", path, {"_t": s.app.token, **form}) def start_pr(s, mode="manual"): st, loc, _ = post(s, "/project/demo/runall", mode=mode) assert st == 303 and loc.startswith("/project-run/") return loc def finish_run(s, run_url): """Complete and close the member run behind a /run// URL, as an operator would in that page.""" parts = run_url.split("#")[0].split("/") i, rid = int(parts[2]), parts[3] run = R1.load(s.app.packs[i], rid) for sid in run2.flow(run).steps: assert post(s, f"/run/{i}/{rid}/{sid}/start")[0] == 303 assert post(s, f"/run/{i}/{rid}/{sid}/verify", ok="1")[0] == 303 assert post(s, f"/run/{i}/{rid}/{sid}/done")[0] == 303 assert post(s, f"/run/{i}/{rid}/finalize", outcome="succeeded")[0] == 303 # ---- starting ------------------------------------------------------------------------------------------------------------- def test_project_page_offers_the_whole_project_run_with_both_modes(srv): _, _, body = req(srv, "GET", "/project/demo") assert "

Run the whole project

" in body and "name=mode value='manual' checked" in body and "name=mode value='auto-confirm'" in body assert "manual: You start each member yourself" in body and "auto-confirm: The engine starts the next ready member" in body assert "Running as tester-login" in body and "Start project run" in body and "snapshotted" in body def test_missing_attributes_block_the_start_and_say_which(srv): p = v2_pack("t.alpha"); p["datapoints"][0].pop("default") write(srv.repo / "runbooks/alpha/pack.yaml", p) d = yaml.safe_load((srv.repo / "projects/demo/project.yaml").read_text()); d["attributes"] = {} write(srv.repo / "projects/demo/project.yaml", d) _, _, body = req(srv, "GET", "/project/demo") assert "Give the project the attributes it still needs first: site" in body and "Start project run" not in body st, _, err = post(srv, "/project/demo/runall", mode="manual") assert st == 400 and "attributes still needed: site" in err # ---- manual -------------------------------------------------------------------------------------------------------------------- def test_manual_project_run_page_shows_next_member_and_walks_them_in_order(srv): loc = start_pr(srv) _, _, body = req(srv, "GET", loc) assert "

Project run: Demo project

" in body and "0 of 2 members done" in body assert "Next: t.alpha" in body and "Manual mode." in body and "Hand over to auto-confirm" in body assert "waiting for t.alpha" in body and "Start anyway" in body and ">ready<" in body and ">waiting<" in body st, run_loc, _ = post(srv, f"{loc}/start", key="t.alpha") assert st == 303 and run_loc.startswith("/run/") and run_loc.endswith("#current") _, _, runpage = req(srv, "GET", run_loc.split("#")[0]) assert f"part of project run" in runpage and "project demo" in runpage _, _, body = req(srv, "GET", loc) assert "Continue: t.alpha" in body and "open run" in body and ">running<" in body assert "" not in body # one member at a time finish_run(srv, run_loc) _, _, body = req(srv, "GET", loc) assert "1 of 2 members done" in body and "Next: t.beta" in body and ">succeeded<" in body def test_out_of_order_start_needs_reason_and_acknowledgement_and_shows_as_a_deviation(srv): loc = start_pr(srv) st, _, err = post(srv, f"{loc}/start", key="t.beta") assert st == 400 and "waits for t.alpha" in err st, _, err = post(srv, f"{loc}/start", key="t.beta", override_reason="beta first") assert st == 400 and "acknowledge" in err assert post(srv, f"{loc}/start", key="t.beta", override_reason="beta first", acknowledge_risk="1")[0] == 303 _, _, body = req(srv, "GET", loc) assert "⚑ Deviations: override t.beta (beta first)" in body and "started t.beta (override)" in body def test_only_one_project_run_is_active_and_the_page_links_to_it(srv): loc = start_pr(srv) _, _, body = req(srv, "GET", "/project/demo") assert f"continue it" in body and "Start project run" not in body st, _, err = post(srv, "/project/demo/runall", mode="manual") assert st == 400 and "still open" in err # ---- auto-confirm ----------------------------------------------------------------------------------------------------------------- def test_auto_confirm_runs_members_until_a_person_is_needed_then_continues(srv): loc = start_pr(srv, "auto-confirm") _, _, body = req(srv, "GET", loc) assert "Auto-confirm mode." in body and "Run until it waits" in body and "Take over (manual)" in body and "never runs commands" in body assert "" not in body # the engine starts members, not you assert post(srv, f"{loc}/tick", n="300")[0] == 303 _, _, body = req(srv, "GET", loc) assert "1 of 2 members done" in body and "Last: awaiting-operator t.beta" in body and "manual-only" in body pr = PR.load(srv.app.registry(), loc.rsplit("/", 1)[1]) beta = R1.load(srv.app.registry().packs["t.beta"], next(m["run_id"] for m in pr.data["members"] if m["pack"] == "t.beta")) assert beta.data["mode"] == "auto-confirm" and beta.data["context"]["project_run"] == pr.id i = srv.app.pack_index("t.beta") assert post(srv, f"/run/{i}/{beta.data['run_id']}/careful/start")[0] == 303 # the person takes the careful step assert post(srv, f"/run/{i}/{beta.data['run_id']}/careful/verify", ok="1")[0] == 303 assert post(srv, f"/run/{i}/{beta.data['run_id']}/careful/done")[0] == 303 assert post(srv, f"{loc}/tick", n="300")[0] == 303 _, _, body = req(srv, "GET", loc) assert "2 of 2 members done" in body and "Finish: succeeded" in body def test_take_over_and_hand_over(srv): loc = start_pr(srv, "auto-confirm") assert post(srv, f"{loc}/mode", to="manual")[0] == 303 _, _, body = req(srv, "GET", loc) assert "Manual mode." in body and "Hand over to auto-confirm" in body and "switched to manual mode" in body st, _, err = post(srv, f"{loc}/mode", to="manual") assert st == 400 and "already in manual" in err # ---- closing and locking ------------------------------------------------------------------------------------------------------------ def test_closing_the_project_run_locks_it_and_abandons_the_open_member_run(srv): loc = start_pr(srv) post(srv, f"{loc}/start", key="t.alpha") _, _, body = req(srv, "GET", loc) assert "Finish: succeeded" not in body and "Abandon project run" in body and "Finish: failed" in body st, _, err = post(srv, f"{loc}/finalize", outcome="succeeded") assert st == 400 and "succeeds only when" in err assert post(srv, f"{loc}/finalize", outcome="abandoned", note="not now")[0] == 303 _, _, body = req(srv, "GET", loc) assert "Project run finished: abandoned" in body and "Comment: not now" in body and "closed and locked" in body assert "Close this project run" not in body and "" not in body and "Hand over" not in body reg = srv.app.registry() pr = PR.load(reg, loc.rsplit("/", 1)[1]) member = R1.load(reg.packs["t.alpha"], pr.data["members"][0]["run_id"]) assert member.data["outcome"] == "abandoned" for path, form in ((f"{loc}/start", {"key": "t.beta"}), (f"{loc}/tick", {"n": "1"}), (f"{loc}/mode", {"to": "auto-confirm"}), (f"{loc}/finalize", {"outcome": "abandoned"})): st, _, err = post(srv, path, **form) assert st == 400, path _, _, body = req(srv, "GET", "/project/demo") assert "Start project run" in body and f"" in body # a new one may start; the old one is in the history def test_a_tampered_closed_project_run_is_called_out(srv): import json loc = start_pr(srv) post(srv, f"{loc}/finalize", outcome="abandoned") pr = PR.load(srv.app.registry(), loc.rsplit("/", 1)[1]) pr.path.chmod(0o644) d = json.loads(pr.path.read_text()); d["operator"] = "forged"; pr.path.write_text(json.dumps(d)) assert "changed outside the app after it was sealed" in req(srv, "GET", loc)[2] # ---- history, activity, snapshot, safety -------------------------------------------------------------------------------------------- def test_history_table_and_activity_feed_include_the_project_run(srv): loc = start_pr(srv) post(srv, f"{loc}/start", key="t.alpha") _, _, body = req(srv, "GET", "/project/demo") assert "

Project runs

" in body and ">in progress<" in body and "0 of 2 members" in body _, _, act = req(srv, "GET", "/activity") assert "project run: started the project run (manual mode)" in act and "project run: started t.alpha" in act and f"href='{loc}'" in act def test_attribute_changes_after_the_start_are_shown_and_do_not_reach_later_members(srv): loc = start_pr(srv) post(srv, f"{loc}/start", key="t.alpha") reg = srv.app.registry() J.set_attributes(reg, reg.projects["demo"], {"site": "east"}) _, _, body = req(srv, "GET", loc) assert "attributes have changed since this project run started" in body and "keeps the values it started with" in body pr = PR.load(srv.app.registry(), loc.rsplit("/", 1)[1]) assert pr.data["snapshot"]["t.beta"] == {"site": "south"} def test_sidebar_marks_projects_on_a_project_run_page_and_csrf_and_unknown_ids(srv): loc = start_pr(srv) _, _, body = req(srv, "GET", loc) assert "'/project-run/']" in body # the alias list includes project runs assert req(srv, "POST", f"{loc}/tick", {"n": "1"})[0] == 403 assert req(srv, "POST", "/project/demo/runall", {"mode": "manual"})[0] == 403 assert req(srv, "GET", "/project-run/nope")[0] == 404 assert post(srv, "/project-run/nope/tick", n="1")[0] == 400 def test_a_closed_project_run_record_can_be_downloaded(srv): import json from rtut import run as RR loc = start_pr(srv) c = http.client.HTTPConnection("127.0.0.1", srv.server_address[1]) c.request("GET", f"{loc}/record.json", None, {"Host": "127.0.0.1"}) assert c.getresponse().status == 409 # open: no final record yet post(srv, f"{loc}/finalize", outcome="abandoned") assert f"
Download the project run record (JSON)" in req(srv, "GET", loc)[2] c = http.client.HTTPConnection("127.0.0.1", srv.server_address[1]) c.request("GET", f"{loc}/record.json", None, {"Host": "127.0.0.1"}) r = c.getresponse(); body = r.read() assert r.status == 200 and r.getheader("X-Rtut-Seal") == "sealed" and "attachment" in r.getheader("Content-Disposition") rec = json.loads(body) assert rec["project"] == "demo" and rec["outcome"] == "abandoned" and RR.record_seal_state(rec) == "sealed"