diff --git a/app/server/api.py b/app/server/api.py index 1acdb3d..6f4cc51 100644 --- a/app/server/api.py +++ b/app/server/api.py @@ -104,7 +104,7 @@ def page_json(page: status.Page) -> dict[str, Any]: "dir": page.dir, "rows": [asdict(row) for row in page.rows], "hosts": page.hosts, - "deploy": page.deploy, + "deploy": asdict(page.deploy) if page.deploy else None, "log": page.log, "template_moved": page.template_moved, } @@ -866,9 +866,10 @@ async def app_deploy_get(self, c: Context, w: Writer) -> None: # No deploy runs: the deploy container is the record. A skipped deploy has none. record = await status.last_deploy(self.yard, name) if record is not None: - if record["status"] == "ok": - record["port"] = await apps.exposed_port(self.yard, name) - w.json(cast(JsonValue, {"deploy": record})) + body = asdict(record) + if record.status == "ok": + body["port"] = await apps.exposed_port(self.yard, name) + w.json(cast(JsonValue, {"deploy": body})) return if job is None: raise YardError(404, "not_found", f"{name} has no deploy yet", context={"app": name}) diff --git a/app/server/apps.py b/app/server/apps.py index b1e5a01..fc387f1 100644 --- a/app/server/apps.py +++ b/app/server/apps.py @@ -210,9 +210,9 @@ async def _join(yard: Yard, job: Job) -> Outcome: job.push(f"== {deploy_container(job.app)} is running. This deploy follows it.") await compose.follow(yard.config, job.app, job.push) record = await status.last_deploy(yard, job.app) - if record is None or record["status"] == "running": + if record is None or record.status == "running": return Outcome("failed", error=f"lost {deploy_container(job.app)} while it ran", joined=True) - return Outcome(record["status"], record["image"], record["error"], record["previous"], joined=True) + return Outcome(record.status, record.image, record.error, record.previous, joined=True) @dataclass(slots=True) @@ -241,24 +241,19 @@ async def _ready_repo(yard: Yard, app: App, job: Job, ready: Ready) -> Ready: # Label the tried pair before the plan can fail, or the check loop retries every round. ready.deployed = Deployed(revision=ready.revision, template=_template_at(checkout, app)) plan = _plan(yard, app, ready.before) - ready.image = compose.image_tag(app.name, ready.revision, plan.template) - dockerfile = "" - if plan.kind == "containerfile": - dockerfile = str(config.app_dir(app.name) / "Containerfile") if plan.answer.startswith("yard:") else plan.answer + ready.deployed = Deployed( + revision=ready.revision, runs=plan.answer, kind=plan.kind, reason=plan.reason, template=plan.template + ) try: - project = compose.project( - config, app, plan.kind, plan.answer, ready.revision, ready.image, checkout, dockerfile - ) + project = compose.project(config, app, ready.deployed) except OSError as exc: raise YardError(502, "compose", str(exc)) from exc + ready.image = project.image job.push(f"== runs {plan.answer} ({plan.kind}" + (f"; {plan.reason}" if plan.reason else "") + ")") # A deploy of a sha whose image is still local skips the build. if await _image_exists(yard, ready.image): project.env["SKIP_BUILD"] = "1" ready.project = project - ready.deployed = Deployed( - revision=ready.revision, runs=plan.answer, kind=plan.kind, reason=plan.reason, template=plan.template - ) return ready @@ -273,12 +268,13 @@ async def _ready_source(yard: Yard, app: App, job: Job, ready: Ready) -> Ready: previous_copy = None if kind == "image": ready.revision = await sources.digest(app.runs) - ready.deployed = Deployed(revision=ready.revision) - ready.image = f"{app.runs.split('@', 1)[0]}@{ready.revision}" + ready.deployed = Deployed(revision=ready.revision, runs=app.runs, kind="image") + ready.image = compose.plan_image(app, ready.deployed) else: body = await sources.fetch_file(app.runs) ready.revision = "sha256:" + hashlib.sha256(body).hexdigest() - ready.deployed = Deployed(revision=ready.revision) + ready.deployed = Deployed(revision=ready.revision, runs=app.runs, kind="compose") + ready.image = compose.plan_image(app, ready.deployed) text = body.decode("utf-8", "replace") if detect.classify(text) != "compose": raise YardError(502, "detect", "a Containerfile needs a repo to build; a URL can name a compose file") @@ -289,8 +285,6 @@ async def _ready_source(yard: Yard, app: App, job: Job, ready: Ready) -> Ready: "a compose file from a URL cannot build: there is no checkout", help_text="Build the image elsewhere, or give the app a repo.", ) - kind = "compose" - ready.image = f"compose:{ready.revision[7:19]}" ready.revert_image = False if ready.before.revision: previous_copy = config.compose_copy(app.name, ready.before.revision) @@ -301,12 +295,11 @@ async def _ready_source(yard: Yard, app: App, job: Job, ready: Ready) -> Ready: for path in copy.parent.glob("*.yml"): if path not in (copy, previous_copy): path.unlink() - project = compose.project(config, app, kind, app.runs, ready.revision, ready.image, config.app_dir(app.name)) + project = compose.project(config, app, ready.deployed) project.env["SKIP_BUILD"] = "1" if previous_copy is not None and previous_copy.is_file(): project.env["PREVIOUS_COMPOSE_FILE"] = str(previous_copy) ready.project = project - ready.deployed = Deployed(revision=ready.revision, runs=app.runs, kind=kind) return ready @@ -325,13 +318,7 @@ async def _apply(yard: Yard, app: App, job: Job, ready: Ready) -> Outcome: code, _text = await compose.apply(config, app.name, "up", project, labels=labels, detach=detach, on_line=job.push) if detach: return Outcome("pending", ready.image, previous=previous) - if code == 3: - outcome = Outcome("reverted", ready.image, f"unhealthy; reverted to {previous}", previous) - elif code: - outcome = Outcome("failed", ready.image, f"apply exited {code}", previous) - else: - outcome = Outcome("ok", ready.image, previous=previous) - return outcome + return Outcome(status.deploy_status(code), ready.image, status.deploy_error(code, previous), previous) def _labels(job: Job, ready: Ready, outcome: Outcome) -> dict[str, str]: diff --git a/app/server/compose.py b/app/server/compose.py index 9ec5ed0..22aa10e 100644 --- a/app/server/compose.py +++ b/app/server/compose.py @@ -35,6 +35,17 @@ class Project: name: str files: list[str] env: dict[str, str] + image: str = "" + + +def plan_image(app: App, plan: Deployed) -> str: + """The image a plan runs: a local tag for a repo app, the pinned digest for an image app, + and a compose:<12 hex> name for a compose file from a URL.""" + if app.repo: + return image_tag(app.name, plan.revision, plan.template) + if plan.kind == "image": + return f"{plan.runs.split('@', 1)[0]}@{plan.revision}" + return f"compose:{plan.revision[7:19]}" def run_env(config: Config, app: App, checkout: Path, image: str, sha: str) -> dict[str, str]: @@ -58,56 +69,41 @@ def run_env(config: Config, app: App, checkout: Path, image: str, sha: str) -> d return env -def project( - config: Config, - app: App, - kind: str, - runs: str, - revision: str, - image: str, - checkout: Path, - dockerfile: str = "", -) -> Project: - """The project a deploy or purge runs. +def project(config: Config, app: App, plan: Deployed) -> Project: + """The project a deploy or purge runs, with the image it runs. kind "containerfile" composes Yard's app+build files; "image" composes app only; "compose" runs the file itself: the clone path or the fetched copy. """ - env = run_env(config, app, checkout, image, revision) + checkout = config.clone(app.name) if app.repo else config.app_dir(app.name) + image = plan_image(app, plan) + env = run_env(config, app, checkout, image, plan.revision) ensure_file(config.env(app.name)) - if kind == "containerfile": + if plan.kind == "containerfile": files = [APP_COMPOSE, BUILD_COMPOSE] - env["DOCKERFILE"] = dockerfile + # A written Containerfile lives beside the clone; a repo one is a path in it. + written = plan.runs.startswith("yard:") + env["DOCKERFILE"] = str(config.app_dir(app.name) / "Containerfile") if written else plan.runs env["YARD_FILES"] = "1" - elif kind == "image": + elif plan.kind == "image": files = [APP_COMPOSE] env["YARD_FILES"] = "1" - elif runs.startswith("https://"): - files = [str(config.compose_copy(app.name, revision))] + elif not app.repo: + files = [str(config.compose_copy(app.name, plan.revision))] else: - files = [str(checkout / runs)] - return Project(app.name, files, env) + files = [str(checkout / plan.runs)] + return Project(app.name, files, env, image) -def saved_project(config: Config, app: App, deployed: Deployed | None) -> Project | None: - """Rebuild the project for a purge from the running plan; None when there is none.""" - if deployed is None or not deployed.revision: +def saved_project(config: Config, app: App, plan: Deployed | None) -> Project | None: + """The project for a purge from the running plan; None when its files are gone.""" + if plan is None or not plan.revision: + return None + if app.repo and not (config.clone(app.name) / ".git").exists(): return None - if deployed.kind == "image": - image = f"{deployed.runs.split('@', 1)[0]}@{deployed.revision}" - return project(config, app, "image", "", deployed.revision, image, config.app_dir(app.name)) - if deployed.kind == "compose" and deployed.runs.startswith("https://"): - copy = config.compose_copy(app.name, deployed.revision) - if not copy.is_file(): - return None - image = f"compose:{deployed.revision[7:19]}" - return project(config, app, "compose", deployed.runs, deployed.revision, image, config.app_dir(app.name)) - checkout = config.clone(app.name) - if not (checkout / ".git").exists(): + if not app.repo and plan.kind == "compose" and not config.compose_copy(app.name, plan.revision).is_file(): return None - image = image_tag(app.name, deployed.revision, deployed.template) - dockerfile = str(config.app_dir(app.name) / "Containerfile") if deployed.runs.startswith("yard:") else deployed.runs - return project(config, app, deployed.kind, deployed.runs, deployed.revision, image, checkout, dockerfile) + return project(config, app, plan) async def apply( diff --git a/app/server/status.py b/app/server/status.py index dcb253a..9389877 100644 --- a/app/server/status.py +++ b/app/server/status.py @@ -3,7 +3,7 @@ import os from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from app.server import journal, podman, sites from app.server.podman import Container, Sample @@ -15,6 +15,32 @@ from app.server.yard import Yard +@dataclass(slots=True) +class Record: + """The last deploy, read from the deploy container: its labels and its exit code.""" + + app: str + sha: str + image: str + status: str + cause: str + actor: str + previous: str + error: str + started: str + ended: str + + +def deploy_status(code: int) -> str: + """The end of a deploy from the apply exit code: 0 ok, 3 reverted, any other code failed.""" + return "ok" if code == 0 else "reverted" if code == 3 else "failed" + + +def deploy_error(code: int, previous: str) -> str: + """The error text of an apply that did not end ok.""" + return f"unhealthy; reverted to {previous}" if code == 3 else f"apply exited {code}" if code else "" + + @dataclass(slots=True) class Row: app: str = "" @@ -38,7 +64,7 @@ class Row: cpu: str = "" mem: str = "" data: int = 0 - deploy: dict[str, Any] | None = None + deploy: Record | None = None template_moved: bool = False @@ -70,7 +96,7 @@ class Page: dir: str rows: list[Row] hosts: list[str] - deploy: dict[str, Any] | None + deploy: Record | None log: str deployed: Deployed | None = None template_moved: bool = False @@ -213,37 +239,25 @@ def _image_plan(app: App, listed: dict[str, Container]) -> Deployed | None: return Deployed(revision=revision, template=tag[len(revision) + 1 :]) -def deploy_record(listed: dict[str, Container], name: str) -> dict[str, Any] | None: +def deploy_record(listed: dict[str, Container], name: str) -> Record | None: """The deploy container is the record of the last deploy: its labels and its exit code.""" item = listed.get(deploy_container(name)) if item is None: return None - if item.running: - state = "running" - elif item.exit_code == 0: - state = "ok" - elif item.exit_code == 3: - state = "reverted" - else: - state = "failed" labels = item.labels - error = labels.get("error", "") - if not error and state == "failed": - error = f"apply exited {item.exit_code}" - elif not error and state == "reverted": - error = f"unhealthy; reverted to {labels.get('previous', '')}" - return { - "app": name, - "sha": labels.get("sha", ""), - "image": labels.get("image", ""), - "status": state, - "cause": labels.get("cause", ""), - "actor": labels.get("actor", ""), - "previous": labels.get("previous", ""), - "error": error, - "started": stamp(item.started) if item.started else "", - "ended": stamp(item.exited) if item.exited and not item.running else "", - } + previous = labels.get("previous", "") + return Record( + app=name, + sha=labels.get("sha", ""), + image=labels.get("image", ""), + status="running" if item.running else deploy_status(item.exit_code), + cause=labels.get("cause", ""), + actor=labels.get("actor", ""), + previous=previous, + error=labels.get("error", "") or ("" if item.running else deploy_error(item.exit_code, previous)), + started=stamp(item.started) if item.started else "", + ended=stamp(item.exited) if item.exited and not item.running else "", + ) def _site_target(app: App, items: list[Container]) -> str: @@ -259,7 +273,7 @@ def _app_rows( samples: dict[str, Sample], tip: Tip | None, deployed: Deployed | None, - note: dict[str, Any] | None, + note: Record | None, hosts: list[str], crashes: dict[str, tuple[bool, str]], running_only: bool, @@ -338,7 +352,7 @@ def _row( sample: Sample | None, tip: Tip | None, deployed: Deployed | None, - note: dict[str, Any] | None, + note: Record | None, hosts: list[str], crashes: dict[str, tuple[bool, str]], ) -> Row: @@ -415,7 +429,7 @@ async def _container_tail(yard: Yard, name: str) -> str: return "" -async def last_deploy(yard: Yard, name: str) -> dict[str, Any] | None: +async def last_deploy(yard: Yard, name: str) -> Record | None: """The record of the last deploy, read from its container; None before the first deploy.""" try: listed = podman.parse_ps(await yard.podman.run("ps", "-a", "--format", "json")) diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py index 8036f04..f6b5f9e 100644 --- a/tests/server/test_apps.py +++ b/tests/server/test_apps.py @@ -752,7 +752,8 @@ async def test_check_repo_debounces(tmp_path: Path, monkeypatch: pytest.MonkeyPa yard = make_yard(tmp_path) yard.store.create_app("heldso", "https://origin.cursor.com/org/app", "main", "push") _connect(monkeypatch) - monkeypatch.setattr(apps, "_DEBOUNCE", 0.05) + # The window must outlast one check: each check also asks Podman for the deploy record. + monkeypatch.setattr(apps, "_DEBOUNCE", 0.5) seen: list[str] = [] async def fake_tip(clone, url, ref, token=""): @@ -764,7 +765,7 @@ async def fake_tip(clone, url, ref, token=""): await apps.check_repo(yard, "https://origin.cursor.com/org/app") await apps.check_repo(yard, "https://origin.cursor.com/org/app") assert seen == ["tip"] - await asyncio.sleep(0.1) + await asyncio.sleep(0.7) assert seen == ["tip", "tip"] diff --git a/tests/server/test_compose.py b/tests/server/test_compose.py index ce74da4..49294dc 100644 --- a/tests/server/test_compose.py +++ b/tests/server/test_compose.py @@ -27,8 +27,9 @@ def _app(name: str, trigger: str = "") -> App: return App(name, "https://origin.cursor.com/org/heldso", "main", trigger or "push", "") -def _checkout(root: Path, files: dict[str, str]) -> Path: - directory = root / "src" / "heldso" / "abc" +def _checkout(root: Path, files: dict[str, str], name: str = "heldso") -> Path: + """The app's clone, where compose.project reads a repo app's files.""" + directory = root / "apps" / name / "repo" directory.mkdir(parents=True) for name, text in files.items(): path = directory / name @@ -73,18 +74,16 @@ def test_run_env_is_the_interpolation_env(tmp_path: Path): def test_project_containerfile_composes_app_and_build(tmp_path: Path): config = _config(tmp_path) checkout = _checkout(tmp_path, {"Containerfile": "FROM scratch\n"}) - found = project( - config, - _app("heldso"), - "containerfile", - "Containerfile", - "abc", - "localhost/heldso:abc", - checkout, - "Containerfile", - ) + found = project(config, _app("heldso"), Deployed(revision="abc", runs="Containerfile", kind="containerfile")) assert found.files == [APP_COMPOSE, BUILD_COMPOSE] assert found.env["DOCKERFILE"] == "Containerfile" + assert found.env["CHECKOUT"] == str(checkout) + assert found.image == "localhost/heldso:abc" + written = project( + config, _app("heldso"), Deployed(revision="abc", runs="yard:go", kind="containerfile", template="3f9a1c2e") + ) + assert written.env["DOCKERFILE"] == str(tmp_path / "apps" / "heldso" / "Containerfile") + assert written.image == "localhost/heldso:abc-3f9a1c2e" assert found.env["YARD_FILES"] == "1" env_file = tmp_path / "apps" / "heldso" / "env" assert env_file.read_text() == "" @@ -93,7 +92,7 @@ def test_project_containerfile_composes_app_and_build(tmp_path: Path): def test_project_repo_compose_is_one_file(tmp_path: Path): config = _config(tmp_path) checkout = _checkout(tmp_path, {"compose.yml": FULL}) - found = project(config, _app("heldso"), "compose", "compose.yml", "abc", "localhost/heldso:abc", checkout) + found = project(config, _app("heldso"), Deployed(revision="abc", runs="compose.yml", kind="compose")) assert found.files == [str(checkout / "compose.yml")] assert "YARD_FILES" not in found.env @@ -101,10 +100,10 @@ def test_project_repo_compose_is_one_file(tmp_path: Path): def test_project_image_app_is_app_only(tmp_path: Path): config = _config(tmp_path) app = App("db", "", "main", "push", "", "postgres:17") - found = project( - config, app, "image", "", "sha256:" + "a" * 64, f"postgres:17@sha256:{'a' * 64}", config.app_dir("db") - ) + found = project(config, app, Deployed(revision="sha256:" + "a" * 64, runs=app.runs, kind="image")) assert found.files == [APP_COMPOSE] + assert found.image == f"postgres:17@sha256:{'a' * 64}" + assert found.env["CHECKOUT"] == str(config.app_dir("db")) assert found.env["YARD_FILES"] == "1" @@ -112,14 +111,15 @@ def test_project_url_compose_is_the_copy(tmp_path: Path): config = _config(tmp_path) app = App("u", "", "main", "push", "", "https://example.com/x.yml") rev = "sha256:" + "b" * 64 - found = project(config, app, "compose", app.runs, rev, f"compose:{rev[7:19]}", config.app_dir("u")) + found = project(config, app, Deployed(revision=rev, runs=app.runs, kind="compose")) + assert found.image == f"compose:{rev[7:19]}" assert found.files == [str(tmp_path / "apps" / "u" / "compose" / f"{rev[7:19]}.yml")] def test_the_yard_app_gets_its_host_paths_from_config(tmp_path: Path): config = _config(tmp_path) - checkout = _checkout(tmp_path, {"ops/compose.yml": FULL}) - found = project(config, _app("yard"), "compose", "ops/compose.yml", "abc", "localhost/yard:abc", checkout) + checkout = _checkout(tmp_path, {"ops/compose.yml": FULL}, "yard") + found = project(config, _app("yard"), Deployed(revision="abc", runs="ops/compose.yml", kind="compose")) assert found.env["YARD_ROOT"] == str(tmp_path) assert found.env["YARD_PODMAN_SOCK"] == "/run/podman.sock" assert found.env["YARD_PODMAN_DIR"] == "/run" @@ -284,8 +284,8 @@ async def fake_run(argv, env, on_line=None): return 0, "" monkeypatch.setattr(compose, "_run", fake_run) - checkout = _checkout(tmp_path, {"ops/compose.yml": FULL}) - found = project(config, _app("yard"), "compose", "ops/compose.yml", "abc", "localhost/yard:abc", checkout) + _checkout(tmp_path, {"ops/compose.yml": FULL}, "yard") + found = project(config, _app("yard"), Deployed(revision="abc", runs="ops/compose.yml", kind="compose")) asyncio.run(compose.apply(config, "yard", "up", found)) argv, _env = calls[-1] python_names = {value.split("=", 1)[0] for flag, value in zip(argv, argv[1:], strict=False) if flag == "-e"} diff --git a/tests/server/test_status.py b/tests/server/test_status.py index 78d9ecb..6bffcf6 100644 --- a/tests/server/test_status.py +++ b/tests/server/test_status.py @@ -256,6 +256,6 @@ def test_deploy_record_reads_the_labels_and_the_exit_code(state, code, status_, raw = json.dumps([{"Names": ["deploy-heldso"], "State": state, "ExitCode": code, "Labels": labels}]) record = status.deploy_record(parse_ps(raw), "heldso") assert record is not None - assert record["sha"] == "abc123" and record["image"] == "localhost/heldso:abc123" - assert record["status"] == status_ - assert record["error"] == error + assert record.sha == "abc123" and record.image == "localhost/heldso:abc123" + assert record.status == status_ + assert record.error == error