diff --git a/src/agent_env/bundle/ledger.py b/src/agent_env/bundle/ledger.py index 570dc4fa..0b7eefca 100644 --- a/src/agent_env/bundle/ledger.py +++ b/src/agent_env/bundle/ledger.py @@ -10,7 +10,6 @@ import hashlib import json -import os from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager from dataclasses import dataclass @@ -22,14 +21,13 @@ from agent_env.artifact.registry import canonical_type, get_artifact_registry from agent_env.artifact.store import ARTIFACTS_COLLECTION from agent_env.config import get_config -from agent_env.config.paths import state_root from agent_env.env.env import Env from agent_env.env.registry import get_env_registry from agent_env.env.store import ENVS_COLLECTION from agent_env.eval.store import EVALS_COLLECTION from agent_env.store import Filter, Sort from agent_env.store.document_store import LocalSqliteDocumentStore -from agent_env.store.local_state import ensure_state_dir +from agent_env.store.local_state import holding_locks from agent_env.task.store import TASKS_COLLECTION from .authoring import build_context_files, entry_files @@ -37,11 +35,6 @@ from .plan import Plan, Write, folder_walk, keeps_base_from_toml from .resolve import BuiltImage -try: - import fcntl -except ImportError: # Windows: runs of one bundle aren't serialized - fcntl = None - LEDGER_COLLECTION = "bundle_ledger" # Bumped by hand when what a digest covers changes, or a writer's output changes enough that every # version it wrote should be written again. @@ -173,31 +166,10 @@ def _find(self, collection: str, filter: Filter) -> dict | None: @contextmanager def materializing(bundle: Bundle, on_wait: Callable[[], None] | None = None) -> Iterator[None]: """Hold a lock on every id the bundle's entries write while its entities are written. Another run that - writes any of those ids, of this bundle or another, waits here, calling ``on_wait`` first. The locks are - taken in one order, so two runs can't each hold one the other waits for. Task runs don't hold them.""" - if fcntl is None: - yield - return - ensure_state_dir(state_root() / "locks") - fds, waited = [], False - try: - for path in sorted({_lock_path(entry.id) for entry in bundle.entries}): - fds.append(os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)) - try: - fcntl.flock(fds[-1], fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - if on_wait is not None and not waited: - on_wait() - waited = True - fcntl.flock(fds[-1], fcntl.LOCK_EX) + writes any of those ids, of this bundle or another, waits here, calling ``on_wait`` first. Task runs don't + hold them.""" + with holding_locks((entry.id for entry in bundle.entries), on_wait): yield - finally: - for fd in fds: - os.close(fd) - - -def _lock_path(id: str) -> Path: - return state_root() / "locks" / f"entity-{hashlib.sha256(id.encode()).hexdigest()[:16]}.lock" def _tracked(write: Write) -> bool: diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py new file mode 100644 index 00000000..f3ed87f8 --- /dev/null +++ b/src/agent_env/bundle/preflight.py @@ -0,0 +1,361 @@ +"""What a bundle run checks about where its tasks deploy, before it writes anything, and the infra envs it builds. + +Each deploy, an env's, an agent's, a sandbox's or a rubrics judge's, runs on its effective sandbox provider: the run's +``--sandbox``, else the step's own field, else the config default, resolved as the step resolves it when it runs. A +comma-separated provider is a chain whose deploys fall back from one provider to the next, so every provider in it must +pass. A run is refused when a deploy would fail on its provider: an image only this machine has (in its local registry, +or saved in its local object store) on a provider that isn't the local one, a VM asked of a provider that can't create +one, or containers on the local provider without a Docker daemon. A deploy_agent step that names no agent, and a judge +that names none, deploy the configured default agent, which must be in the store. + +A gateway deploy on the local provider runs on infra envs the store may not hold yet: the gateway, the service-db its +local Postgres state runs from, and the website browser it adds for websites. The run builds those once its writes are +done (``agent_env.env.bootstrap``), so they're named here, and refused here when they can't be built. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from urllib.parse import urlparse + +from agent_env.a2a_agent import A2AAgent +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.config import get_config +from agent_env.entity_refs import EntityKind, parse_toml_ref +from agent_env.env.bootstrap import ( + GATEWAY, + SERVICE_DB, + WEBSITE_BROWSER, + InfraBuild, + InfraError, + default_env_id, + infra_to_build, + put_command, +) +from agent_env.env.env import Env +from agent_env.env.envs._deployment import provider_or_class +from agent_env.env.envs.mcp_server import MCPServerEnv +from agent_env.env.envs.multi_env import MultiEnv +from agent_env.env.envs.service_db import ServiceDBEnv +from agent_env.env.envs.website import WebsiteEnv +from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider, _gateway_topology +from agent_env.providers.env_providers.env_server_provider import EnvironmentServerProvider +from agent_env.providers.env_state.env_state_provider import LOCAL_POSTGRES_STATE_TYPE +from agent_env.providers.env_state.store import get_env_state_instance_store +from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider +from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider +from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider +from agent_env.providers.sandbox_providers.sandbox_provider import ( + SandboxProvider, + build_sandbox_provider, + get_agent_sandbox_provider, + get_env_sandbox_provider, + get_sandbox_provider, +) +from agent_env.store.base import NotFoundError +from agent_env.store.image_store.oci_registry_credentials import registry_host_from_ref +from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep +from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep +from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep +from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep +from agent_env.utils.docker_build import docker_unreachable + +from ._fs import relative +from .parse import BundleError, BundleKind +from .plan import Plan +from .resolve import BuiltImage, ResolvedEntry, build_step + +_SHOWN_DOCKER_USERS = 3 + + +@dataclass(frozen=True) +class Preflight: + """What a run needs before its tasks start, beyond its writes: the infra envs it builds.""" + + infra_kinds: frozenset[str] # the infra its deploys on the local provider run on + infra: tuple[InfraBuild, ...] # those the store doesn't hold yet, or holds built from other inputs + + +def preflight_run(plan: Plan, tasks: Iterable[ResolvedEntry], sandbox: str | None) -> Preflight: + """Check where each of ``tasks`` deploys, with ``sandbox`` overriding every deploy's provider, and name the infra + envs the run builds. Raises BundleError listing every problem found. Reads the store but writes nothing.""" + walk = _Walk(plan, sandbox) + for task in tasks: + walk.task(task) + return walk.finish() + + +@dataclass(frozen=True) +class _Image: + """An image a deploy runs, as its message names it.""" + + what: str + local_only: str | None # why only this machine has it, or None + + +class _Walk: + def __init__(self, plan: Plan, sandbox: str | None): + self.plan, self.sandbox = plan, sandbox + self.problems: dict[str, dict[str, None]] = {} # each deploy's problem, and the deploys it's found at + self.run_problems: list[str] = [] # the run's as a whole + self.infra: set[str] = set() # the infra kinds a deploy on the local provider needs + self.remote_infra: list[tuple[str, SandboxProvider, set[str]]] = [] # (where, provider, kinds) of a deploy elsewhere + self.docker_users: list[str] = [] + self.default_agent_users: list[tuple[str, str]] = [] # (where, what names no agent) + self.written = {write.id for write in plan.writes} + self.agents = {write.id: write.source for write in plan.writes if write.kind is BundleKind.AGENT} + self.built = {write.id: write.source for write in plan.writes if isinstance(write.source, BuiltImage)} + + def task(self, task: ResolvedEntry) -> None: + steps = [build_step(config) for config in task.config] + sandboxes = {step.sandbox_name: step for step in steps if isinstance(step, DeploySandboxTaskStep)} + for step in steps: + where = f"{relative(self.plan.bundle.bundle.root, task.entry.path)}: step {step.id!r}" + try: + if isinstance(step, DeployEnvTaskStep): + self._env(where, step) + elif isinstance(step, DeployAgentTaskStep): + self._agent(where, step, sandboxes) + elif isinstance(step, DeploySandboxTaskStep): + self._sandbox(where, step) + elif isinstance(step, RubricsVerifierTaskStep): + self._judge(where, step) + except ValueError as e: # a provider that names no backend, or can't be built from its config + self._problem(where, str(e)) + + def finish(self) -> Preflight: + infra: list[InfraBuild] = [] + try: + infra = infra_to_build(self.infra) + except InfraError as e: + self.run_problems.extend(e.problems) + for where, provider, kinds in self.remote_infra: + self._remote_infra(where, provider, kinds) + self._default_agent() + if self.docker_users and (reason := docker_unreachable()): + shown = ", ".join(self.docker_users[:_SHOWN_DOCKER_USERS]) + more = len(self.docker_users) - _SHOWN_DOCKER_USERS + self.run_problems.append(f"the local sandbox provider runs containers for {shown}" + f"{f' and {more} more' if more > 0 else ''}, and {reason}") + problems = list(self.run_problems) + for problem, wheres in self.problems.items(): + first, *others = wheres + more = f" (and {len(others)} more deploy{'s' if len(others) > 1 else ''})" if others else "" + problems.append(f"{first}{more}: {problem}") + if problems: + raise BundleError(problems) + return Preflight(frozenset(self.infra), tuple(infra)) + + # Deploys, each resolving its provider as its step does when it runs + + def _env(self, where: str, step: DeployEnvTaskStep) -> None: + provider = _provider(self.sandbox or step.sandbox_type, get_env_sandbox_provider) + if step.env_id in self.written: + return # a bundle env, refused at materialize until envs can be written + try: + env = Env.get(step.env_id, step.env_version) + except NotFoundError: + return # the plan reports a store env that isn't there + if not isinstance(env, (MCPServerEnv, WebsiteEnv, MultiEnv)): + return # an env type that deploys itself, as deploy_env's own preflight leaves it + try: + provider_class = provider_or_class(env) + except (ValueError, KeyError): + return # deploy_env's own preflight reports a provider type this process can't load + if not isinstance(provider_class, type): + provider_class = type(provider_class) + if issubclass(provider_class, EnvironmentGatewayProvider): + topology = _gateway_topology(env) + images = [*topology.mcp_server_images, *(topology.website_images or [])] + kinds = {GATEWAY} + if _state_type(step) == LOCAL_POSTGRES_STATE_TYPE: + kinds.add(SERVICE_DB) + if topology.website_configs: + kinds.add(WEBSITE_BROWSER) + elif issubclass(provider_class, EnvironmentServerProvider) and isinstance(env, MCPServerEnv): + images, kinds = [env.docker_image_artifact], set() + else: # a plugin's provider, or one deploy_env's own preflight refuses for this env + return + if _local_link(provider): + self.infra |= kinds + self.docker_users.append(where) + if remote := _remote_links(provider): + self._reachable(where, remote, [_Image(f"env {env.id!r}'s image {image.id!r}", _local_only(image)) for image in images]) + # Modal's gateway runs each server in a container of its own, and can't serve websites. + containers = [link for link in remote if isinstance(link, ModalSandboxProvider)] + vms = [link for link in remote if link not in containers] + if WEBSITE_BROWSER in kinds and containers: + self._problem(where, f"deploys env {env.id!r}, which has websites, on the {_shown(containers[0])} " + "sandbox provider, whose gateway runs in containers and can't serve websites; run it " + "on a VM provider, such as --sandbox local") + if containers: + self.remote_infra.append((where, containers[0], kinds - {WEBSITE_BROWSER})) + if vms: + self.remote_infra.append((where, vms[0], kinds)) + + def _agent(self, where: str, step: DeployAgentTaskStep, sandboxes: dict[str, DeploySandboxTaskStep]) -> None: + if step.sandbox_name: + linked = sandboxes.get(step.sandbox_name) + if linked is None: + return # the step itself refuses an agent linked to a sandbox the task doesn't deploy + provider = _provider(self.sandbox or linked.sandbox_type, get_sandbox_provider) + else: + provider = _provider(self.sandbox or step.sandbox_type, get_agent_sandbox_provider) + self._agent_deploy(where, provider, step.a2a_agent_id, step.a2a_agent_version, "names no agent") + + def _judge(self, where: str, step: RubricsVerifierTaskStep) -> None: + if not step.use_agent_judge or step.agent_name is not None: + return # the direct LLM judge, or a judge the task deployed itself + provider = _provider(self.sandbox or step.judge_sandbox_type, get_agent_sandbox_provider) + self._agent_deploy(where, provider, step.judge_a2a_agent_id, None, "names no judge agent") + + def _agent_deploy(self, where: str, provider: SandboxProvider, agent_id: str | None, version: int | None, + unnamed: str) -> None: + if _local_link(provider): + self.docker_users.append(where) + if agent_id is None: + self.default_agent_users.append((where, unnamed)) + agent_id = get_config().get_default_a2a_agent_id() + if remote := _remote_links(provider): + image = self._agent_image(agent_id, version) + if image is not None: + self._reachable(where, remote, [image]) + + def _sandbox(self, where: str, step: DeploySandboxTaskStep) -> None: + provider = _provider(self.sandbox or step.sandbox_type, get_sandbox_provider) + if step.sandbox_mode == "vm": + if not _creates_vms(provider): + self._problem(where, f"deploys a VM sandbox, and the {_shown(provider)} sandbox provider can't create a " + "VM; run it on one that can, such as --sandbox local") + return + if _local_link(provider): + self.docker_users.append(where) + if step.image and (remote := _remote_links(provider)): + self._reachable(where, remote, [_Image(f"the image {step.image}", _local_only(step.image))]) + + # What a deploy runs + + def _agent_image(self, agent_id: str, version: int | None) -> _Image | None: + """The image the agent ``agent_id`` runs, or None when the store doesn't hold the agent, which the plan or + ``_default_agent`` reports.""" + what = f"agent {agent_id!r}'s image" + if (agent := self.agents.get(agent_id)) is not None: + image_id, image_version = parse_toml_ref(EntityKind.ARTIFACT, agent.config.get("image")) + if (built := self.built.get(image_id)) is not None: + path = relative(self.plan.bundle.bundle.root, built.entry.path) + return _Image(what, f"it's built on this machine from {path}/{built.dockerfile}") + if image_id in self.written: + return None # another of the bundle's writes, which materialize refuses or writes first + return _Image(what, _local_only(DockerImageArtifact.get(image_id, image_version))) + try: + return _Image(what, _local_only(A2AAgent.get(agent_id, version).docker_image_artifact)) + except NotFoundError: + return None + + def _reachable(self, where: str, remote: list[SandboxProvider], images: list[_Image]) -> None: + for image in images: + if image.local_only: + self._problem(where, f"deploys {image.what} on the {_shown(remote[0])} sandbox provider, which can't " + f"reach it: {image.local_only}; run it with --sandbox local") + + def _remote_infra(self, where: str, provider: SandboxProvider, kinds: set[str]) -> None: + for kind in sorted(kinds): + env_id = default_env_id(kind) + try: + env = Env.get(env_id) + except NotFoundError: + self._problem(where, f"deploys on the {_shown(provider)} sandbox provider, which needs the {kind} env " + f"{env_id!r}, and the store doesn't hold it; agent-env builds it only for the local " + f"sandbox provider, so put it in a store that provider can reach (`{put_command(kind)}`)") + continue + images = ([env.db_docker_image_artifact, env.db_web_docker_image_artifact, env.db_mcp_docker_image_artifact] + if isinstance(env, ServiceDBEnv) else [env.docker_image_artifact]) + self._reachable(where, [provider], [_Image(f"the {kind} env {env_id!r}'s image {image.id!r}", _local_only(image)) + for image in images]) + + def _default_agent(self) -> None: + if not self.default_agent_users: + return + agent_id = get_config().get_default_a2a_agent_id() + if agent_id in self.agents: + return + try: + A2AAgent.get(agent_id) + return + except NotFoundError: + problem = (f"there is no agent {agent_id!r} in the store; register one under that id, or point " + "[agents] default_a2a_agent_id at an agent that is") + except (ValueError, KeyError, TypeError) as e: + problem = f"agent {agent_id!r} can't be read ({type(e).__name__}: {e})" + for where, unnamed in self.default_agent_users: + self._problem(where, f"{unnamed}, so it deploys the default, {agent_id!r}, and {problem}") + + def _problem(self, where: str, problem: str) -> None: + """Record ``problem`` at the deploy ``where``. One found at several deploys, such as an infra env's image every + gateway deploy runs, is reported once, naming the first of them.""" + self.problems.setdefault(problem, {})[where] = None + + +def _state_type(step: DeployEnvTaskStep) -> str | None: + """The type of the env state store the deploy runs on: an attached instance's own, else the one it creates.""" + if step.env_state_instance_id is None: + return step.env_state_type or LOCAL_POSTGRES_STATE_TYPE + try: + return get_env_state_instance_store().get(step.env_state_instance_id).state_type + except NotFoundError: + return None # the deploy refuses an instance that isn't there + + +def _provider(spec: str | None, default: Callable[[], SandboxProvider]) -> SandboxProvider: + return build_sandbox_provider(spec) if spec else default() + + +def _links(provider: SandboxProvider) -> list[SandboxProvider]: + return list(provider.providers) if isinstance(provider, ChainedSandboxProvider) else [provider] + + +def _local_link(provider: SandboxProvider) -> bool: + return any(isinstance(link, LocalSandboxProvider) for link in _links(provider)) + + +def _remote_links(provider: SandboxProvider) -> list[SandboxProvider]: + return [link for link in _links(provider) if not isinstance(link, LocalSandboxProvider)] + + +def _creates_vms(provider: SandboxProvider) -> bool: + """Whether ``provider`` implements create_vm; a chain doesn't, whatever its providers do.""" + return type(provider).create_vm is not SandboxProvider.create_vm + + +def _shown(provider: SandboxProvider) -> str: + return repr(",".join(_name(link) for link in _links(provider))) + + +def _name(provider: SandboxProvider) -> str: + """The name ``provider`` is registered under, as ``--sandbox`` names it.""" + cls = type(provider) + for name, section in get_config().sandbox_registry().items(): + impl = section.get("impl") + if impl is cls or impl == f"{cls.__module__}:{cls.__qualname__}": + return name + return cls.__name__ + + +def _local_only(image: DockerImageArtifact | str) -> str | None: + """Why only this machine has ``image``: its reference names a registry on this machine, or it's saved in this + machine's object store. None when neither.""" + ref = image if isinstance(image, str) else image.image_name + if _loopback(registry_host_from_ref(ref)): + return f"{ref} is in a registry on this machine" + if not isinstance(image, str) and urlparse(image.tar_gz_object_url).scheme == "file": + return f"{image.id!r} is saved in this machine's object store" + return None + + +def _loopback(host: str | None) -> bool: + if not host: + return False + name = host[1:host.find("]")] if host.startswith("[") else host.rsplit(":", 1)[0] + return name == "localhost" or name.startswith("127.") or name == "::1" + diff --git a/src/agent_env/bundle/run.py b/src/agent_env/bundle/run.py index e87e4b55..c4bc7e8b 100644 --- a/src/agent_env/bundle/run.py +++ b/src/agent_env/bundle/run.py @@ -5,8 +5,9 @@ and the others go on. Each run's sandboxes are torn down as it ends, unless ``keep`` holds them up. Ctrl-C or SIGTERM cancels the runs: each one that started is marked cancelled and torn down, and a second one stops the teardown. A bundle's evals run only the bundle's own tasks for now, so one naming a store task is refused -before anything is written. A dry run makes every check the run makes before its first task, and writes and -runs nothing. +before anything is written, and so is a deploy its sandbox provider can't serve (``preflight``). The infra envs a +gateway deploy on the local provider needs are built once the writes are done, before any task starts. A dry run +makes every check the run makes before its first task, and writes, builds and runs nothing. """ from __future__ import annotations @@ -21,6 +22,7 @@ from enum import Enum from pathlib import Path +from agent_env.env.bootstrap import InfraBuild, ensure_default_envs from agent_env.providers import build_sandbox_provider from agent_env.store.routing import namespace_routing, run_scope from agent_env.task import Task, record_task_cancelled @@ -32,6 +34,7 @@ from .materialize import Materialization, Materialized, materialize from .parse import BundleEntry, BundleError, BundleKind, parse_bundle from .plan import Plan, Write, plan_bundle +from .preflight import Preflight, preflight_run from .resolve import BuiltImage, Reference, resolve_bundle logger = logging.getLogger(__name__) @@ -147,6 +150,7 @@ class DryRun: materialization: Materialization # each write at the version it would leave, and why runs: tuple[BundleEntry, ...] # the tasks that would run, each once, in the plan's order skipped: tuple[BundleEntry, ...] # the tasks no eval names, which running every eval leaves out + infra: tuple[InfraBuild, ...] = () # the infra envs the run would build first def path(self, entry: BundleEntry) -> str: """``entry``'s path in the bundle (``tasks/hello.json``).""" @@ -191,14 +195,16 @@ def run_bundle( ends. Before anything is written, raises RuntimeError when an event loop is already running, ValueError when - ``sandbox`` names no provider, and BundleError when the bundle can't be planned. A task that fails its - preflight raises BundleError once the entities it reads are written, before any task is.""" + ``sandbox`` names no provider, and BundleError when the bundle can't be planned or a deploy can't run where + it would (``preflight``). A task that fails its preflight raises BundleError once the entities it reads are + written, before any task is. Building an infra env the run needs raises what the build raises, before any task + runs.""" _refuse_a_running_loop() if sandbox: build_sandbox_provider(sandbox) say = _progress(on_progress) with namespace_routing(): - plan = _planned(root, tasks, evals, id_root) + plan, preflight = _planned(root, tasks, evals, id_root, sandbox) materialization = materialize( plan, on_wait=lambda: say("waiting for another agent-env run to finish writing this bundle's ids"), @@ -207,6 +213,8 @@ def run_bundle( ) entries = _to_run(plan) to_run = [(entry, Task.get(entry.id, materialization.version_of("task", entry.id))) for entry in entries] + if preflight.infra: + ensure_default_envs(preflight.infra_kinds, say=say) with Interrupts() as interrupts: runs = interrupts.run(_run_all(plan, to_run, model, sandbox, keep, say, interrupts)) _mark_cancelled(runs, interrupts.reason) @@ -242,10 +250,10 @@ def dry_run_bundle( build_sandbox_provider(sandbox) say = _progress(on_progress) with namespace_routing(): - plan = _planned(root, tasks, evals, id_root) + plan, preflight = _planned(root, tasks, evals, id_root, sandbox) materialization = materialize(plan, dry_run=True, on_write=lambda done: say(_written(plan, done))) runs = _to_run(plan) - return DryRun(materialization, runs, _skipped(plan, runs, every=not tasks and not evals)) + return DryRun(materialization, runs, _skipped(plan, runs, every=not tasks and not evals), preflight.infra) def _mark_cancelled(runs: tuple[TaskRun, ...], reason: str) -> None: @@ -269,10 +277,13 @@ def _bundle_run(plan: Plan, materialization: Materialization, runs: tuple[TaskRu return BundleRun(materialization, runs, eval_runs, skipped) -def _planned(root: Path | str, tasks: Sequence[str], evals: Sequence[str], id_root: str | None) -> Plan: +def _planned(root: Path | str, tasks: Sequence[str], evals: Sequence[str], id_root: str | None, + sandbox: str | None) -> tuple[Plan, Preflight]: plan = plan_bundle(resolve_bundle(parse_bundle(Path(root), id_root=id_root)), tasks=tasks, evals=evals) refuse_store_tasks(plan) - return plan + to_run = set(_to_run(plan)) + return plan, preflight_run(plan, [write.source for write in plan.writes + if write.kind is BundleKind.TASK and write.source.entry in to_run], sandbox) def _to_run(plan: Plan) -> tuple[BundleEntry, ...]: diff --git a/src/agent_env/cli/env/gateway.py b/src/agent_env/cli/env/gateway.py index c9f06356..a25eee5f 100644 --- a/src/agent_env/cli/env/gateway.py +++ b/src/agent_env/cli/env/gateway.py @@ -1,17 +1,9 @@ import sys -from pathlib import Path import click -from agent_env.artifact import DockerImageArtifact -from agent_env.cli.utils import build_platform_option, detect_env_metadata -from agent_env.env import GatewayEnv -from agent_env.utils.docker_build import build_image - -_PACKAGE_ROOT = Path(__file__).parent.parent.parent -GATEWAY_DOCKERFILE = _PACKAGE_ROOT / "env" / "gateway" / "Dockerfile" -GATEWAY_CONTEXT = _PACKAGE_ROOT / "env" -GATEWAY_IMAGE_TAG = "env-gateway" +from agent_env.cli.utils import build_platform_option +from agent_env.env.bootstrap import GATEWAY_CONTEXT, GATEWAY_DOCKERFILE, GATEWAY_IMAGE_TAG, put_gateway_env # noqa: F401 @click.group() @@ -26,32 +18,11 @@ def gateway(): @build_platform_option def put(env_id: str, metadata_pairs: tuple[str, ...], build_platform: str): """Build and upload a gateway environment.""" - - click.echo(f"Building gateway Docker image...") - build_image(GATEWAY_DOCKERFILE, GATEWAY_CONTEXT, GATEWAY_IMAGE_TAG, platform=build_platform) - - click.echo(f"Creating DockerImageArtifact...") - artifact = DockerImageArtifact.put( - id=f"gateway-{env_id}", - description="Created from agent-env CLI", - image_name=GATEWAY_IMAGE_TAG, - ) - click.echo(f"Created artifact: id={artifact.id} version={artifact.version}") - - user_metadata = {} + metadata = {} for pair in metadata_pairs: if "=" not in pair: click.echo(f"Invalid metadata format '{pair}', expected key=value", err=True) sys.exit(1) key, value = pair.split("=", 1) - user_metadata[key] = value - metadata = detect_env_metadata(GATEWAY_DOCKERFILE, GATEWAY_CONTEXT) - metadata.update(user_metadata) - - click.echo(f"Creating GatewayEnv...") - env = GatewayEnv.put( - id=env_id, - docker_image_artifact=artifact, - metadata=metadata if metadata else None, - ) - click.echo(f"Created GatewayEnv: id={env.id} version={env.version}") + metadata[key] = value + put_gateway_env(env_id, platform=build_platform, metadata=metadata, say=click.echo) diff --git a/src/agent_env/cli/env/service_db.py b/src/agent_env/cli/env/service_db.py index 121e0590..577e8239 100644 --- a/src/agent_env/cli/env/service_db.py +++ b/src/agent_env/cli/env/service_db.py @@ -1,22 +1,18 @@ """CLI commands for ServiceDBEnv.""" -from pathlib import Path - import click -from agent_env.artifact import DockerImageArtifact -from agent_env.cli.utils import build_platform_option, detect_env_metadata -from agent_env.env.envs.service_db import ServiceDBEnv +from agent_env.cli.utils import build_platform_option from agent_env.config import get_config -from agent_env.utils.docker_build import build_image - -# Path to ServiceDB Dockerfile -SERVICE_DB_DOCKERFILE = Path(__file__).parent.parent.parent / "env" / "envs" / "service_db" / "Dockerfile" -SERVICE_DB_IMAGE_NAME = "agent-env-service-db" -DB_WEB_DOCKERFILE = Path(__file__).parent.parent.parent / "env" / "envs" / "service_db" / "Dockerfile.db-web" -DB_WEB_IMAGE_NAME = "agent-env-db-web" -DB_MCP_DOCKERFILE = Path(__file__).parent.parent.parent / "env" / "envs" / "service_db" / "Dockerfile.db-mcp" -DB_MCP_IMAGE_NAME = "agent-env-db-mcp" +from agent_env.env.bootstrap import ( # noqa: F401 + DB_MCP_DOCKERFILE, + DB_MCP_IMAGE_NAME, + DB_WEB_DOCKERFILE, + DB_WEB_IMAGE_NAME, + SERVICE_DB_DOCKERFILE, + SERVICE_DB_IMAGE_NAME, + put_service_db_env, +) @click.group(name="service-db") @@ -31,69 +27,12 @@ def service_db(): @build_platform_option def put(env_id: str, metadata_pairs: tuple[str, ...], build_platform: str): """Build and upload a ServiceDB environment.""" - if not env_id: - env_id = get_config().default_service_db_env_id - - click.echo(f"Building ServiceDB image from {SERVICE_DB_DOCKERFILE}...") - - # Build Docker image - build_image(SERVICE_DB_DOCKERFILE, SERVICE_DB_DOCKERFILE.parent, SERVICE_DB_IMAGE_NAME, platform=build_platform) - click.echo("Docker build successful") - - # Create DB DockerImageArtifact - click.echo("Creating DB DockerImageArtifact...") - db_artifact = DockerImageArtifact.put( - id=f"service-db-{env_id}", - description=f"ServiceDB PostgreSQL image for {env_id}", - image_name=SERVICE_DB_IMAGE_NAME, - ) - click.echo(f"Created DB DockerImageArtifact: id={db_artifact.id} version={db_artifact.version}") - - # Build db-web image (build instead of pull to avoid docker save manifest issues on Apple Silicon) - click.echo(f"Building db-web image from {DB_WEB_DOCKERFILE}...") - build_image(DB_WEB_DOCKERFILE, DB_WEB_DOCKERFILE.parent, DB_WEB_IMAGE_NAME, platform=build_platform) - click.echo("db-web build successful") - - # Create db-web DockerImageArtifact - click.echo("Creating db-web DockerImageArtifact...") - db_web_artifact = DockerImageArtifact.put( - id=f"db-web-{env_id}", - description="db-web lightweight web UI for database inspection", - image_name=DB_WEB_IMAGE_NAME, - ) - click.echo(f"Created db-web DockerImageArtifact: id={db_web_artifact.id} version={db_web_artifact.version}") - - # Build db-mcp image (PostgreSQL MCP server for direct DB access) - click.echo(f"Building db-mcp image from {DB_MCP_DOCKERFILE}...") - build_image(DB_MCP_DOCKERFILE, DB_MCP_DOCKERFILE.parent, DB_MCP_IMAGE_NAME, platform=build_platform) - click.echo("db-mcp build successful") - - # Create db-mcp DockerImageArtifact - click.echo("Creating db-mcp DockerImageArtifact...") - db_mcp_artifact = DockerImageArtifact.put( - id=f"db-mcp-{env_id}", - description="db-mcp PostgreSQL MCP server for direct DB access", - image_name=DB_MCP_IMAGE_NAME, - ) - click.echo(f"Created db-mcp DockerImageArtifact: id={db_mcp_artifact.id} version={db_mcp_artifact.version}") - - user_metadata = {} + metadata = {} for pair in metadata_pairs: if "=" not in pair: click.echo(f"Invalid metadata format '{pair}', expected key=value", err=True) raise click.Abort() key, value = pair.split("=", 1) - user_metadata[key] = value - metadata = detect_env_metadata(SERVICE_DB_DOCKERFILE, SERVICE_DB_DOCKERFILE.parent) - metadata.update(user_metadata) - - # Create ServiceDBEnv - click.echo("Creating ServiceDBEnv...") - env = ServiceDBEnv.put( - id=env_id, - db_docker_image_artifact=db_artifact, - db_web_docker_image_artifact=db_web_artifact, - db_mcp_docker_image_artifact=db_mcp_artifact, - metadata=metadata if metadata else None, - ) - click.echo(f"Created ServiceDBEnv: id={env.id} version={env.version}") + metadata[key] = value + put_service_db_env(env_id or get_config().default_service_db_env_id, platform=build_platform, metadata=metadata, + say=click.echo) diff --git a/src/agent_env/cli/env/website_browser.py b/src/agent_env/cli/env/website_browser.py index 59f74c46..15c8d315 100644 --- a/src/agent_env/cli/env/website_browser.py +++ b/src/agent_env/cli/env/website_browser.py @@ -1,21 +1,9 @@ import sys -from pathlib import Path import click -from agent_env.artifact import DockerImageArtifact -from agent_env.cli.utils import build_platform_option, detect_env_metadata -from agent_env.env import MCPServerEnv -from agent_env.env.envs.website_browser import ( - PLAYWRIGHT_MCP_VERSION, - WEBSITE_BROWSER_IMAGE_TAG, - WEBSITE_BROWSER_ENVIRONMENT_NAME, -) -from agent_env.utils.docker_build import build_image - -_PACKAGE_ROOT = Path(__file__).parent.parent.parent -WEBSITE_BROWSER_DOCKERFILE = _PACKAGE_ROOT / "env" / "envs" / "website_browser" / "Dockerfile" -WEBSITE_BROWSER_CONTEXT = _PACKAGE_ROOT / "env" / "envs" / "website_browser" +from agent_env.cli.utils import build_platform_option +from agent_env.env.bootstrap import WEBSITE_BROWSER_CONTEXT, WEBSITE_BROWSER_DOCKERFILE, put_website_browser_env # noqa: F401 @click.group(name="website-browser") @@ -32,36 +20,12 @@ def put(env_id: str, metadata_pairs: tuple[str, ...], build_platform: str): """Build and upload a website browser environment.""" from agent_env.config import get_config - if not env_id: - env_id = get_config().default_website_browser_env_id - - click.echo("Building website browser Docker image...") - build_image(WEBSITE_BROWSER_DOCKERFILE, WEBSITE_BROWSER_CONTEXT, WEBSITE_BROWSER_IMAGE_TAG, platform=build_platform, - build_args={"PLAYWRIGHT_MCP_VERSION": PLAYWRIGHT_MCP_VERSION}) - - click.echo("Creating DockerImageArtifact...") - artifact = DockerImageArtifact.put( - id=f"website-browser-{env_id}", - description="Website browser MCP server", - image_name=WEBSITE_BROWSER_IMAGE_TAG, - ) - click.echo(f"Created artifact: id={artifact.id} version={artifact.version}") - - user_metadata = {} + metadata = {} for pair in metadata_pairs: if "=" not in pair: click.echo(f"Invalid metadata format '{pair}', expected key=value", err=True) sys.exit(1) key, value = pair.split("=", 1) - user_metadata[key] = value - metadata = detect_env_metadata(WEBSITE_BROWSER_DOCKERFILE, WEBSITE_BROWSER_CONTEXT) - metadata.update(user_metadata) - - click.echo("Creating MCPServerEnv...") - env = MCPServerEnv.put( - id=env_id, - docker_image_artifact=artifact, - environment_name=WEBSITE_BROWSER_ENVIRONMENT_NAME, - metadata=metadata if metadata else None, - ) - click.echo(f"Created MCPServerEnv: id={env.id} version={env.version} environment_name={env.environment_name}") + metadata[key] = value + put_website_browser_env(env_id or get_config().default_website_browser_env_id, platform=build_platform, + metadata=metadata, say=click.echo) diff --git a/src/agent_env/cli/run.py b/src/agent_env/cli/run.py index d43d636e..3a21d5d7 100644 --- a/src/agent_env/cli/run.py +++ b/src/agent_env/cli/run.py @@ -199,6 +199,10 @@ def _report_dry_run(dry: DryRun) -> None: click.echo(f" {ref.kind} {ref.id} v{plan.store_latest[ref.kind, ref.id]}, the latest") else: click.echo(f" {ref.kind} {ref.id} v{ref.version}") + if dry.infra: + click.echo("Would build first:") + for build in dry.infra: + click.echo(f" {build}") click.echo("Would run:") for entry in dry.runs: click.echo(f" {dry.path(entry)} v{materialization.version_of('task', entry.id)}") diff --git a/src/agent_env/cli/up.py b/src/agent_env/cli/up.py index 327251fc..6ac7dcbe 100644 --- a/src/agent_env/cli/up.py +++ b/src/agent_env/cli/up.py @@ -9,15 +9,10 @@ import importlib.util import logging -import subprocess -import sys -from shutil import which import click - -def _docker(*args: str, check: bool = False) -> subprocess.CompletedProcess: - return subprocess.run(["docker", *args], capture_output=True, text=True, check=check) +from agent_env.env.bootstrap import GATEWAY, SERVICE_DB, InfraError, default_env_id, ensure_default_envs def _require_explorer_deps() -> None: @@ -31,45 +26,16 @@ def _require_explorer_deps() -> None: ) -def _require_docker() -> None: - """Docker is required only where the host actually builds/runs containers (the bootstrap - image builds; the local sandbox/registry later). Checked at point of use, not at ``up`` - startup, so a remote-backed or browse-only explorer needs no local Docker.""" - if which("docker") is None: - raise click.ClickException( - "docker not found on PATH — building the gateway / service-db envs needs a Docker " - "daemon. Install and start Docker, or run `agent-env up --no-bootstrap`." - ) - if _docker("info").returncode != 0: - raise click.ClickException( - "the Docker daemon is not reachable — start Docker, or run `agent-env up --no-bootstrap`." - ) - - def _bootstrap_envs() -> None: - """Register the two envs ``deploy_env`` resolves by id (service-db, gateway) if missing. - Idempotent: existing envs are left as-is, so `up` is cheap after the first run (which builds - their images).""" - from agent_env.config import get_config - from agent_env.env import Env - from agent_env.store import NotFoundError - - cfg = get_config() - wanted = { - cfg.default_service_db_env_id: ("service-db", ["env", "service-db", "put", "--id"]), - cfg.default_gateway_env_id: ("gateway", ["env", "gateway", "put", "--id"]), - } - for env_id, (label, argv) in wanted.items(): - try: - if Env.get(env_id) is not None: - click.echo(f" {label:<12} already registered ({env_id})") - continue - except NotFoundError: - pass - click.echo(f" {label:<12} building + registering ({env_id}) — first run only, this is slow") - _require_docker() # only when we actually build — an already-registered env needs no Docker - if subprocess.run([sys.executable, "-m", "agent_env.cli", *argv, env_id]).returncode != 0: - raise click.ClickException(f"bootstrap of {label} failed") + """Build the two envs ``deploy_env`` resolves by id (service-db, gateway) when they're missing or were built from + other inputs than this release's, so `up` is cheap after the first run, which builds their images.""" + try: + builds = ensure_default_envs((SERVICE_DB, GATEWAY), say=lambda line: click.echo(f" {line}")) + except InfraError as e: + raise click.ClickException(f"{e}; or run `agent-env up --no-bootstrap`") from None + for kind in (SERVICE_DB, GATEWAY): + if kind not in {build.kind for build in builds}: + click.echo(f" {kind:<12} already registered ({default_env_id(kind)})") @click.command() diff --git a/src/agent_env/cli/utils.py b/src/agent_env/cli/utils.py index a0a45920..cd122b30 100644 --- a/src/agent_env/cli/utils.py +++ b/src/agent_env/cli/utils.py @@ -1,7 +1,4 @@ import asyncio -import os -import subprocess -from pathlib import Path import click @@ -9,6 +6,7 @@ from agent_env.providers.env_providers.env_provider import _env_provider_class from agent_env.providers.env_providers.env_server_provider import EnvironmentServerProvider from agent_env.store.base import NotFoundError +from agent_env.utils.build_metadata import detect_base_metadata, detect_env_metadata # noqa: F401 re-exported for the put commands from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM @@ -97,65 +95,6 @@ def parse_artifact_ref(ref: str) -> tuple[str, int | None]: return id_part, version -def detect_base_metadata() -> dict[str, str]: - """Auto-detect non-git metadata (created_by, agent_env_version, etc.).""" - from importlib.metadata import version - metadata: dict[str, str] = {} - metadata["created_by"] = os.getenv("USER", "") - try: - metadata["agent_env_version"] = version("agentenv-framework") - except Exception: - pass - return {k: v for k, v in metadata.items() if v} - - -def detect_env_metadata(dockerfile: Path, context: Path) -> dict[str, str]: - """Auto-detect metadata from a Dockerfile path and its git repo.""" - metadata = detect_base_metadata() - metadata["dockerfile_path"] = str(dockerfile.resolve()) - metadata.update(_detect_git_metadata(context)) - return {k: v for k, v in metadata.items() if v} - - -def _detect_git_metadata(path: Path) -> dict[str, str]: - """Auto-detect git metadata from a path. Returns empty dict if not in a git repo.""" - directory = path if path.is_dir() else path.parent - - def _git(*args: str) -> str | None: - try: - result = subprocess.run( - ["git", "-C", str(directory), *args], - capture_output=True, text=True, timeout=5, - ) - return result.stdout.strip() if result.returncode == 0 else None - except Exception: - return None - - metadata: dict[str, str] = {} - commit = _git("rev-parse", "--short", "HEAD") - if commit: - metadata["git_commit"] = commit - commit_full = _git("rev-parse", "HEAD") - if commit_full: - metadata["git_commit_full"] = commit_full - commit_date = _git("log", "-1", "--format=%aI") - if commit_date: - metadata["git_commit_date"] = commit_date - branch = _git("rev-parse", "--abbrev-ref", "HEAD") - if branch: - metadata["git_branch"] = branch - tag = _git("describe", "--tags", "--exact-match", "HEAD") - if tag: - metadata["git_tag"] = tag - remote = _git("remote", "get-url", "origin") - if remote: - metadata["git_repo"] = remote - dirty = _git("status", "--porcelain") - if dirty is not None: - metadata["git_dirty"] = str(dirty != "").lower() - return metadata - - def deployed_env_from_instance(env_id: str | None, instance_id: str) -> Env: """Rehydrate the live deployed env behind ``instance_id``. diff --git a/src/agent_env/env/bootstrap.py b/src/agent_env/env/bootstrap.py new file mode 100644 index 00000000..f0883aaf --- /dev/null +++ b/src/agent_env/env/bootstrap.py @@ -0,0 +1,249 @@ +"""The infra envs agent-env builds from Dockerfiles it ships: the gateway every gateway deploy runs, the service-db its +local Postgres state runs from, and the website browser it adds for websites. Each has the bare id config names +(``default_gateway_env_id``, ``default_service_db_env_id``, ``default_website_browser_env_id``). + +The put commands build them one at a time; ``ensure_default_envs`` builds the ones a run needs, into local stores only. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path + +from agent_env.artifact import DockerImageArtifact +from agent_env.config import get_config +from agent_env.env.env import Env +from agent_env.env.envs import GatewayEnv, MCPServerEnv +from agent_env.env.envs.service_db import ServiceDBEnv +from agent_env.env.envs.website_browser import ( + PLAYWRIGHT_MCP_VERSION, + WEBSITE_BROWSER_ENVIRONMENT_NAME, + WEBSITE_BROWSER_IMAGE_TAG, +) +from agent_env.store.base import NotFoundError +from agent_env.store.document_store import LocalSqliteDocumentStore +from agent_env.store.image_store import LocalRegistryImageStore +from agent_env.store.local_state import holding_locks +from agent_env.store.object_store import LocalFilesystemObjectStore +from agent_env.store.routing import configured_store +from agent_env.utils.build_metadata import detect_env_metadata +from agent_env.utils.docker_build import build_image, docker_unreachable + +_ENV_PACKAGE = Path(__file__).parent +GATEWAY_DOCKERFILE = _ENV_PACKAGE / "gateway" / "Dockerfile" +GATEWAY_CONTEXT = _ENV_PACKAGE +GATEWAY_IMAGE_TAG = "env-gateway" +SERVICE_DB_DOCKERFILE = _ENV_PACKAGE / "envs" / "service_db" / "Dockerfile" +SERVICE_DB_IMAGE_NAME = "agent-env-service-db" +DB_WEB_DOCKERFILE = _ENV_PACKAGE / "envs" / "service_db" / "Dockerfile.db-web" +DB_WEB_IMAGE_NAME = "agent-env-db-web" +DB_MCP_DOCKERFILE = _ENV_PACKAGE / "envs" / "service_db" / "Dockerfile.db-mcp" +DB_MCP_IMAGE_NAME = "agent-env-db-mcp" +WEBSITE_BROWSER_DOCKERFILE = _ENV_PACKAGE / "envs" / "website_browser" / "Dockerfile" +WEBSITE_BROWSER_CONTEXT = WEBSITE_BROWSER_DOCKERFILE.parent + +GATEWAY, SERVICE_DB, WEBSITE_BROWSER = "gateway", "service-db", "website-browser" # the kinds, named as their commands + + +def _quiet(line: str) -> None: + pass + + +def put_gateway_env(env_id: str, *, platform: str | None, metadata: Mapping[str, str] | None = None, + say: Callable[[str], None] = _quiet) -> GatewayEnv: + """Build the gateway image for ``platform`` (this host's when None) and write it as the gateway env ``env_id``.""" + say("Building gateway Docker image...") + build_image(GATEWAY_DOCKERFILE, GATEWAY_CONTEXT, GATEWAY_IMAGE_TAG, platform=platform) + say("Creating DockerImageArtifact...") + artifact = DockerImageArtifact.put(id=f"gateway-{env_id}", description="Created from agent-env CLI", + image_name=GATEWAY_IMAGE_TAG) + say(f"Created artifact: id={artifact.id} version={artifact.version}") + say("Creating GatewayEnv...") + env = GatewayEnv.put(id=env_id, docker_image_artifact=artifact, + metadata=_metadata(GATEWAY, GATEWAY_DOCKERFILE, GATEWAY_CONTEXT, metadata)) + say(f"Created GatewayEnv: id={env.id} version={env.version}") + return env + + +def put_service_db_env(env_id: str, *, platform: str | None, metadata: Mapping[str, str] | None = None, + say: Callable[[str], None] = _quiet) -> ServiceDBEnv: + """Build the service-db, db-web and db-mcp images for ``platform`` (this host's when None) and write them as the + service-db env ``env_id``.""" + artifacts = [] + for label, dockerfile, image, artifact_id, description in ( + ("ServiceDB", SERVICE_DB_DOCKERFILE, SERVICE_DB_IMAGE_NAME, f"service-db-{env_id}", + f"ServiceDB PostgreSQL image for {env_id}"), + ("db-web", DB_WEB_DOCKERFILE, DB_WEB_IMAGE_NAME, f"db-web-{env_id}", + "db-web lightweight web UI for database inspection"), + ("db-mcp", DB_MCP_DOCKERFILE, DB_MCP_IMAGE_NAME, f"db-mcp-{env_id}", + "db-mcp PostgreSQL MCP server for direct DB access"), + ): + say(f"Building {label} image from {dockerfile}...") + build_image(dockerfile, dockerfile.parent, image, platform=platform) + say(f"Creating {label} DockerImageArtifact...") + artifacts.append(DockerImageArtifact.put(id=artifact_id, description=description, image_name=image)) + say(f"Created {label} DockerImageArtifact: id={artifacts[-1].id} version={artifacts[-1].version}") + say("Creating ServiceDBEnv...") + env = ServiceDBEnv.put( + id=env_id, + db_docker_image_artifact=artifacts[0], + db_web_docker_image_artifact=artifacts[1], + db_mcp_docker_image_artifact=artifacts[2], + metadata=_metadata(SERVICE_DB, SERVICE_DB_DOCKERFILE, SERVICE_DB_DOCKERFILE.parent, metadata), + ) + say(f"Created ServiceDBEnv: id={env.id} version={env.version}") + return env + + +def put_website_browser_env(env_id: str, *, platform: str | None, metadata: Mapping[str, str] | None = None, + say: Callable[[str], None] = _quiet) -> MCPServerEnv: + """Build the website browser image for ``platform`` (this host's when None) and write it as the MCP server env + ``env_id``.""" + say("Building website browser Docker image...") + build_image(WEBSITE_BROWSER_DOCKERFILE, WEBSITE_BROWSER_CONTEXT, WEBSITE_BROWSER_IMAGE_TAG, platform=platform, + build_args={"PLAYWRIGHT_MCP_VERSION": PLAYWRIGHT_MCP_VERSION}) + say("Creating DockerImageArtifact...") + artifact = DockerImageArtifact.put(id=f"website-browser-{env_id}", description="Website browser MCP server", + image_name=WEBSITE_BROWSER_IMAGE_TAG) + say(f"Created artifact: id={artifact.id} version={artifact.version}") + say("Creating MCPServerEnv...") + env = MCPServerEnv.put(id=env_id, docker_image_artifact=artifact, environment_name=WEBSITE_BROWSER_ENVIRONMENT_NAME, + metadata=_metadata(WEBSITE_BROWSER, WEBSITE_BROWSER_DOCKERFILE, WEBSITE_BROWSER_CONTEXT, + metadata)) + say(f"Created MCPServerEnv: id={env.id} version={env.version} environment_name={env.environment_name}") + return env + + +def _metadata(kind: str, dockerfile: Path, context: Path, extra: Mapping[str, str] | None) -> dict[str, str]: + return {**detect_env_metadata(dockerfile, context), **(extra or {}), BUILD_INPUTS_KEY: build_inputs_digest(kind)} + + +BUILD_INPUTS_KEY = "build_inputs_sha256" # the metadata key a put records build_inputs_digest under +_BUILD_INPUTS = { + GATEWAY: lambda: _files_under(GATEWAY_DOCKERFILE.parent), # the gateway's Dockerfile copies only its own folder + SERVICE_DB: lambda: [SERVICE_DB_DOCKERFILE, DB_WEB_DOCKERFILE, DB_MCP_DOCKERFILE], # they copy nothing + WEBSITE_BROWSER: lambda: [WEBSITE_BROWSER_DOCKERFILE, WEBSITE_BROWSER_CONTEXT / "entrypoint.sh"], +} +_BUILD_ARGS = {WEBSITE_BROWSER: {"PLAYWRIGHT_MCP_VERSION": PLAYWRIGHT_MCP_VERSION}} + + +def build_inputs_digest(kind: str) -> str: + """A digest of what agent-env builds ``kind``'s images from: the Dockerfiles it ships, the files they copy and the + build args. What a build fetches (base images, packages) isn't an input.""" + digest = hashlib.sha256() + for path in sorted(_BUILD_INPUTS[kind]()): + digest.update(path.relative_to(_ENV_PACKAGE).as_posix().encode() + b"\0" + path.read_bytes() + b"\0") + for name, value in sorted(_BUILD_ARGS.get(kind, {}).items()): + digest.update(f"{name}={value}\0".encode()) + return digest.hexdigest() + + +def _files_under(folder: Path) -> list[Path]: + return [path for path in folder.rglob("*") + if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc"] + + +_PUTS = {GATEWAY: put_gateway_env, SERVICE_DB: put_service_db_env, WEBSITE_BROWSER: put_website_browser_env} + + +class InfraError(ValueError): + """Infra envs a run needs and can't build, one line each in ``problems``.""" + + def __init__(self, problems: list[str]): + super().__init__("; ".join(problems)) + self.problems = problems + + +@dataclass(frozen=True) +class InfraBuild: + """An infra env a run builds, and why.""" + + kind: str # GATEWAY, SERVICE_DB or WEBSITE_BROWSER + id: str + reason: str # "missing", or which agent-env built the one in the store + + def __str__(self) -> str: + return f"{self.kind} env {self.id!r} ({self.reason})" + + +def default_env_id(kind: str) -> str: + config = get_config() + return {GATEWAY: config.default_gateway_env_id, SERVICE_DB: config.default_service_db_env_id, + WEBSITE_BROWSER: config.default_website_browser_env_id}[kind] + + +def put_command(kind: str) -> str: + return f"agent-env env {kind} put --id {default_env_id(kind)}" + + +def infra_to_build(kinds: Iterable[str]) -> list[InfraBuild]: + """The infra envs of ``kinds`` a run builds: each one missing from the store and, in local stores, each one agent-env + built from the Dockerfile it ships whose recorded build inputs aren't this release's. Raises InfraError naming the + put command for each one missing from stores that aren't all local, which agent-env never builds into.""" + kinds = _in_order(kinds) + if not kinds: + return [] + builds, problems = [], [] + local = stores_are_local() + for kind in kinds: + env_id = default_env_id(kind) + try: + env = Env.get(env_id) + except NotFoundError: + if local: + builds.append(InfraBuild(kind, env_id, "missing")) + else: + problems.append(f"the {kind} env {env_id!r} isn't in the store, and agent-env builds infra envs only into " + f"local stores; put it with `{put_command(kind)}`") + continue + recorded = (env.metadata or {}).get(BUILD_INPUTS_KEY) + if local and _built_from_stock(env, kind) and recorded != build_inputs_digest(kind): + reason = "its build inputs changed" if recorded else "built before agent-env recorded its build inputs" + builds.append(InfraBuild(kind, env_id, reason)) + if problems: + raise InfraError(problems) + return builds + + +def ensure_default_envs(kinds: Iterable[str], *, say: Callable[[str], None] = _quiet) -> list[InfraBuild]: + """Build the infra envs of ``kinds`` that ``infra_to_build`` names, for this host's platform, and return them. Each + id is locked while it's checked and built, so concurrent runs build it once. Raises InfraError before building + anything when one can't be built: missing from stores that aren't local, or docker unreachable.""" + kinds = _in_order(kinds) + if not kinds: + return [] + ids = [default_env_id(kind) for kind in kinds] + with holding_locks(ids, on_wait=lambda: say("waiting for another agent-env run to finish building the infra envs")): + builds = infra_to_build(kinds) + if builds and (reason := docker_unreachable()): + raise InfraError([f"building the {', '.join(build.kind for build in builds)} env needs docker, and {reason}"]) + for build in builds: + say(f"{build}: building, which can take minutes") + _PUTS[build.kind](build.id, platform=None) + return builds + + +def stores_are_local() -> bool: + """Whether the configured document, object and image stores are all the local ones.""" + config = get_config() + return (isinstance(configured_store(config.get_document_store()), LocalSqliteDocumentStore) + and isinstance(configured_store(config.get_object_store()), LocalFilesystemObjectStore) + and isinstance(configured_store(config.get_image_store()), LocalRegistryImageStore)) + + +_STOCK_DOCKERFILES = {GATEWAY: GATEWAY_DOCKERFILE, SERVICE_DB: SERVICE_DB_DOCKERFILE, WEBSITE_BROWSER: WEBSITE_BROWSER_DOCKERFILE} + + +def _built_from_stock(env: Env, kind: str) -> bool: + """Whether ``env`` was built from the Dockerfile agent-env ships for ``kind``, in this install or another one, as + the put commands record it; an env someone built from their own Dockerfile is theirs to rebuild.""" + recorded = (env.metadata or {}).get("dockerfile_path") + shipped = _STOCK_DOCKERFILES[kind].relative_to(_ENV_PACKAGE.parent.parent).parts # ("agent_env", "env", ...) + return recorded is not None and Path(recorded).parts[-len(shipped):] == shipped + + +def _in_order(kinds: Iterable[str]) -> list[str]: + wanted = set(kinds) + return [kind for kind in (SERVICE_DB, GATEWAY, WEBSITE_BROWSER) if kind in wanted] diff --git a/src/agent_env/env/envs/website_browser/Dockerfile b/src/agent_env/env/envs/website_browser/Dockerfile index ad75c449..43b5b40c 100644 --- a/src/agent_env/env/envs/website_browser/Dockerfile +++ b/src/agent_env/env/envs/website_browser/Dockerfile @@ -1,7 +1,9 @@ FROM public.ecr.aws/docker/library/node:22-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 -# Chrome runtime dependencies for headless mode. +# Chromium runtime dependencies for headless mode, and python3 for the socket probe the gateway's compose +# health-checks every MCP server with. RUN apt-get update && apt-get install -y --no-install-recommends \ + python3-minimal \ libnss3 \ libnspr4 \ libatk1.0-0 \ @@ -29,10 +31,11 @@ WORKDIR /app ARG PLAYWRIGHT_MCP_VERSION ENV PLAYWRIGHT_MCP_VERSION=${PLAYWRIGHT_MCP_VERSION} -# Pre-install the pinned version and Chrome. +# Pre-install the pinned version and Playwright's Chromium, which is built for linux/amd64 and linux/arm64 alike +# (Chrome has no Linux arm64 build). RUN npm init -y && \ npm install @playwright/mcp@${PLAYWRIGHT_MCP_VERSION} && \ - npx playwright install chrome + npx playwright install chromium COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/src/agent_env/env/envs/website_browser/entrypoint.sh b/src/agent_env/env/envs/website_browser/entrypoint.sh index 0cc1e7a8..912a2736 100644 --- a/src/agent_env/env/envs/website_browser/entrypoint.sh +++ b/src/agent_env/env/envs/website_browser/entrypoint.sh @@ -4,6 +4,7 @@ set -euo pipefail # MCP_PORT and MCP_HOST are injected by docker-compose (gateway convention). # PLAYWRIGHT_MCP_VERSION is baked into the image at build time. exec npx @playwright/mcp@${PLAYWRIGHT_MCP_VERSION} \ + --browser chromium \ --headless \ --isolated \ --no-sandbox \ diff --git a/src/agent_env/store/local_state.py b/src/agent_env/store/local_state.py index 67de9610..5c45b4d2 100644 --- a/src/agent_env/store/local_state.py +++ b/src/agent_env/store/local_state.py @@ -2,9 +2,19 @@ from __future__ import annotations +import hashlib +import os +from collections.abc import Callable, Iterable, Iterator +from contextlib import contextmanager from pathlib import Path from agent_env.config.errors import ConfigError +from agent_env.config.paths import state_root + +try: + import fcntl +except ImportError: # Windows: writers of one id aren't serialized + fcntl = None def ensure_state_dir(path: Path) -> None: @@ -25,3 +35,33 @@ def ensure_state_dir(path: Path) -> None: f"cannot create the local store directory {path} ({e}); set XDG_STATE_HOME to a " "writable directory, or point the store somewhere writable in config.toml" ) from e + + +@contextmanager +def holding_locks(ids: Iterable[str], on_wait: Callable[[], None] | None = None) -> Iterator[None]: + """Hold a lock on each of ``ids`` in the per-user state root, so another process writing any of them waits + here, calling ``on_wait`` first. The locks are taken in one order, so two holders can't each hold one the + other waits for.""" + if fcntl is None: + yield + return + ensure_state_dir(state_root() / "locks") + fds, waited = [], False + try: + for path in sorted({_lock_path(id) for id in ids}): + fds.append(os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)) + try: + fcntl.flock(fds[-1], fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + if on_wait is not None and not waited: + on_wait() + waited = True + fcntl.flock(fds[-1], fcntl.LOCK_EX) + yield + finally: + for fd in fds: + os.close(fd) + + +def _lock_path(id: str) -> Path: + return state_root() / "locks" / f"entity-{hashlib.sha256(id.encode()).hexdigest()[:16]}.lock" diff --git a/src/agent_env/utils/build_metadata.py b/src/agent_env/utils/build_metadata.py new file mode 100644 index 00000000..62c68758 --- /dev/null +++ b/src/agent_env/utils/build_metadata.py @@ -0,0 +1,65 @@ +"""What a put command records about how an image was built: who built it, with which agent-env, from which +Dockerfile, and the git state of its context.""" + +import os +import subprocess +from importlib.metadata import version +from pathlib import Path + + +def detect_base_metadata() -> dict[str, str]: + """Auto-detect non-git metadata (created_by, agent_env_version, etc.).""" + metadata: dict[str, str] = {} + metadata["created_by"] = os.getenv("USER", "") + try: + metadata["agent_env_version"] = version("agentenv-framework") + except Exception: + pass + return {k: v for k, v in metadata.items() if v} + + +def detect_env_metadata(dockerfile: Path, context: Path) -> dict[str, str]: + """Auto-detect metadata from a Dockerfile path and its git repo.""" + metadata = detect_base_metadata() + metadata["dockerfile_path"] = str(dockerfile.resolve()) + metadata.update(_detect_git_metadata(context)) + return {k: v for k, v in metadata.items() if v} + + +def _detect_git_metadata(path: Path) -> dict[str, str]: + """Auto-detect git metadata from a path. Returns empty dict if not in a git repo.""" + directory = path if path.is_dir() else path.parent + + def _git(*args: str) -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(directory), *args], + capture_output=True, text=True, timeout=5, + ) + return result.stdout.strip() if result.returncode == 0 else None + except Exception: + return None + + metadata: dict[str, str] = {} + commit = _git("rev-parse", "--short", "HEAD") + if commit: + metadata["git_commit"] = commit + commit_full = _git("rev-parse", "HEAD") + if commit_full: + metadata["git_commit_full"] = commit_full + commit_date = _git("log", "-1", "--format=%aI") + if commit_date: + metadata["git_commit_date"] = commit_date + branch = _git("rev-parse", "--abbrev-ref", "HEAD") + if branch: + metadata["git_branch"] = branch + tag = _git("describe", "--tags", "--exact-match", "HEAD") + if tag: + metadata["git_tag"] = tag + remote = _git("remote", "get-url", "origin") + if remote: + metadata["git_repo"] = remote + dirty = _git("status", "--porcelain") + if dirty is not None: + metadata["git_dirty"] = str(dirty != "").lower() + return metadata diff --git a/src/agent_env/utils/docker_build.py b/src/agent_env/utils/docker_build.py index 44123687..870fbd36 100644 --- a/src/agent_env/utils/docker_build.py +++ b/src/agent_env/utils/docker_build.py @@ -1,4 +1,5 @@ -"""Build a Docker image on this machine, the one way every put command and bundle writer does it.""" +"""Build a Docker image on this machine, the one way every put command and bundle writer does it, and check the +machine's Docker daemon answers.""" from __future__ import annotations @@ -7,6 +8,7 @@ from pathlib import Path DEFAULT_BUILD_PLATFORM = "linux/amd64" # the remote sandbox VMs +DOCKER_INFO_TIMEOUT_SECONDS = 10 class DockerBuildError(RuntimeError): @@ -27,3 +29,18 @@ def build_image(dockerfile: Path, context: Path, tag: str, *, platform: str | No raise DockerBuildError(f"docker build of {tag} failed: docker is not on PATH") from e if result.returncode != 0: raise DockerBuildError(f"docker build of {tag} failed (exit {result.returncode}):\n{result.stdout.rstrip()}") + + +def docker_unreachable() -> str | None: + """Why this machine's Docker daemon can't be used, or None when ``docker info`` answers.""" + try: + result = subprocess.run(["docker", "info"], capture_output=True, text=True, errors="replace", + timeout=DOCKER_INFO_TIMEOUT_SECONDS) + except FileNotFoundError: + return "docker isn't on PATH" + except subprocess.TimeoutExpired: + return f"`docker info` didn't answer in {DOCKER_INFO_TIMEOUT_SECONDS}s" + if result.returncode == 0: + return None + reason = next((line.strip() for line in result.stderr.splitlines() if line.strip()), "") + return "the Docker daemon isn't reachable" + (f": {reason}" if reason else "") diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py new file mode 100644 index 00000000..0d1eeef7 --- /dev/null +++ b/tst/unit/bundle/preflight_test.py @@ -0,0 +1,494 @@ +"""Where a bundle run's tasks deploy, checked before anything is written: images only this machine has on another +provider, VMs asked of a provider that can't create one, containers on the local provider without Docker, the default +agent, and the infra envs a gateway deploy on the local provider needs.""" + +import asyncio +import json +import logging +from typing import ClassVar + +import pytest +from click.testing import CliRunner + +import agent_env.bundle.preflight as preflight_module +from agent_env.a2a_agent import A2AAgent +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.bundle import BundleError, dry_run_bundle, run_bundle +from agent_env.cli import cli +from agent_env.config.runtime import Config +from agent_env.env import Env, GatewayEnv, MCPServerEnv, MultiEnv +from agent_env.env import bootstrap +from agent_env.env.envs.service_db import ServiceDBEnv +from agent_env.env.envs.website import WebsiteEnv +from agent_env.env.store import get_env_store +from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider +from agent_env.providers.env_state.env_state_provider import EnvStateInstance +from agent_env.providers.env_state.store import register_env_state_instance +from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider +from agent_env.store.routing import namespace_routing +from agent_env.task_step.context import PromptResponse, TaskStepContext +from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep +from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep +from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep +from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep +from tst.unit.bundle._support import layout, local_store + +LOCAL = ("localhost:5000/local/img:v1", "file:///state/img-v1.tar.gz") +REMOTE = ("123456789012.dkr.ecr.us-west-2.amazonaws.com/img:v1", "s3://bucket/img-v1.tar.gz") + + +@pytest.fixture +def bundle_dir(tmp_path, monkeypatch, local_stores): + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path / "triage" + + +@pytest.fixture +def quiet_logs(): + """pytest's live logging swaps its own stdout back in to print a record, so CliRunner loses whatever is + echoed after the first one.""" + logging.disable(logging.CRITICAL) + yield + logging.disable(logging.NOTSET) + + +def _task(root, steps, name="t"): + layout(root, {f"tasks/{name}.json": json.dumps(steps)}) + + +def _image(id, where=LOCAL): + with namespace_routing(): + return get_artifact_store().put_document(DockerImageArtifact( + id=id, description=id, image_name=where[0], tar_gz_s3_url=where[1])) + + +def _agent(id, where=LOCAL): + with namespace_routing(): + return A2AAgent.put(id=id, docker_image_artifact=_image(f"{id}-image", where)) + + +def _env(id, where=LOCAL, **fields): + with namespace_routing(): + return MCPServerEnv.put(id=id, docker_image_artifact=_image(f"{id}-image", where), environment_name=id, **fields) + + +def _infra(where=LOCAL, built_from=None): + """The infra envs, as agent-env would have built them from inputs whose digest is ``built_from``.""" + def metadata(dockerfile): + return {"dockerfile_path": str(dockerfile), bootstrap.BUILD_INPUTS_KEY: built_from} if built_from else None + + with namespace_routing(): + GatewayEnv.put(id="default", docker_image_artifact=_image("gateway-default", where), + metadata=metadata(bootstrap.GATEWAY_DOCKERFILE)) + ServiceDBEnv.put(id="default-db", db_docker_image_artifact=_image("service-db-default-db", where), + db_web_docker_image_artifact=_image("db-web-default-db", where), + db_mcp_docker_image_artifact=_image("db-mcp-default-db", where), + metadata=metadata(bootstrap.SERVICE_DB_DOCKERFILE)) + + +def _problems(fn): + with pytest.raises(BundleError) as e: + fn() + return list(e.value.problems) + + +AGENT = {"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"} + + +# Images only this machine has + + +@pytest.mark.parametrize("sandbox", ["modal", "local,modal"]) +def test_an_image_only_this_machine_has_is_refused_on_another_provider_before_anything_is_written(bundle_dir, sandbox): + _agent("solver") + _task(bundle_dir, [AGENT]) + + assert _problems(lambda: run_bundle(bundle_dir, sandbox=sandbox)) == [ + "tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal' sandbox provider, which can't reach it: " + "localhost:5000/local/img:v1 is in a registry on this machine; run it with --sandbox local", + ] + assert not local_store().path.exists() + + +def test_an_image_saved_only_in_this_machines_object_store_is_refused_too(bundle_dir): + _agent("solver", (REMOTE[0], LOCAL[1])) + _task(bundle_dir, [AGENT]) + + (problem,) = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) + assert problem.endswith("can't reach it: 'solver-image' is saved in this machine's object store; run it with " + "--sandbox local") + + +def test_a_remote_image_runs_anywhere_and_a_local_one_on_the_local_provider(bundle_dir): + _agent("solver", REMOTE) + _agent("helper") + _task(bundle_dir, [AGENT, {**AGENT, "id": "helper", "agent_name": "helper", "a2a_agent_id": "helper"}]) + + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="local").runs] == ["t"] + (problem,) = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) + assert problem.startswith("tasks/t.json: step 'helper': deploys agent 'helper''s image") + + +def test_an_agent_built_from_the_bundle_is_refused_on_another_provider(bundle_dir): + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) + _task(bundle_dir, [AGENT]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) == [ + "tasks/t.json: step 'agent': deploys agent '@local/~/triage/solver''s image on the 'modal' sandbox provider, " + "which can't reach it: it's built on this machine from agents/solver/Dockerfile; run it with --sandbox local", + ] + + +def test_a_sandbox_image_in_this_machines_registry_is_refused_on_another_provider(bundle_dir): + sandbox = {"id": "box", "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "container", "port": 80} + _task(bundle_dir, [{**sandbox, "image": "localhost:5000/box:v1"}, {**sandbox, "id": "py", "sandbox_name": "py", + "image": "python:3.12"}]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) == [ + "tasks/t.json: step 'box': deploys the image localhost:5000/box:v1 on the 'modal' sandbox provider, which can't " + "reach it: localhost:5000/box:v1 is in a registry on this machine; run it with --sandbox local", + ] + + +# What a provider can create + + +@pytest.mark.parametrize("sandbox, refused", [("modal", True), ("local,modal_vm", True), ("modal_vm", False), (None, False)]) +def test_a_vm_sandbox_is_refused_on_a_provider_that_cant_create_one(bundle_dir, sandbox, refused): + _task(bundle_dir, [{"id": "box", "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "vm", + "sandbox_type": "local"}]) + + if refused: + (problem,) = _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) + assert problem == (f"tasks/t.json: step 'box': deploys a VM sandbox, and the {sandbox!r} sandbox provider can't " + "create a VM; run it on one that can, such as --sandbox local") + else: + dry_run_bundle(bundle_dir, sandbox=sandbox) + + +def test_a_step_naming_a_provider_that_doesnt_exist_is_refused(bundle_dir): + _agent("solver") + _task(bundle_dir, [{**AGENT, "sandbox_type": "nosuch"}]) + + (problem,) = _problems(lambda: dry_run_bundle(bundle_dir)) + assert problem.startswith("tasks/t.json: step 'agent': Unknown sandbox backend: 'nosuch'") + + +# Docker + + +def test_containers_on_the_local_provider_need_docker_and_a_vm_sandbox_doesnt(bundle_dir, monkeypatch): + probes = [] + monkeypatch.setattr(preflight_module, "docker_unreachable", lambda: probes.append(1) or "docker isn't on PATH") + _task(bundle_dir, [{"id": "box", "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "vm", + "sandbox_type": "local"}], name="hello") + _agent("solver") + _task(bundle_dir, [AGENT], name="agent") + + dry_run_bundle(bundle_dir, tasks=["hello"]) + assert probes == [] + assert _problems(lambda: dry_run_bundle(bundle_dir, tasks=["agent", "hello"])) == [ + "the local sandbox provider runs containers for tasks/agent.json: step 'agent', and docker isn't on PATH", + ] + + +class _DeploysItself(Env): + """An env type with a deploy() of its own, as a plugin's env can have.""" + + type: ClassVar[str] = "deploys_itself_preflight_test" + description = "test" + + @classmethod + def from_dict(cls, data): + return cls(id=data["id"], version=data.get("version")) + + async def deploy(self, **kwargs): + raise NotImplementedError + + +class _BuildsItsOwnGateway(_DeploysItself): + """One that sets up a gateway itself, with no image of its own.""" + + type: ClassVar[str] = "builds_its_own_gateway_preflight_test" + + def __init__(self, id, version, metadata=None): + super().__init__(id, version, metadata=metadata) + self._env_provider = EnvironmentGatewayProvider() + + +@pytest.mark.parametrize("cls", [_DeploysItself, _BuildsItsOwnGateway]) +def test_an_env_type_that_deploys_itself_is_left_to_its_own_deploy(bundle_dir, monkeypatch, cls): + registry = Config.env_registry + monkeypatch.setattr(Config, "env_registry", lambda self: {**registry(self), cls.type: cls}) + with namespace_routing(): + get_env_store().put_document(cls(id="plug", version=None)) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "plug"}]) + + assert dry_run_bundle(bundle_dir, sandbox="modal").infra == () + + +def test_a_multi_env_on_the_server_provider_is_left_to_deploy_envs_own_refusal(bundle_dir): + crm = _env("crm") + with namespace_routing(): + MultiEnv.put(id="both", mcp_server_envs=[crm], env_provider_type="server") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "both"}]) + + (problem,) = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) + assert "deploys one MCP server, not a multi env" in problem + + +def test_a_website_on_modal_is_refused_since_its_gateway_runs_in_containers(bundle_dir): + with namespace_routing(): + WebsiteEnv.put(id="shop", backend_docker_image_artifact=_image("shop-back", REMOTE), + frontend_docker_image_artifact=_image("shop-front", REMOTE), environment_name="shop") + _infra(REMOTE) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "shop"}]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) == [ + "tasks/t.json: step 'env': deploys env 'shop', which has websites, on the 'modal' sandbox provider, whose " + "gateway runs in containers and can't serve websites; run it on a VM provider, such as --sandbox local", + ] + + +def test_an_agent_over_another_of_the_bundles_writes_gets_that_writes_own_refusal(bundle_dir): + layout(bundle_dir, {"artifacts/base/Dockerfile": "FROM scratch\n", "agents/solver/agent.toml": 'image = "base"\n'}) + _task(bundle_dir, [AGENT]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) == [ + "artifacts/base: writing a docker_image artifact isn't supported yet"] + + +# The default agent + + +def test_a_step_that_names_no_agent_needs_the_default_in_the_store(bundle_dir, monkeypatch): + monkeypatch.setattr("agent_env.config.runtime.Config.get_default_a2a_agent_id", lambda self: "house-agent") + _task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": []}, + {"id": "judge", "type": "rubrics_verifier", "prompt_id": "p", "verifier_id": "v", + "criteria": [{"id": "c", "description": "done"}]}]) + + assert _problems(lambda: dry_run_bundle(bundle_dir)) == [ + "tasks/t.json: step 'agent': names no agent, so it deploys the default, 'house-agent', and there is no agent " + "'house-agent' in the store; register one under that id, or point [agents] default_a2a_agent_id at an agent " + "that is", + "tasks/t.json: step 'judge': names no judge agent, so it deploys the default, 'house-agent', and there is no " + "agent 'house-agent' in the store; register one under that id, or point [agents] default_a2a_agent_id at an " + "agent that is", + ] + _agent("house-agent") + dry_run_bundle(bundle_dir) + + +def test_a_judge_the_task_deploys_itself_or_the_direct_llm_judge_needs_no_default_agent(bundle_dir, monkeypatch): + monkeypatch.setattr("agent_env.config.runtime.Config.get_default_a2a_agent_id", lambda self: "house-agent") + judge = {"type": "rubrics_verifier", "prompt_id": "p", "verifier_id": "v", "criteria": [{"id": "c", "description": "done"}]} + _agent("solver") + _task(bundle_dir, [AGENT, {**judge, "id": "own", "agent_name": "default-agent"}, + {**judge, "id": "llm", "use_agent_judge": False, "default_model": "m"}]) + + dry_run_bundle(bundle_dir) + + +# Infra envs + + +def test_a_gateway_deploy_on_the_local_provider_names_the_infra_it_builds(bundle_dir): + _env("crm") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + + assert [str(build) for build in dry_run_bundle(bundle_dir).infra] == [ + "service-db env 'default-db' (missing)", "gateway env 'default' (missing)"] + + +def test_external_state_needs_no_service_db_and_a_website_needs_the_browser(bundle_dir): + with namespace_routing(): + WebsiteEnv.put(id="shop", backend_docker_image_artifact=_image("shop-back"), + frontend_docker_image_artifact=_image("shop-front"), environment_name="shop") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "shop", "env_state_type": "external_db"}]) + + assert [build.kind for build in dry_run_bundle(bundle_dir).infra] == ["gateway", "website-browser"] + + +@pytest.mark.parametrize("state_type, kinds", [("local_postgres", ["service-db", "gateway"]), ("external_db", ["gateway"])]) +def test_an_attached_state_instance_needs_the_service_db_by_its_own_type(bundle_dir, state_type, kinds): + _env("crm") + with namespace_routing(): + register_env_state_instance(EnvStateInstance(state_type=state_type, instance_id="state-1"), ttl_seconds=600) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm", "env_state_instance_id": "state-1"}]) + + assert [build.kind for build in dry_run_bundle(bundle_dir).infra] == kinds + + +def test_infra_built_from_other_inputs_is_rebuilt_and_infra_built_from_this_releases_isnt(bundle_dir, monkeypatch): + _env("crm") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + _infra(built_from="an earlier release's") + + assert [str(build) for build in dry_run_bundle(bundle_dir).infra] == [ + "service-db env 'default-db' (its build inputs changed)", + "gateway env 'default' (its build inputs changed)", + ] + monkeypatch.setattr("agent_env.env.bootstrap.build_inputs_digest", lambda kind: "an earlier release's") + assert dry_run_bundle(bundle_dir).infra == () + + +def test_missing_infra_in_stores_that_arent_local_is_refused_naming_its_put_command(bundle_dir, monkeypatch): + monkeypatch.setattr("agent_env.env.bootstrap.stores_are_local", lambda: False) + _env("crm") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + + assert _problems(lambda: dry_run_bundle(bundle_dir)) == [ + "the gateway env 'default' isn't in the store, and agent-env builds infra envs only into local stores; put it " + "with `agent-env env gateway put --id default`", + "the service-db env 'default-db' isn't in the store, and agent-env builds infra envs only into local stores; " + "put it with `agent-env env service-db put --id default-db`", + ] + + +def test_a_gateway_deploy_on_another_provider_needs_infra_it_can_reach(bundle_dir): + _env("crm", REMOTE) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + + (problem, *_) = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) + assert problem.startswith("tasks/t.json: step 'env': deploys on the 'modal_vm' sandbox provider, which needs the " + "gateway env 'default', and the store doesn't hold it") + _infra() + problems = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) + assert problems[0] == ("tasks/t.json: step 'env': deploys the gateway env 'default''s image 'gateway-default' on the " + "'modal_vm' sandbox provider, which can't reach it: localhost:5000/local/img:v1 is in a " + "registry on this machine; run it with --sandbox local") + assert len(problems) == 4 # the gateway's image, and the service-db's three + + +def test_a_problem_several_deploys_share_is_reported_once_naming_the_first(bundle_dir): + _env("crm") + with namespace_routing(): + GatewayEnv.put(id="default", docker_image_artifact=_image("gateway-default")) + db = _image("db") # one image for all three of the service-db's, so each deploy finds its problem three times + ServiceDBEnv.put(id="default-db", db_docker_image_artifact=db, db_web_docker_image_artifact=db, + db_mcp_docker_image_artifact=db) + for name in ("a", "b", "c"): + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}], name=name) + + unreachable = ("on the 'modal_vm' sandbox provider, which can't reach it: localhost:5000/local/img:v1 is in a " + "registry on this machine; run it with --sandbox local") + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) == [ + f"tasks/a.json: step 'env' (and 2 more deploys): deploys env 'crm''s image 'crm-image' {unreachable}", + f"tasks/a.json: step 'env' (and 2 more deploys): deploys the gateway env 'default''s image 'gateway-default' " + f"{unreachable}", + f"tasks/a.json: step 'env' (and 2 more deploys): deploys the service-db env 'default-db''s image 'db' " + f"{unreachable}", + ] + + +def test_the_cli_dry_run_lists_the_infra_it_would_build(bundle_dir, quiet_logs): + _env("crm") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + + result = CliRunner().invoke(cli, ["run", str(bundle_dir), "--dry-run"]) + + assert result.exit_code == 0, result.output + assert ("Would build first:\n service-db env 'default-db' (missing)\n gateway env 'default' (missing)\n" + "Would run:\n tasks/t.json v1\n") in result.output + + +def test_a_run_builds_its_infra_after_its_writes_and_before_its_tasks(bundle_dir, monkeypatch): + _env("crm") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + events = [] + monkeypatch.setattr("agent_env.bundle.run.ensure_default_envs", + lambda kinds, say: events.append(("bootstrap", sorted(kinds)))) + + async def deploy(self, context): + events.append(("deploy", self.env_id)) + return context + + monkeypatch.setattr(DeployEnvTaskStep, "execute", deploy) + + run_bundle(bundle_dir, on_progress=lambda line: events.append(("say", line))) + + assert events[:3] == [("say", "tasks/t.json: v1 (new)"), ("bootstrap", ["gateway", "service-db"]), + ("say", "[tasks/t.json] step 1/1 env (deploy_env)")] + assert ("deploy", "crm") in events + + +# The walk resolves each deploy's provider as its step does + + +class _Stop(Exception): + pass + + +_STEPS = { + "deploy_env": {"id": "s", "type": "deploy_env", "env_id": "crm"}, + "deploy_agent": {"id": "s", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}, + "deploy_sandbox": {"id": "s", "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "container", + "image": "python:3.12", "port": 80}, + "rubrics_verifier": {"id": "s", "type": "rubrics_verifier", "prompt_id": "p", "verifier_id": "v", + "criteria": [{"id": "c", "description": "done"}], "judge_a2a_agent_id": "solver", + "use_trajectory": False}, +} +_FIELDS = {"deploy_env": "sandbox_type", "deploy_agent": "sandbox_type", "deploy_sandbox": "sandbox_type", + "rubrics_verifier": "judge_sandbox_type"} +# What a step falls back to with no provider named: deploy_env's env through deploy_through_provider, the agents through +# A2AAgent.deploy, deploy_sandbox itself. +_DEFAULT_GETTERS = {"deploy_env": "get_env_sandbox_provider", "deploy_agent": "get_agent_sandbox_provider", + "deploy_sandbox": "get_sandbox_provider", "rubrics_verifier": "get_agent_sandbox_provider"} +_OVERRIDE_KEYS = {"deploy_env": "env_sandbox", "deploy_agent": "agent_sandbox", "deploy_sandbox": "sandbox", + "rubrics_verifier": "agent_sandbox"} + + +def _walked_spec(bundle_dir, monkeypatch, config, sandbox): + """The provider the walk resolves for the one deploy in ``config``: a spec, or "default".""" + seen = [] + monkeypatch.setattr(preflight_module, "build_sandbox_provider", lambda spec: seen.append(spec) or LocalSandboxProvider()) + for getter in ("get_env_sandbox_provider", "get_agent_sandbox_provider", "get_sandbox_provider"): + monkeypatch.setattr(preflight_module, getter, lambda getter=getter: seen.append(getter) or LocalSandboxProvider()) + _task(bundle_dir, [config]) + dry_run_bundle(bundle_dir, sandbox=sandbox) + return seen[0] + + +def _executed_spec(monkeypatch, kind, config, sandbox): + """The provider the step itself resolves when it runs, with the overrides ``run_bundle`` sets for ``sandbox``.""" + seen = [] + step = {"deploy_env": DeployEnvTaskStep, "deploy_agent": DeployAgentTaskStep, "deploy_sandbox": DeploySandboxTaskStep, + "rubrics_verifier": RubricsVerifierTaskStep}[kind].from_dict({**config, "version": 1}) + + class _Deployable: + metadata = {} + + async def deploy(self, **kwargs): + seen.append(kwargs.get("sandbox_type") or "default") + raise _Stop + + monkeypatch.setattr("agent_env.env.env.Env.get", classmethod(lambda cls, *a, **k: _Deployable())) + monkeypatch.setattr(A2AAgent, "get", classmethod(lambda cls, *a, **k: _Deployable())) + monkeypatch.setattr("agent_env.providers.sandbox_providers.sandbox_provider.build_sandbox_provider", + lambda spec: seen.append(spec) or (_ for _ in ()).throw(_Stop())) + monkeypatch.setattr("agent_env.providers.sandbox_providers.sandbox_provider.get_sandbox_provider", + lambda: seen.append("default") or (_ for _ in ()).throw(_Stop())) + overrides = {"env_sandbox": sandbox, "agent_sandbox": sandbox, "sandbox": sandbox} if sandbox else {} + context = TaskStepContext(prompt_responses=[PromptResponse(prompt_id="p", response="done", prompt_text="do it")], + metadata={"user_overrides": {**overrides, "judge_litellm_api_key": "k", + "judge_litellm_base_url": "http://litellm"}}) + with pytest.raises(_Stop): + asyncio.run(step.execute(context)) + return seen[0] + + +@pytest.mark.parametrize("kind", list(_STEPS)) +@pytest.mark.parametrize("field, sandbox", [(None, None), ("modal_vm", None), ("modal_vm", "modal")], + ids=["default", "step", "override"]) +def test_the_walk_resolves_each_deploys_provider_as_its_step_does(bundle_dir, monkeypatch, kind, field, sandbox): + _env("crm", REMOTE) + _agent("solver", REMOTE) + config = {**_STEPS[kind], **({_FIELDS[kind]: field} if field else {})} + + walked = _walked_spec(bundle_dir, monkeypatch, config, sandbox) + executed = _executed_spec(monkeypatch, kind, config, sandbox) + + assert executed == (sandbox or field or "default") + assert walked == (sandbox or field or _DEFAULT_GETTERS[kind]) + assert _OVERRIDE_KEYS[kind] # run_bundle sets every override key to --sandbox diff --git a/tst/unit/cli/build_platform_test.py b/tst/unit/cli/build_platform_test.py index bbbc4cd3..6b7a0577 100644 --- a/tst/unit/cli/build_platform_test.py +++ b/tst/unit/cli/build_platform_test.py @@ -43,12 +43,12 @@ def _put_commands(tmp_path): "website": ("agent_env.cli.env.website", ["env", "website", "put", "--id", "x", "--environment-name", "shop", "--backend-dockerfile", str(backend), "--frontend-dockerfile", str(frontend)], (backend, tmp_path, "website-backend-x"), None), - "gateway": ("agent_env.cli.env.gateway", ["env", "gateway", "put", "--id", "x"], + "gateway": ("agent_env.env.bootstrap", ["env", "gateway", "put", "--id", "x"], (gateway.GATEWAY_DOCKERFILE, gateway.GATEWAY_CONTEXT, gateway.GATEWAY_IMAGE_TAG), None), - "service-db": ("agent_env.cli.env.service_db", ["env", "service-db", "put", "--id", "x"], + "service-db": ("agent_env.env.bootstrap", ["env", "service-db", "put", "--id", "x"], (service_db.SERVICE_DB_DOCKERFILE, service_db.SERVICE_DB_DOCKERFILE.parent, service_db.SERVICE_DB_IMAGE_NAME), None), - "website-browser": ("agent_env.cli.env.website_browser", ["env", "website-browser", "put", "--id", "x"], + "website-browser": ("agent_env.env.bootstrap", ["env", "website-browser", "put", "--id", "x"], (website_browser.WEBSITE_BROWSER_DOCKERFILE, website_browser.WEBSITE_BROWSER_CONTEXT, WEBSITE_BROWSER_IMAGE_TAG), {"PLAYWRIGHT_MCP_VERSION": PLAYWRIGHT_MCP_VERSION}), } @@ -74,7 +74,7 @@ def _multi_build_puts(tmp_path): """The puts that build more than one image: the env type each writes, and every build it makes.""" backend, frontend = _dockerfile(tmp_path / "backend"), _dockerfile(tmp_path / "frontend") return { - "service-db": ("agent_env.cli.env.service_db", "ServiceDBEnv", ["env", "service-db", "put", "--id", "x"], [ + "service-db": ("agent_env.env.bootstrap", "ServiceDBEnv", ["env", "service-db", "put", "--id", "x"], [ (service_db.SERVICE_DB_DOCKERFILE, service_db.SERVICE_DB_DOCKERFILE.parent, service_db.SERVICE_DB_IMAGE_NAME), (service_db.DB_WEB_DOCKERFILE, service_db.DB_WEB_DOCKERFILE.parent, service_db.DB_WEB_IMAGE_NAME), (service_db.DB_MCP_DOCKERFILE, service_db.DB_MCP_DOCKERFILE.parent, service_db.DB_MCP_IMAGE_NAME), diff --git a/tst/unit/cli/sandbox_url_relocation_test.py b/tst/unit/cli/sandbox_url_relocation_test.py index 64ed8408..fb208f7d 100644 --- a/tst/unit/cli/sandbox_url_relocation_test.py +++ b/tst/unit/cli/sandbox_url_relocation_test.py @@ -96,8 +96,8 @@ def test_service_db_put_falls_back_to_the_config_default(): cfg = _config(default_service_db_env_id="svc-db-from-config") with patch("agent_env.cli.env.service_db.get_config", return_value=cfg), \ - patch("agent_env.cli.env.service_db.DockerImageArtifact") as artifact, \ - patch("agent_env.cli.env.service_db.build_image"): + patch("agent_env.env.bootstrap.DockerImageArtifact") as artifact, \ + patch("agent_env.env.bootstrap.build_image"): artifact.put.side_effect = RuntimeError("stop once the id is recorded") res = CliRunner().invoke(service_db, ["put"]) @@ -109,7 +109,7 @@ def test_service_db_put_explicit_id_wins_and_skips_the_config_read(): from agent_env.cli.env.service_db import service_db with patch("agent_env.cli.env.service_db.get_config") as get_config, \ - patch("agent_env.cli.env.service_db.build_image") as run: + patch("agent_env.env.bootstrap.build_image") as run: run.side_effect = DockerBuildError("stop here") CliRunner().invoke(service_db, ["put", "--id", "explicit-env"]) @@ -121,8 +121,8 @@ def test_website_browser_put_falls_back_to_the_config_default(): cfg = _config(default_website_browser_env_id="wb-from-config") with patch("agent_env.config.get_config", return_value=cfg), \ - patch("agent_env.cli.env.website_browser.DockerImageArtifact") as artifact, \ - patch("agent_env.cli.env.website_browser.build_image"): + patch("agent_env.env.bootstrap.DockerImageArtifact") as artifact, \ + patch("agent_env.env.bootstrap.build_image"): artifact.put.side_effect = RuntimeError("stop once the id is recorded") res = CliRunner().invoke(website_browser, ["put"]) diff --git a/tst/unit/conftest.py b/tst/unit/conftest.py index 82406a73..e8583a78 100644 --- a/tst/unit/conftest.py +++ b/tst/unit/conftest.py @@ -46,6 +46,14 @@ def _disable_network(): enable_socket() +@pytest.fixture(autouse=True) +def _docker_answers(monkeypatch): + """A bundle run asks whether Docker answers before it deploys containers locally or builds an infra env; unit + tests never reach a daemon, so it answers. Tests of the probe itself patch ``subprocess.run``.""" + monkeypatch.setattr("agent_env.bundle.preflight.docker_unreachable", lambda: None) + monkeypatch.setattr("agent_env.env.bootstrap.docker_unreachable", lambda: None) + + @pytest.fixture(autouse=True) def isolated_state_root(tmp_path_factory, monkeypatch): """A fresh per-user state root for every unit test, overriding the run's.""" diff --git a/tst/unit/env/bootstrap_test.py b/tst/unit/env/bootstrap_test.py new file mode 100644 index 00000000..f624448b --- /dev/null +++ b/tst/unit/env/bootstrap_test.py @@ -0,0 +1,179 @@ +"""The infra envs a run builds: the ones missing from the store, or built by another agent-env release, into local +stores only, for this host's platform, once each however many runs ask.""" + +import importlib + +import pytest +from click.testing import CliRunner + +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.cli.up import _bootstrap_envs, up +from agent_env.env import GatewayEnv +from agent_env.env import bootstrap +from agent_env.env.bootstrap import GATEWAY, SERVICE_DB, WEBSITE_BROWSER, InfraError, ensure_default_envs + + +@pytest.fixture +def puts(local_stores, monkeypatch): + """Stands in for each put: records the kind, id and platform, and writes the env as this release's.""" + calls = [] + + def put(kind): + def write(env_id, *, platform): + calls.append((kind, env_id, platform)) + artifact = get_artifact_store().put_document(DockerImageArtifact( + id=f"{kind}-{env_id}", description=kind, image_name=f"{kind}:v1", tar_gz_s3_url="file:///x.tar.gz")) + shipped = bootstrap._STOCK_DOCKERFILES[kind].relative_to(bootstrap._ENV_PACKAGE.parent.parent) + GatewayEnv.put(id=env_id, docker_image_artifact=artifact, metadata={ + "dockerfile_path": f"/another/install/site-packages/{shipped}", + bootstrap.BUILD_INPUTS_KEY: bootstrap.build_inputs_digest(kind)}) + return write + + for kind in (GATEWAY, SERVICE_DB, WEBSITE_BROWSER): + monkeypatch.setitem(bootstrap._PUTS, kind, put(kind)) + return calls + + +def test_missing_infra_is_built_for_this_host_in_order_and_once(puts): + said = [] + + builds = ensure_default_envs([WEBSITE_BROWSER, GATEWAY, SERVICE_DB], say=said.append) + + assert puts == [(SERVICE_DB, "default-db", None), (GATEWAY, "default", None), (WEBSITE_BROWSER, "website-browser", None)] + assert [str(build) for build in builds] == [ + "service-db env 'default-db' (missing)", "gateway env 'default' (missing)", + "website-browser env 'website-browser' (missing)"] + assert said[0] == "service-db env 'default-db' (missing): building, which can take minutes" + assert ensure_default_envs([GATEWAY, SERVICE_DB]) == [] + assert len(puts) == 3 + + +def test_infra_built_from_other_inputs_is_rebuilt_once(puts, monkeypatch): + ensure_default_envs([GATEWAY]) + monkeypatch.setattr(bootstrap, "build_inputs_digest", lambda kind: "this release's") + + (build,) = ensure_default_envs([GATEWAY]) + + assert build.reason == "its build inputs changed" + assert ensure_default_envs([GATEWAY]) == [] + assert len(puts) == 2 + + +def test_infra_built_before_its_inputs_were_recorded_is_rebuilt(puts): + artifact = get_artifact_store().put_document(DockerImageArtifact( + id="gateway-default", description="g", image_name="g:v1", tar_gz_s3_url="file:///g.tar.gz")) + GatewayEnv.put(id="default", docker_image_artifact=artifact, + metadata={"agent_env_version": "0.9.1", "dockerfile_path": str(bootstrap.GATEWAY_DOCKERFILE)}) + + (build,) = ensure_default_envs([GATEWAY]) + + assert build.reason == "built before agent-env recorded its build inputs" + assert puts == [(GATEWAY, "default", None)] + + +def test_infra_that_records_no_shipped_dockerfile_is_left_as_it_is(puts): + artifact = get_artifact_store().put_document(DockerImageArtifact( + id="gateway-default", description="g", image_name="g:v1", tar_gz_s3_url="file:///g.tar.gz")) + GatewayEnv.put(id="default", docker_image_artifact=artifact) + + assert ensure_default_envs([GATEWAY]) == [] + assert puts == [] + + +def test_an_env_built_from_someone_elses_dockerfile_is_theirs_to_rebuild(puts, monkeypatch): + artifact = get_artifact_store().put_document(DockerImageArtifact( + id="website-browser-website-browser", description="b", image_name="b:v1", tar_gz_s3_url="file:///b.tar.gz")) + GatewayEnv.put(id="website-browser", docker_image_artifact=artifact, + metadata={"dockerfile_path": "/home/me/my-browser/Dockerfile", bootstrap.BUILD_INPUTS_KEY: "theirs"}) + + assert ensure_default_envs([WEBSITE_BROWSER]) == [] + assert puts == [] + + +def test_a_run_that_needs_no_infra_reads_no_store(monkeypatch): + monkeypatch.setattr(bootstrap, "stores_are_local", lambda: pytest.fail("read the configured stores")) + + assert bootstrap.infra_to_build([]) == [] + + +def test_nothing_is_built_without_docker(puts, monkeypatch): + monkeypatch.setattr(bootstrap, "docker_unreachable", lambda: "docker isn't on PATH") + + with pytest.raises(InfraError) as e: + ensure_default_envs([GATEWAY, SERVICE_DB]) + + assert e.value.problems == ["building the service-db, gateway env needs docker, and docker isn't on PATH"] + assert puts == [] + + +def test_stores_that_arent_local_are_never_built_into(puts, monkeypatch): + ensure_default_envs([GATEWAY]) + monkeypatch.setattr(bootstrap, "stores_are_local", lambda: False) + monkeypatch.setattr(bootstrap, "build_inputs_digest", lambda kind: "this release's") + + with pytest.raises(InfraError) as e: + ensure_default_envs([GATEWAY, SERVICE_DB]) + + assert e.value.problems == ["the service-db env 'default-db' isn't in the store, and agent-env builds infra envs " + "only into local stores; put it with `agent-env env service-db put --id default-db`"] + assert ensure_default_envs([GATEWAY]) == [] # built from other inputs, in a store it doesn't build into + assert len(puts) == 1 + + +def test_the_default_stores_are_local(local_stores): + assert bootstrap.stores_are_local() + + +def test_up_builds_whats_missing_and_says_what_was_already_there(puts, capsys): + ensure_default_envs([GATEWAY]) + + _bootstrap_envs() + + out = capsys.readouterr().out + assert " service-db env 'default-db' (missing): building, which can take minutes" in out + assert " gateway already registered (default)" in out + assert [kind for kind, *_ in puts] == [GATEWAY, SERVICE_DB] + + +def test_up_reports_infra_it_cant_build_as_one_line(puts, monkeypatch): + monkeypatch.setattr(bootstrap, "docker_unreachable", lambda: "docker isn't on PATH") + + monkeypatch.setattr(importlib.import_module("agent_env.cli.up"), "_require_explorer_deps", lambda: None) + monkeypatch.setattr("agent_env.config.runtime.Config.config_path", lambda self: "config.toml") + monkeypatch.setattr("agent_env.config.configure", lambda *a, **k: None) + result = CliRunner().invoke(up, []) + + assert result.exit_code == 1 + assert result.output.rstrip().endswith( + "Error: building the service-db, gateway env needs docker, and docker isn't on PATH; or run " + "`agent-env up --no-bootstrap`") + + +def test_the_website_browser_runs_playwrights_chromium_which_is_built_for_amd64_and_arm64(): + """Chrome has no Linux arm64 build, so a browser built for an Apple Silicon host couldn't install it.""" + assert "npx playwright install chromium" in bootstrap.WEBSITE_BROWSER_DOCKERFILE.read_text() + assert "--browser chromium" in (bootstrap.WEBSITE_BROWSER_CONTEXT / "entrypoint.sh").read_text() + + +def test_a_put_records_what_the_env_was_built_from_whatever_metadata_it_was_given(local_stores, monkeypatch): + monkeypatch.setattr(bootstrap, "build_image", lambda *args, **kwargs: None) + monkeypatch.setattr(bootstrap.DockerImageArtifact, "put", lambda id, **kwargs: get_artifact_store().put_document( + DockerImageArtifact(id=id, description="b", image_name=kwargs["image_name"], tar_gz_s3_url="file:///b.tar.gz"))) + + env = bootstrap.put_website_browser_env("website-browser", platform=None, + metadata={bootstrap.BUILD_INPUTS_KEY: "mine", "owner": "me"}) + + assert env.metadata[bootstrap.BUILD_INPUTS_KEY] == bootstrap.build_inputs_digest(WEBSITE_BROWSER) + assert env.metadata["owner"] == "me" + + +def test_the_build_inputs_are_the_shipped_files_and_build_args_less_python_caches(tmp_path, monkeypatch): + (tmp_path / "__pycache__").mkdir() + (tmp_path / "__pycache__/gateway.cpython-312.pyc").write_text("compiled") + (tmp_path / "gateway.py").write_text("served") + before = bootstrap.build_inputs_digest(WEBSITE_BROWSER) + monkeypatch.setitem(bootstrap._BUILD_ARGS, WEBSITE_BROWSER, {"PLAYWRIGHT_MCP_VERSION": "9.9.9"}) + + assert bootstrap._files_under(tmp_path) == [tmp_path / "gateway.py"] + assert bootstrap.build_inputs_digest(WEBSITE_BROWSER) != before diff --git a/tst/unit/utils/docker_build_test.py b/tst/unit/utils/docker_build_test.py index ad06d64d..68a10dd5 100644 --- a/tst/unit/utils/docker_build_test.py +++ b/tst/unit/utils/docker_build_test.py @@ -84,3 +84,23 @@ def test_importing_it_pulls_neither_cli_nor_providers(): ) proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) assert proc.returncode == 0, proc.stderr + + +@pytest.mark.parametrize("outcome, reason", [ + (SimpleNamespace(returncode=0, stderr=""), None), + (SimpleNamespace(returncode=1, stderr="\nCannot connect to the Docker daemon. Is the docker daemon running?\n"), + "the Docker daemon isn't reachable: Cannot connect to the Docker daemon. Is the docker daemon running?"), + (SimpleNamespace(returncode=1, stderr=""), "the Docker daemon isn't reachable"), + (FileNotFoundError("docker"), "docker isn't on PATH"), + (subprocess.TimeoutExpired(["docker", "info"], 10), "`docker info` didn't answer in 10s"), +], ids=["answers", "down", "down-silently", "not-installed", "hangs"]) +def test_docker_unreachable_says_why_docker_info_didnt_answer(monkeypatch, outcome, reason): + def run(argv, **kwargs): + assert argv == ["docker", "info"] and kwargs["timeout"] == docker_build.DOCKER_INFO_TIMEOUT_SECONDS + if isinstance(outcome, BaseException): + raise outcome + return outcome + + monkeypatch.setattr(docker_build.subprocess, "run", run) + + assert docker_build.docker_unreachable() == reason