Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions app/server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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})
Expand Down
39 changes: 13 additions & 26 deletions app/server/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -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


Expand All @@ -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]:
Expand Down
68 changes: 32 additions & 36 deletions app/server/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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(
Expand Down
78 changes: 46 additions & 32 deletions app/server/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = ""
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"))
Expand Down
5 changes: 3 additions & 2 deletions tests/server/test_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=""):
Expand All @@ -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"]


Expand Down
Loading
Loading