diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index f427a8a3..e389cf90 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -352,7 +352,7 @@ def accept_toml(cls, data: dict[str, Any], ctx: AuthoringContext) -> dict[str, A kinds, described = cls.toml_metadata[name] if isinstance(value, bool) or not isinstance(value, kinds): values.append(f"metadata.{name} must be {described}, not {value!r}") - problems += [f"agent.toml: {problem}" for problem in values] + problems += [ctx.config_problem(problem) for problem in values] if problems: ctx.refuse(problems) return fields diff --git a/src/agent_env/bundle/authoring.py b/src/agent_env/bundle/authoring.py index 521953ee..d70a2efd 100644 --- a/src/agent_env/bundle/authoring.py +++ b/src/agent_env/bundle/authoring.py @@ -12,6 +12,11 @@ from agent_env.artifact.artifact import Artifact from agent_env.entity_refs import EntityKind, parse_toml_ref from agent_env.env.env import Env +from agent_env.env.envs._deployment import provider_refusal +from agent_env.providers.env_providers.constants import GATEWAY_SERVICE_NAMES +from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider +from agent_env.providers.env_providers.env_provider import _env_provider_class +from agent_env.utils.card_naming import card_names_in_files from ._fs import os_reason, relative, show, with_article from .parse import CONFIG_FILES, LEAVINGS, NAMED_BY, Bundle, BundleEntry, BundleError @@ -20,7 +25,13 @@ E = TypeVar("E", bound=Env) _BRING_IT_IN = "copy what it points to into the bundle, or put it in a store and refer to it by id" -_TOML_TYPES = {str: "a string", dict: "a table"} +_TOML_TYPES = {str: "a string", dict: "a table", list: "a list"} + + +def default_dockerfile(key: str) -> str: + """The Dockerfile an image key left out builds from the entry's folder: ``Dockerfile`` for ``image``, and + ``Dockerfile.`` for ``_image``, as a website's ``backend_image`` and ``frontend_image``.""" + return "Dockerfile" if key == "image" else f"Dockerfile.{key.removesuffix('_image')}" @dataclass(frozen=True) @@ -50,6 +61,19 @@ def files(self) -> dict[str, Path]: def file(self) -> tuple[str, Path]: return entry_file(self.bundle, self.entry) + def card_names(self, image_key: str) -> list[str] | None: + """The names ``@environment_card(name=...)`` gives in the source of the image ``image_key`` builds from + this folder: the ``.py`` files of its build context, those in its Dockerfile's folder first. None when the + key names an image rather than building one.""" + value = self.entry.config.get(image_key) if isinstance(self.entry.config, dict) else None + if value is None: + dockerfile = default_dockerfile(image_key) + elif isinstance(value, dict) and value.keys() == {"dockerfile"} and isinstance(value["dockerfile"], str): + dockerfile = value["dockerfile"] + else: + return None + return card_names_in_files(build_context_files(self.bundle, self.entry), dockerfile) + def accept(self, data: dict, /, **takes: type) -> dict: """The keys of ``data`` a type takes, each checked against the type given; any other key but ``type`` and ``id`` is refused.""" @@ -61,20 +85,79 @@ def accept(self, data: dict, /, **takes: type) -> dict: def accepted(self, data: dict, /, **takes: type) -> tuple[dict, list[str]]: """``accept`` without refusing: the keys of ``data`` a type takes and of the type given, and the problems with the rest, for a type that checks more before it refuses.""" - config = CONFIG_FILES[self.entry.kind] problems = [] unknown = sorted(set(data) - {"type", "id", *takes}) if unknown: names = [*takes, "type", "id"] - problems.append(f"{config}: unknown key{'s' * (len(unknown) > 1)} {', '.join(map(repr, unknown))}; " - f"{with_article(f'{self.entry.type} {self.entry.kind.store}')} takes " - f"{', '.join(names[:-1])} and {names[-1]}") + problems.append(self.config_problem( + f"unknown key{'s' * (len(unknown) > 1)} {', '.join(map(repr, unknown))}; " + f"{with_article(f'{self.entry.type} {self.entry.kind.store}')} takes " + f"{', '.join(names[:-1])} and {names[-1]}")) for key, kind in takes.items(): if key in data and not isinstance(data[key], kind): - problems.append(f"{config}: {key} must be {_TOML_TYPES.get(kind, kind.__name__)}, not {data[key]!r}") + problems.append(self.config_problem( + f"{key} must be {_TOML_TYPES.get(kind, kind.__name__)}, not {data[key]!r}")) fields = {key: data[key] for key, kind in takes.items() if key in data and isinstance(data[key], kind)} return fields, problems + def accept_env(self, data: dict, env_class: type[Env], *, named_by: str | None = None) -> dict: + """The keys of ``data``, an env.toml, ``env_class`` takes, checked the way ``accepted_env`` checks + them; every problem is refused.""" + fields, problems = self.accepted_env(data, env_class, named_by=named_by) + if problems: + self.refuse(problems) + return fields + + def accepted_env(self, data: dict, env_class: type[Env], *, + named_by: str | None = None) -> tuple[dict, list[str]]: + """``accepted`` for an env.toml: the keys ``env_class`` takes, and the problems with them. The fields + carry its env_provider_type, the gateway's when left out, which must name an installed provider that + deploys an env of ``env_class``. With ``named_by``, they carry its environment_name too: the one set, + else the one the ``@environment_card`` in the source of the image that key builds gives, which a + gateway deploy can't take from one of its own containers.""" + fields, problems = self.accepted(data, **env_class.toml_keys) + provider_type = fields.setdefault("env_provider_type", EnvironmentGatewayProvider.type) + try: + provider = _env_provider_class(provider_type) + except ValueError as e: + problems.append(self.config_problem(str(e))) + provider = None + if provider is not None and (reason := provider_refusal(provider, env_class)) is not None: + problems.append(self.config_problem(f"env_provider_type {provider_type!r} {reason}")) + if named_by is not None: + name, problem = self._environment_name(data, fields, named_by) + if problem is not None: + problems.append(self.config_problem(problem)) + elif name is not None: + fields["environment_name"] = name + if (name in GATEWAY_SERVICE_NAMES and provider is not None + and issubclass(provider, EnvironmentGatewayProvider)): + problems.append(self.config_problem( + f"environment_name {name!r} is one a gateway deploy names its own containers " + f"({', '.join(sorted(GATEWAY_SERVICE_NAMES))}); choose another")) + return fields, problems + + def _environment_name(self, data: dict, fields: dict, named_by: str) -> tuple[str | None, str | None]: + """The env's environment_name, and the problem when there's none: the one set, else the one the source + of the image ``named_by`` builds from this folder declares.""" + if "environment_name" in data: # one of the wrong type is a problem accepted() reported + name = fields.get("environment_name") + return name, "environment_name can't be empty" if name == "" else None + names = self.card_names(named_by) + what = "image" if named_by == "image" else named_by.replace("_", " ") + if names is None: + return None, (f"environment_name isn't set, and its {what} isn't built from this folder, so there's no " + "source to read it from; set it") + if len(names) != 1: + found = (f"several environment cards ({', '.join(map(repr, names))})" if names + else "no @environment_card(name=...)") + return None, f"environment_name isn't set, and the source its {what} is built from declares {found}; set it" + return names[0], None + + def config_problem(self, message: str) -> str: + """``message`` as a problem with the entry's toml, ready for ``refuse``.""" + return f"{CONFIG_FILES[self.entry.kind]}: {message}" + def refuse(self, problems: list[str]) -> NoReturn: """Raise BundleError with ``problems``, each named by the entry's folder.""" where = relative(self.bundle.root, self.entry.path) diff --git a/src/agent_env/bundle/ledger.py b/src/agent_env/bundle/ledger.py index 49a05400..ccf3bc09 100644 --- a/src/agent_env/bundle/ledger.py +++ b/src/agent_env/bundle/ledger.py @@ -22,7 +22,6 @@ 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.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 @@ -33,7 +32,7 @@ from .authoring import build_context_files, entry_files from .parse import Bundle, BundleKind -from .plan import Plan, Write, folder_walk, keeps_base_from_toml, unpinned_store_refs +from .plan import Plan, Write, env_writer, folder_walk, unpinned_store_refs from .resolve import BuiltImage LEDGER_COLLECTION = "bundle_ledger" @@ -200,8 +199,8 @@ def materializing(bundle: Bundle, on_wait: Callable[[], None] | None = None) -> def _tracked(write: Write) -> bool: """Whether the ledger can list everything ``write`` is made from. Not yet for a skill, nor for a type with - a ``from_toml`` of its own, which may read its folder in ways it can't see. An agent is made from its - agent.toml alone: its image is a reference. A built image is made from its build context, + a ``from_toml`` of its own, which may read its folder in ways it can't see. An agent or env is made from its + toml alone: its images and envs are references. A built image is made from its build context, ``build_context_files``; what the build fetches (its base image, packages) isn't an input.""" if isinstance(write.source, BuiltImage): return True @@ -210,8 +209,7 @@ def _tracked(write: Write) -> bool: if write.kind is BundleKind.ARTIFACT: return folder_walk(get_artifact_registry().get(_type(write))) is not None if write.kind is BundleKind.ENV: - cls = get_env_registry().get(_type(write)) - return cls is not None and keeps_base_from_toml(cls, Env) + return env_writer(get_env_registry().get(_type(write))) return True diff --git a/src/agent_env/bundle/materialize.py b/src/agent_env/bundle/materialize.py index 84bc69c3..d49d3938 100644 --- a/src/agent_env/bundle/materialize.py +++ b/src/agent_env/bundle/materialize.py @@ -26,6 +26,7 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.registry import canonical_type, get_artifact_registry from agent_env.entity_refs import EntityRef, RefRole, ref_sites +from agent_env.env.registry import get_env_registry from agent_env.eval import Eval, EvalTask from agent_env.store.ids import image_repository from agent_env.store.routing import namespace_routing_enabled @@ -38,7 +39,7 @@ from .authoring import AuthoringContext, build_context_files from .ledger import Ledger, materializing from .parse import BundleError, BundleKind -from .plan import Plan, Write, folder_walk, unpinned_store_refs +from .plan import Plan, Write, env_writer, folder_walk, unpinned_store_refs from .resolve import BuiltImage, build_step @dataclass(frozen=True) @@ -184,6 +185,12 @@ def _write_agent(plan: Plan, write: Write) -> int: AuthoringContext(plan.bundle.bundle, entry)).version +def _write_env(plan: Plan, write: Write) -> int: + entry = write.source.entry + cls = get_env_registry()[entry.type] + return cls.from_toml(_pinned(plan, write, cls.toml_refs), AuthoringContext(plan.bundle.bundle, entry)).version + + def _pinned(plan: Plan, write: Write, refs: tuple[EntityRef, ...]) -> Any: """A copy of ``write``'s resolved toml with each store ref that names no version pinned to the version the plan read, which the ledger hashed (``unpinned_store_refs``).""" @@ -210,7 +217,8 @@ def _write_eval(plan: Plan, write: Write) -> int: # The writers this release has, by kind; any other kind is refused before anything is written. Tasks are # written separately, once every one of them is preflighted, and evals after them, since they name the tasks. _WRITERS: dict[BundleKind, Callable[[Plan, Write], int]] = { - BundleKind.ARTIFACT: _write_artifact, BundleKind.AGENT: _write_agent, BundleKind.EVAL: _write_eval, + BundleKind.ARTIFACT: _write_artifact, BundleKind.AGENT: _write_agent, BundleKind.ENV: _write_env, + BundleKind.EVAL: _write_eval, } @@ -238,10 +246,11 @@ def _unwritable(write: Write) -> str | None: return None if write.kind not in _WRITERS: return with_article(write.kind.value.removesuffix("s")) - if write.kind is BundleKind.ARTIFACT: - type_ = write.source.entry.type - if folder_walk(get_artifact_registry().get(canonical_type(type_))) is None: - return with_article(f"{type_} artifact") + type_ = write.source.entry.type + if write.kind is BundleKind.ARTIFACT and folder_walk(get_artifact_registry().get(canonical_type(type_))) is None: + return with_article(f"{type_} artifact") + if write.kind is BundleKind.ENV and not env_writer(get_env_registry().get(type_)): + return with_article(f"{type_} env") return None diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 4d3e48fb..ae338704 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -18,8 +18,22 @@ from agent_env.a2a_agent import A2AAgent from agent_env.artifact import Artifact, FileArtifact, FileArtifactUniverse from agent_env.artifact.registry import canonical_type, get_artifact_registry +from agent_env.config import get_config from agent_env.entity_refs import EntityKind from agent_env.env.env import Env +from agent_env.env.envs._deployment import provider_refusal +from agent_env.env.envs.mcp_server import MCPServerEnv +from agent_env.env.envs.multi_env import MultiEnv +from agent_env.env.envs.website import WebsiteEnv +from agent_env.env.registry import get_env_registry +from agent_env.env.store import ENVS_COLLECTION +from agent_env.providers.env_providers.constants import ( + AGENT_ENV_WEBSITE_BACKEND_SUFFIX, + AGENT_ENV_WEBSITE_FRONTEND_SUFFIX, +) +from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider +from agent_env.providers.env_providers.env_provider import _env_provider_class +from agent_env.store import Filter, Sort from agent_env.store.base import NotFoundError from agent_env.store.ids import LOCAL_PREFIX from agent_env.store.routing import namespace_routing_enabled @@ -38,6 +52,9 @@ # The artifact types written from their folder's files, and how each lists them. _FOLDER_WALKS = {FileArtifact: entry_file, FileArtifactUniverse: entry_files} +# The env types whose from_toml reads only the toml and what it names, so a type built by one of them is written from +# its env.toml and the ledger can list what it's made from. +_ENV_FROM_TOMLS = (Env, MCPServerEnv, WebsiteEnv, MultiEnv) _NOTHING_TO_RUN = "this bundle has no tasks or evals to run" @@ -94,6 +111,7 @@ def __init__(self, resolved: ResolvedBundle): self.identified = {entry.entry.id: entry.entry for entry in resolved.entries} self.outputs = {(output.kind.value, output.id): (entry.entry, output) for entry in resolved.entries for output in entry.outputs} + self.accepted: dict[_Key, dict] = {} # each agent's and env's toml, as its type accepted it def _add(self, key: _Key, source: ResolvedEntry | BuiltImage, needs: list[_Key]) -> None: """Record a write in bundle order, a built image just before the entry that builds it.""" @@ -107,6 +125,8 @@ def plan(self, tasks: Sequence[str], evals: Sequence[str]) -> Plan: store_refs, store_latest = self._check_store_refs(closure) self._build_tasks(closure) self._check_files(closure) + self._check_env_types(closure) + self._check_multi_names(closure, store_latest) if self.problems: raise BundleError(self.problems) return Plan(self.resolved, self._ordered(closure), tuple(selected_tasks), tuple(selected_evals), store_refs, @@ -265,17 +285,20 @@ def _store_ref_problem(self, ref: Reference, read: dict) -> str | None: return None def _check_files(self, closure: set[_Key]) -> None: - """List the folder of each file artifact, and read its toml and each agent's the way their writes - will, so a link that leaves the bundle or a key the type doesn't take fails before anything is + """List the folder of each file artifact, and read its toml and each agent's and env's the way their + writes will, so a link that leaves the bundle or a key the type doesn't take fails before anything is written.""" registry = get_artifact_registry() for key in sorted(closure, key=self.rank.__getitem__): source = self.nodes[key] if isinstance(source, BuiltImage): continue - if source.entry.kind is BundleKind.AGENT: + if source.entry.kind in (BundleKind.AGENT, BundleKind.ENV): + agent = source.entry.kind is BundleKind.AGENT + accept = A2AAgent.accept_toml if agent else self._env_accept(source.entry) try: - A2AAgent.accept_toml(source.config, AuthoringContext(self.resolved.bundle, source.entry)) + if accept is not None: + self.accepted[key] = accept(source.config, AuthoringContext(self.resolved.bundle, source.entry)) except BundleError as e: self.problems.extend(e.problems) continue @@ -294,6 +317,75 @@ def _check_files(self, closure: set[_Key]) -> None: except BundleError as e: self.problems.extend(e.problems) + def _env_accept(self, entry: BundleEntry) -> Any: + """How ``entry``'s env type checks its env.toml, when it's an env written from one and checks it at all.""" + cls = get_env_registry().get(entry.type) if entry.kind is BundleKind.ENV else None + return getattr(cls, "accept_toml", None) if env_writer(cls) else None + + def _check_env_types(self, closure: set[_Key]) -> None: + """An env keeps its type across versions, so an env folder whose id is another type of env in the store + is refused here, not by the store once earlier entities are written.""" + store = get_config().get_document_store() + for key in sorted(closure, key=self.rank.__getitem__): + source = self.nodes[key] + if isinstance(source, BuiltImage) or source.entry.kind is not BundleKind.ENV: + continue + latest = Sort.by("version", descending=True) + stored = store.find_one(ENVS_COLLECTION, Filter.of(id=source.entry.id), sort=latest) + if stored is not None and stored.get("type") not in (None, source.entry.type): + self.problems.append(f"{self._path(source.entry)}: {source.entry.id!r} is " + f"{with_article(stored.get('type'))} env in the store, and an env keeps its type " + "across versions; rename the folder") + + def _check_multi_names(self, closure: set[_Key], store_latest: dict[tuple[EntityKind, str], int]) -> None: + """Each env of a multi runs as containers its environment_name names: an MCP server as one by that name, a + website as its backend's and frontend's. No two of a multi's envs may name one container, and a provider + other than the core's, which gives a multi one env card, can't tell an MCP server and a website of one name + apart.""" + for key in sorted(closure, key=self.rank.__getitem__): + source = self.nodes[key] + if isinstance(source, BuiltImage) or source.entry.kind is not BundleKind.ENV: + continue + if not issubclass(get_env_registry().get(source.entry.type, Env), MultiEnv): + continue + where = self._path(source.entry) + taken: dict[str, str] = {} # each container a child runs as, by the field naming the child + named: dict[str, set[str]] = {} # the environment_names of each list's envs + for ref in source.references: + name = self._environment_name(ref, store_latest) + if name is None: + continue + group = ref.where.partition("[")[0] + named.setdefault(group, set()).add(name) + containers = [name] if group == "mcp_server_envs" else [ + f"{name}-{AGENT_ENV_WEBSITE_BACKEND_SUFFIX}", f"{name}-{AGENT_ENV_WEBSITE_FRONTEND_SUFFIX}"] + clash = next((container for container in containers if container in taken), None) + if clash is not None: + self.problems.append(f"{where}: {ref.where}: environment_name {name!r} names the container " + f"{clash!r}, as {taken[clash]}'s does; each env of a multi needs its own") + else: + taken.update(dict.fromkeys(containers, ref.where)) + provider_type = source.config.get("env_provider_type", EnvironmentGatewayProvider.type) + try: + provider = _env_provider_class(provider_type) + except ValueError: + continue # the env's toml check refuses a type no installed provider has + if provider_refusal(provider, MultiEnv) is not None: + continue # and one that deploys no multi at all + reason = provider_refusal(provider, MultiEnv, mcp_names=named.get("mcp_server_envs", ()), + website_names=named.get("website_envs", ())) + if reason is not None: + self.problems.append(f"{where}: env_provider_type {provider_type!r} {reason}; rename one") + + def _environment_name(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int]) -> str | None: + if ref.local is not None: + return self.accepted.get(_key(ref.local), {}).get("environment_name") + try: + env = Env.get(ref.id, ref.version if ref.version is not None else store_latest.get((ref.kind, ref.id))) + except (NotFoundError, ValueError, KeyError, TypeError): + return None # reported by the store ref check + return getattr(env, "environment_name", None) + def _build_tasks(self, closure: set[_Key]) -> None: """Build each task's steps the way a write will, so a bad step fails before anything is written.""" registry = get_task_step_registry() @@ -351,6 +443,12 @@ def unpinned_store_refs(plan: Plan, write: Write) -> dict[tuple[EntityKind, str] for ref in write.source.references if ref.local is None and ref.version is None} +def env_writer(cls: type | None) -> bool: + """Whether an env of type ``cls`` is written from its env.toml, by a ``from_toml`` that reads only the toml and + what it names: its own, or one it inherits from a core env type.""" + return cls is not None and next(c for c in cls.__mro__ if "from_toml" in vars(c)) in _ENV_FROM_TOMLS + + def folder_walk(cls: type | None) -> Any: """The listing a type's write runs: a subclass keeping its base's ``from_toml`` lists the same way, one that overrides it may read its folder differently, so it gets none.""" diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index f3ed87f8..03d5ce3f 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -6,7 +6,8 @@ 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. +that names none, deploy the configured default agent, which must be in the store. An env the bundle writes is +checked from its planned env.toml: an image the bundle builds is one only this machine has. 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 @@ -39,7 +40,9 @@ 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.env.registry import get_env_registry from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider, _gateway_topology +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.providers.env_state.env_state_provider import LOCAL_POSTGRES_STATE_TYPE from agent_env.providers.env_state.store import get_env_state_instance_store @@ -64,7 +67,7 @@ from ._fs import relative from .parse import BundleError, BundleKind from .plan import Plan -from .resolve import BuiltImage, ResolvedEntry, build_step +from .resolve import BuiltImage, Reference, ResolvedEntry, build_step _SHOWN_DOCKER_USERS = 3 @@ -94,6 +97,16 @@ class _Image: local_only: str | None # why only this machine has it, or None +@dataclass(frozen=True) +class _Deployment: + """How a deploy_env step's env deploys, as far as the walk checks it.""" + + provider: type # the provider class its env_provider_type names + images: list[_Image] # what it runs, its children's included + websites: bool + one_server: bool # a single MCP server, which a provider that deploys one server can take + + class _Walk: def __init__(self, plan: Plan, sandbox: str | None): self.plan, self.sandbox = plan, sandbox @@ -105,6 +118,7 @@ def __init__(self, plan: Plan, sandbox: str | None): 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.envs = {write.id: write.source for write in plan.writes if write.kind is BundleKind.ENV} self.built = {write.id: write.source for write in plan.writes if isinstance(write.source, BuiltImage)} def task(self, task: ResolvedEntry) -> None: @@ -151,42 +165,30 @@ def finish(self) -> Preflight: 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 [])] + deployment = self._planned(step.env_id) if step.env_id in self.envs else self._stored(step) + if deployment is None: + return + if issubclass(deployment.provider, EnvironmentGatewayProvider): kinds = {GATEWAY} if _state_type(step) == LOCAL_POSTGRES_STATE_TYPE: kinds.add(SERVICE_DB) - if topology.website_configs: + if deployment.websites: kinds.add(WEBSITE_BROWSER) - elif issubclass(provider_class, EnvironmentServerProvider) and isinstance(env, MCPServerEnv): - images, kinds = [env.docker_image_artifact], set() + elif issubclass(deployment.provider, EnvironmentServerProvider) and deployment.one_server: + kinds = set() else: # a plugin's provider, or one deploy_env's own preflight refuses for this env return + env_id, images = step.env_id, deployment.images 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]) + self._reachable(where, remote, 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])} " + 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: @@ -194,6 +196,75 @@ def _env(self, where: str, step: DeployEnvTaskStep) -> None: if vms: self.remote_infra.append((where, vms[0], kinds)) + def _stored(self, step: DeployEnvTaskStep) -> _Deployment | None: + """How a store env deploys, or None when the walk leaves it to deploy_env's own preflight.""" + try: + env = Env.get(step.env_id, step.env_version) + except NotFoundError: + return None # the plan reports a store env that isn't there + if not isinstance(env, (MCPServerEnv, WebsiteEnv, MultiEnv)): + return None # 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 None # 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) + images, websites = _stored_images(env) + return _Deployment(provider_class, images, websites, isinstance(env, MCPServerEnv)) + + def _planned(self, env_id: str) -> _Deployment | None: + """How an env the bundle writes deploys, from its planned config: what deploys it, and its images and its + children's. None for a type the walk doesn't model, or a provider type the plan has refused.""" + cls = get_env_registry().get(self.envs[env_id].entry.type) + if cls is None or not issubclass(cls, (MCPServerEnv, WebsiteEnv, MultiEnv)): + return None + provider_type = self.envs[env_id].config.get("env_provider_type", EnvironmentGatewayProvider.type) + try: + provider_class = _env_provider_class(provider_type) + except ValueError: + return None + images, websites = self._planned_images(env_id) + return _Deployment(provider_class, images, websites, issubclass(cls, MCPServerEnv)) + + def _planned_images(self, env_id: str) -> tuple[list[_Image], bool]: + """The images an env the bundle writes runs, its children's included, and whether it has websites.""" + source = self.envs[env_id] + cls = get_env_registry().get(source.entry.type) + images, websites = [], cls is not None and issubclass(cls, WebsiteEnv) + for ref in source.references: + if ref.kind is EntityKind.ARTIFACT and (image := self._planned_image(env_id, ref)) is not None: + images.append(image) + elif ref.kind is EntityKind.ENV: + if ref.id in self.envs: + child_images, child_websites = self._planned_images(ref.id) + else: + try: + child = Env.get(ref.id, self._planned_version(ref.kind, ref.id, ref.version)) + child_images, child_websites = _stored_images(child) + except NotFoundError: + continue # the plan reports a store env that isn't there + images += child_images + websites |= child_websites + return images, websites + + def _planned_image(self, env_id: str, ref: Reference) -> _Image | None: + what = f"env {env_id!r}'s image {ref.id!r}" + if (built := self.built.get(ref.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 ref.id in self.written: + return None # another of the bundle's writes, which materialize refuses or writes first + try: + return _Image(what, _local_only(DockerImageArtifact.get(ref.id, self._planned_version(ref.kind, ref.id, + ref.version)))) + except NotFoundError: + return None # the plan reports a store image that isn't there + + def _planned_version(self, kind: EntityKind, entity_id: str, version: int | None) -> int | None: + """The version a write names a store entity at: its pin, else the one the plan read, which the writer pins.""" + return version if version is not None else self.plan.store_latest.get((kind, entity_id)) + def _agent(self, where: str, step: DeployAgentTaskStep, sandboxes: dict[str, DeploySandboxTaskStep]) -> None: if step.sandbox_name: linked = sandboxes.get(step.sandbox_name) @@ -247,7 +318,8 @@ def _agent_image(self, agent_id: str, version: int | None) -> _Image | None: 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))) + planned = self._planned_version(EntityKind.ARTIFACT, image_id, image_version) + return _Image(what, _local_only(DockerImageArtifact.get(image_id, planned))) try: return _Image(what, _local_only(A2AAgent.get(agent_id, version).docker_image_artifact)) except NotFoundError: @@ -297,6 +369,14 @@ def _problem(self, where: str, problem: str) -> None: self.problems.setdefault(problem, {})[where] = None +def _stored_images(env: Env) -> tuple[list[_Image], bool]: + """The images a store env runs through a gateway, and whether it has websites.""" + topology = _gateway_topology(env) + images = [*topology.mcp_server_images, *(topology.website_images or [])] + return [_Image(f"env {env.id!r}'s image {image.id!r}", _local_only(image)) for image in images], bool( + topology.website_configs) + + 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: diff --git a/src/agent_env/bundle/resolve.py b/src/agent_env/bundle/resolve.py index 21f96bac..887c9d2c 100644 --- a/src/agent_env/bundle/resolve.py +++ b/src/agent_env/bundle/resolve.py @@ -18,6 +18,8 @@ from agent_env.a2a_agent import A2AAgent from agent_env.artifact.registry import canonical_type, get_artifact_registry from agent_env.entity_refs import EntityKind, EntityRef, RefRole, RefSite, parse_toml_ref, ref_sites +from agent_env.env.envs.gateway_server import GatewayEnv +from agent_env.env.envs.service_db import ServiceDBEnv from agent_env.env.registry import get_env_registry from agent_env.eval.eval import Eval from agent_env.plugins import _registration @@ -26,10 +28,13 @@ from agent_env.task_step.task_step import TaskStep, attach_retry_config, dependencies from ._fs import fold, relative, show +from .authoring import default_dockerfile from .parse import NAMED_BY, Bundle, BundleEntry, BundleError, BundleKind # Step keys that hold step ids, never entity ids. _STEP_ID_KEYS = ("id", "type", "depends_on") +# The env types config names one of, for every deploy that needs one, by the setting that names it. +_INFRA_ENVS = {GatewayEnv.type: "default_gateway_env_id", ServiceDBEnv.type: "default_service_db_env_id"} @dataclass(frozen=True) @@ -51,7 +56,7 @@ class Reference: version: int | None local: BundleEntry | BuiltImage | None where: str # the referencing field, as problems name it - artifact_type: str | None # the type the field takes, when it names one + artifact_type: str | None # the type the field takes, when it names one: an artifact's, or an env's @dataclass(frozen=True) @@ -195,6 +200,10 @@ def _task(self, entry: BundleEntry) -> ResolvedEntry: def _toml_class(self, entry: BundleEntry) -> type: group = None if entry.kind is BundleKind.ENV: + if entry.type in _INFRA_ENVS: + self._problem(entry, f"a {entry.type} env isn't written from a bundle: config names the one every " + f"deploy uses ({_INFRA_ENVS[entry.type]}), and a run builds it when it's missing") + raise _Skip cls, group = get_env_registry().get(entry.type), _registration.ENVS elif entry.kind is BundleKind.AGENT: cls = A2AAgent if entry.type == A2AAgent.type else None @@ -218,10 +227,14 @@ def _image(self, entry: BundleEntry, config: dict, ref: EntityRef, references: l if isinstance(value, dict) and "ref" in value: self._problem(entry, f"{ref.path}: an external image ({{ ref = ... }}) isn't supported yet") return True - if value is None and ref.path == "image": - dockerfile = "Dockerfile" + if value is None: + dockerfile = default_dockerfile(ref.path) elif isinstance(value, dict) and value.keys() == {"dockerfile"}: dockerfile = value["dockerfile"] + elif isinstance(value, dict) and "dockerfile" in value: + extra = ", ".join(sorted(value.keys() - {"dockerfile"})) + self._problem(entry, f"{ref.path}: an image built from this folder takes only dockerfile, not {extra}") + return True else: return False path = entry.path / dockerfile if isinstance(dockerfile, str) and dockerfile else None @@ -309,7 +322,7 @@ def _depends_on(self, entry: BundleEntry, steps: list[dict]) -> list[list[str] | def _resolve(self, entry: BundleEntry, site: RefSite, where: str, name: str, version: int | None, outputs: dict[tuple[EntityKind, str], Output], upstream: set[int], references: list[Reference]) -> None: - kind, expected, key = site.ref.kind, site.ref.artifact_type, _nfc(name) + kind, expected, key = site.ref.kind, site.ref.artifact_type or site.ref.env_type, _nfc(name) output = outputs.get((kind, key)) if output is not None: if output.step not in upstream: diff --git a/src/agent_env/entity_refs.py b/src/agent_env/entity_refs.py index dd35d04b..c1363dfb 100644 --- a/src/agent_env/entity_refs.py +++ b/src/agent_env/entity_refs.py @@ -62,6 +62,7 @@ class EntityRef: version_field: str | None = None role: RefRole = RefRole.INPUT artifact_type: str | None = None + env_type: str | None = None walk: Walker | None = None @classmethod @@ -92,6 +93,8 @@ def __post_init__(self) -> None: raise ValueError(f"entity ref {self.path!r}: a list element has no sibling key to hold a version") if self.artifact_type is not None and self.kind is not EntityKind.ARTIFACT: raise ValueError(f"entity ref {self.path!r}: artifact_type is only for EntityKind.ARTIFACT") + if self.env_type is not None and self.kind is not EntityKind.ENV: + raise ValueError(f"entity ref {self.path!r}: env_type is only for EntityKind.ENV") @property def field(self) -> str: diff --git a/src/agent_env/env/envs/_deployment.py b/src/agent_env/env/envs/_deployment.py index e86917c4..47ffab57 100644 --- a/src/agent_env/env/envs/_deployment.py +++ b/src/agent_env/env/envs/_deployment.py @@ -15,6 +15,8 @@ from agent_env.store.base import ObjectNotFoundError if TYPE_CHECKING: + from collections.abc import Iterable + from agent_env.artifact import FileArtifact from agent_env.env.env import DeployedEnv, Env from agent_env.env.envs.mcp_server import MCPServerEnv @@ -247,23 +249,34 @@ def provider_or_class(env: Env) -> EnvironmentProvider | type[EnvironmentProvide def deploy_refusal(env: Env, provider: EnvironmentProvider | type[EnvironmentProvider], options: dict) -> str | None: - """Why deploying env through provider, or a provider of that class, is refused before anything is built, or None: the server provider - for an env other than one MCP server, a plugin's MultiEnv with an MCP server and a website of one name, or an option it can't take.""" + """Why deploying env through provider, or a provider of that class, is refused before anything is built, or None: the provider + can't deploy an env like it (see ``provider_refusal``), or an option it can't take.""" + provider_class = provider if isinstance(provider, type) else type(provider) + reason = provider_refusal(provider_class, type(env), + mcp_names=[child.environment_name for child in getattr(env, "mcp_server_envs", ())], + website_names=[child.environment_name for child in getattr(env, "website_envs", ())]) + if reason is not None: + return f"env '{env.id}' has env_provider_type '{env.env_provider_type}', which {reason}" + return option_refusal(env, provider, options) + + +def provider_refusal(provider_class: type[EnvironmentProvider], env_class: type[Env], *, mcp_names: Iterable[str] = (), + website_names: Iterable[str] = ()) -> str | None: + """Why a provider of provider_class can't deploy an env of env_class, a multi's with children of these environment_names, worded + to follow the provider's type, or None: the server provider deploys one MCP server, and a plugin's provider gives a multi one + env card, which can't tell an MCP server and a website of one name apart. It needs no env, so a bundle checks it before writing one.""" from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv from agent_env.providers.env_providers.env_provider import _SandboxEnvironmentProvider from agent_env.providers.env_providers.env_server_provider import EnvironmentServerProvider - provider_class = provider if isinstance(provider, type) else type(provider) - refused = f"env '{env.id}' has env_provider_type '{env.env_provider_type}'" - if issubclass(provider_class, EnvironmentServerProvider) and not isinstance(env, MCPServerEnv): - return f"{refused}, which deploys one MCP server, not a {env.type} env" - if isinstance(env, MultiEnv) and not issubclass(provider_class, _SandboxEnvironmentProvider): - shared = {c.environment_name for c in env.mcp_server_envs} & {c.environment_name for c in env.website_envs} - if shared: - return (f"{refused}, whose one env card can't tell an MCP server and a website apart by name, and both are named " + if issubclass(provider_class, EnvironmentServerProvider) and not issubclass(env_class, MCPServerEnv): + return f"deploys one MCP server, not a {env_class.type} env" + if issubclass(env_class, MultiEnv) and not issubclass(provider_class, _SandboxEnvironmentProvider): + if shared := set(mcp_names) & set(website_names): + return (f"gives a multi one env card, so it can't tell an MCP server and a website apart by name, and both are named " f"{', '.join(map(repr, sorted(shared)))}") - return option_refusal(env, provider, options) + return None def option_refusal(env: Env, provider: EnvironmentProvider | type[EnvironmentProvider], options: dict) -> str | None: diff --git a/src/agent_env/env/envs/mcp_server.py b/src/agent_env/env/envs/mcp_server.py index ba64bfc8..0c8e2a47 100644 --- a/src/agent_env/env/envs/mcp_server.py +++ b/src/agent_env/env/envs/mcp_server.py @@ -12,6 +12,7 @@ from agentenv_protocol import FilePart, client as protocol_v1 from agent_env.artifact import Artifact, DockerImageArtifact from agent_env.artifact.artifacts.docker_image import GitHubBuildResult, ProgressCallback, refuse_local_github_build +from agent_env.entity_refs import EntityRef from agent_env.env.env import Env, gateway_url_of from agent_env.store.ids import derive_id from agent_env.env import legacy_protocol @@ -25,6 +26,7 @@ if TYPE_CHECKING: from agent_env.artifact import CliArtifact, EnvironmentArtifact, EnvironmentUniverseArtifact + from agent_env.bundle.authoring import AuthoringContext from agent_env.env.env import DeployedEnv from agent_env.providers.env_providers.env_provider import EnvironmentProvider @@ -34,6 +36,8 @@ class MCPServerEnv(Env): type: ClassVar[str] = "mcp_server" description = "An MCP server, deployed through the environment provider its env_provider_type names" + toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.artifact("image", artifact_type="docker_image"),) + toml_keys: ClassVar[dict[str, type]] = {"image": object, "environment_name": str, "env_provider_type": str} _MCP_MAX_RETRIES: ClassVar[int] = 5 def __init__(self, id: str, version: Optional[int], docker_image_artifact: DockerImageArtifact, environment_name: Optional[str] = None, *, metadata: Optional[dict[str, str]] = None, env_provider_type: str = "gateway"): @@ -78,6 +82,20 @@ def from_dict(cls, data: dict) -> MCPServerEnv: env_provider_type=data.get("env_provider_type", "gateway"), ) + @classmethod + def from_toml(cls, data: dict, ctx: AuthoringContext) -> MCPServerEnv: + """Write the env authored as ``data`` (its env.toml, with ``image`` resolved to an image artifact's id) + under ``ctx.id`` and return it.""" + fields = cls.accept_toml(data, ctx) + return cls.put(id=ctx.id, docker_image_artifact=ctx.artifact(fields["image"], DockerImageArtifact), + environment_name=fields["environment_name"], env_provider_type=fields["env_provider_type"]) + + @classmethod + def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: + """The keys of ``data``, an env.toml, an MCP server env takes, its environment_name read from the image's + @environment_card when it isn't set. Raises BundleError listing every problem.""" + return ctx.accept_env(data, cls, named_by="image") + async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: # In container mode the server runs in its own container, so loads stage there, as for a MultiEnv child. return await deploy_through_provider( diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index b927798d..8c4cf81b 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -22,8 +22,10 @@ def _snapshot_after_load_default() -> bool: if TYPE_CHECKING: from agent_env.artifact import EnvironmentArtifact, EnvironmentUniverseArtifact + from agent_env.bundle.authoring import AuthoringContext from agent_env.providers.env_state import DatabaseStateProvider +from agent_env.entity_refs import EntityRef from agent_env.env.env import Env, gateway_url_of from agent_env.env.gateway import GatewayMode from agent_env.env.envs._deployment import ( @@ -40,6 +42,10 @@ def _snapshot_after_load_default() -> bool: class MultiEnv(Env): type: ClassVar[str] = "multi" description = "Multi environment combining multiple MCP servers and websites, deployed through the environment provider its env_provider_type names" + toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.env("mcp_server_envs[]", env_type=MCPServerEnv.type), + EntityRef.env("website_envs[]", env_type=WebsiteEnv.type)) + toml_keys: ClassVar[dict[str, type]] = {"mcp_server_envs": list, "website_envs": list, "name": str, + "env_provider_type": str} def __init__(self, id: str, version: Optional[int], mcp_server_envs: list[MCPServerEnv], website_envs: list[WebsiteEnv] | None = None, metadata: Optional[dict[str, str]] = None, name: Optional[str] = None, env_provider_type: str = "gateway"): super().__init__(id, version, metadata=metadata) @@ -87,6 +93,31 @@ def from_dict(cls, data: dict) -> "MultiEnv": return cls(id=data["id"], version=data.get("version"), mcp_server_envs=mcp_server_envs, website_envs=website_envs, metadata=data.get("metadata", {}), name=data.get("name"), env_provider_type=data.get("env_provider_type", "gateway")) + @classmethod + def from_toml(cls, data: dict, ctx: AuthoringContext) -> MultiEnv: + """Write the env authored as ``data`` (its env.toml, with ``mcp_server_envs`` and ``website_envs`` resolved to + env ids) under ``ctx.id`` and return it, unless deploying it would be refused.""" + fields = cls.accept_toml(data, ctx) + env = dict(mcp_server_envs=[ctx.env(ref, MCPServerEnv) for ref in fields.get("mcp_server_envs", [])], + website_envs=[ctx.env(ref, WebsiteEnv) for ref in fields.get("website_envs", [])], + name=fields.get("name"), env_provider_type=fields["env_provider_type"]) + if refusal := cls(id=ctx.id, version=None, **env).deploy_refusal(): + ctx.refuse([refusal]) + return cls.put(id=ctx.id, **env) + + @classmethod + def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: + """The keys of ``data``, an env.toml, a multi env takes. Raises BundleError listing every problem.""" + fields, problems = ctx.accepted_env(data, cls) + if not (fields.get("mcp_server_envs") or fields.get("website_envs")): + problems.append(ctx.config_problem("a multi env needs at least one env in mcp_server_envs or website_envs")) + name = fields.get("name") + if name is not None and (not name or any(ch.isspace() for ch in name)): + problems.append(ctx.config_problem(f"name must be non-empty with no whitespace, not {name!r}")) + if problems: + ctx.refuse(problems) + return fields + async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: deployed_env = await deploy_through_provider( self, environment_name=None, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, diff --git a/src/agent_env/env/envs/website.py b/src/agent_env/env/envs/website.py index 6cb80820..7f316f04 100644 --- a/src/agent_env/env/envs/website.py +++ b/src/agent_env/env/envs/website.py @@ -12,6 +12,7 @@ from agentenv_protocol import FilePart, client as protocol_v1 from agent_env.artifact import Artifact, DockerImageArtifact, EnvironmentArtifact from agent_env.artifact.artifacts.docker_image import GitHubBuildResult, ProgressCallback, refuse_local_github_build +from agent_env.entity_refs import EntityRef from agent_env.env.env import Env, gateway_url_of from agent_env.store.ids import derive_id from agent_env.env import legacy_protocol @@ -22,6 +23,7 @@ from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode from agent_env.attribution import Attribution if TYPE_CHECKING: + from agent_env.bundle.authoring import AuthoringContext from agent_env.env.env import DeployedEnv from agent_env.providers.env_providers.env_provider import EnvironmentProvider @@ -33,6 +35,10 @@ class WebsiteEnv(Env): type: ClassVar[str] = "website" description = "Website environment with frontend and backend containers, deployed through the environment provider its env_provider_type names" + toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.artifact("backend_image", artifact_type="docker_image"), + EntityRef.artifact("frontend_image", artifact_type="docker_image")) + toml_keys: ClassVar[dict[str, type]] = {"backend_image": object, "frontend_image": object, "environment_name": str, + "env_provider_type": str} def __init__( self, @@ -94,6 +100,22 @@ def from_dict(cls, data: dict) -> WebsiteEnv: env_provider_type=data.get("env_provider_type", "gateway"), ) + @classmethod + def from_toml(cls, data: dict, ctx: AuthoringContext) -> WebsiteEnv: + """Write the env authored as ``data`` (its env.toml, with ``backend_image`` and ``frontend_image`` resolved to + image artifacts' ids) under ``ctx.id`` and return it.""" + fields = cls.accept_toml(data, ctx) + return cls.put(id=ctx.id, + backend_docker_image_artifact=ctx.artifact(fields["backend_image"], DockerImageArtifact), + frontend_docker_image_artifact=ctx.artifact(fields["frontend_image"], DockerImageArtifact), + environment_name=fields["environment_name"], env_provider_type=fields["env_provider_type"]) + + @classmethod + def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: + """The keys of ``data``, an env.toml, a website env takes, its environment_name read from the backend image's + @environment_card when it isn't set. Raises BundleError listing every problem.""" + return ctx.accept_env(data, cls, named_by="backend_image") + async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: return await deploy_through_provider( self, environment_name=self.environment_name, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, diff --git a/src/agent_env/providers/env_providers/constants.py b/src/agent_env/providers/env_providers/constants.py index 7683094d..7676ac52 100644 --- a/src/agent_env/providers/env_providers/constants.py +++ b/src/agent_env/providers/env_providers/constants.py @@ -5,6 +5,10 @@ """ GATEWAY_SERVICE_NAME = "gateway" +# The compose services a gateway deploy runs beside its envs' MCP servers, so no env can be named one of them: the +# gateway, the local Postgres state's database and its two browse UIs, and the website browser (named in +# agent_env.env.envs.website_browser). +GATEWAY_SERVICE_NAMES = frozenset({GATEWAY_SERVICE_NAME, "servicedb", "pgweb", "db-mcp", "website-browser"}) GATEWAY_APP_DIR = "/app" AGENT_ENV_WEBSITE_BACKEND_PORT = 8000 diff --git a/src/agent_env/utils/card_naming.py b/src/agent_env/utils/card_naming.py index 1762e4ae..57ded2a7 100644 --- a/src/agent_env/utils/card_naming.py +++ b/src/agent_env/utils/card_naming.py @@ -2,6 +2,7 @@ import ast import posixpath +from collections.abc import Mapping from pathlib import Path import httpx @@ -68,6 +69,24 @@ def _github_card_names(client: httpx.Client, owner: str, repo: str, ref: str | N return sorted(found) +def card_names_in_files(files: Mapping[str, Path], dockerfile: str) -> list[str]: + """The names `@environment_card(name="...")` gives in the `.py` files among ``files``, keyed by POSIX path: in + those in the Dockerfile's folder, else in all of them, as `card_name_from_source` reads a folder.""" + folder = posixpath.dirname(dockerfile) + sources = {key: path for key, path in files.items() if key.endswith(".py")} + near = {key: path for key, path in sources.items() if not folder or key.startswith(f"{folder}/")} + for chosen in (near, sources): + found: set[str] = set() + for path in chosen.values(): + try: + found |= _card_names_in_source(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError): + continue + if found: + return sorted(found) + return [] + + def _scan_environment_card_names(root: Path) -> list[str]: found: set[str] = set() for py in root.rglob("*.py"): diff --git a/tst/integration/cli/run_bundle_built_envs_test.py b/tst/integration/cli/run_bundle_built_envs_test.py new file mode 100644 index 00000000..d11c10d8 --- /dev/null +++ b/tst/integration/cli/run_bundle_built_envs_test.py @@ -0,0 +1,308 @@ +"""``agent-env run`` on bundles whose envs are written from their folders, with real docker builds and the envs +deployed on the local sandbox. An MCP server on the server provider is named by the environment card in its source, +reused while its folder is unchanged, and rebuilt when it changes. Through the gateway, each way of writing an env +deploys and answers a verifier calling it: an MCP server built from its folder, one built from a Dockerfile elsewhere +under a name of its own, one over a store image, a website, and a multi of bundle and store envs, which is rewritten +when a child is. Needs a Docker daemon and the local registry.""" + +import hashlib +import json +import logging +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from agent_env.artifact.store import reset_artifact_store +from agent_env.bundle import parse_bundle +from agent_env.cli import cli +from agent_env.config import configure, reset_config +from agent_env.env import Env +from agent_env.store.routing import namespace_routing + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +REPO = Path(__file__).resolve().parents[3] +DATA = REPO / "tst" / "data" +ITEMS = DATA / "agentenv_mcp" # an in-memory MCP server whose card names it 'items' +PROTOCOL = REPO / "packages" / "agentenv-protocol" / "src" / "agentenv_protocol" +IMAGE = "envs/items (Dockerfile image)" + +# Checks what an env serves through its gateway, by calling it: each items server adds an item under its own +# environment_name's tool and lists it back from the build it was given, the slack server lists its channels, and each +# website serves its frontend and its backend's health. CONFIG, naming what to check, goes above it. +VERIFY = ''' +import json + +import httpx +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def verify(mcp_url): + results = [] + + def check(id, passed, description): + results.append({"id": id, "result": bool(passed), "description": description}) + + if CONFIG["items"] or CONFIG["slack"]: + async with streamablehttp_client(mcp_url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + names = [tool.name for tool in (await session.list_tools()).tools] + + async def call(suffix, arguments): + name = next((name for name in names if name.endswith(suffix)), None) + if name is None: + return None + result = await session.call_tool(name, arguments) + return None if result.isError else " ".join(getattr(part, "text", "") for part in result.content) + + for name in CONFIG["items"]: + added = await call(f"{name}_add_item", {"item": f"from-{name}"}) + listed = json.loads(await call("list_items", {}) or "{}") + check(f"{name}-items", added and f"from-{name}" in listed.get("items", []), + f"{name}_add_item adds an item list_items lists, among {names}") + check(f"{name}-build", listed.get("build") == CONFIG["build"], + f"list_items answers from build {CONFIG['build']}: {listed}") + if CONFIG["slack"]: + channels = await call("channels_list", {"channel_types": "public_channel"}) + check("slack", channels and json.loads(channels).get("ok"), f"channels_list answers: {channels}") + gateway = mcp_url.rsplit("/mcp", 1)[0] + async with httpx.AsyncClient(timeout=30) as client: + for name in CONFIG["websites"]: + page = await client.get(f"{gateway}/website/{name}/") + check(f"{name}-frontend", page.status_code == 200 and "Slack Workspace" in page.text, + f"the frontend serves its page: {page.status_code}") + health = await client.get(f"{gateway}/svc/{name}/api/health") + check(f"{name}-backend", health.status_code == 200, f"the backend is healthy: {health.status_code}") + return results +''' + + +@pytest.fixture +def state(monkeypatch, tmp_path): + """Local stores under this test's folder. Whatever ran, no sandbox work folder may be left behind.""" + sandboxes = tmp_path / "sandboxes" + # HOME stays: docker's credential helper (the macOS keychain, for one) can hang a build's base-image lookup + # when HOME moves. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + monkeypatch.setenv("AGENT_ENV_DOCUMENT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_LOCAL_SANDBOX_DIR", str(sandboxes)) + configure() + reset_artifact_store() + logging.disable(logging.CRITICAL) # pytest's live logging would take CliRunner's stdout + try: + yield tmp_path + assert not sandboxes.exists() or not any(sandboxes.iterdir()), "a run left a sandbox work folder" + finally: + logging.disable(logging.NOTSET) + reset_artifact_store() + reset_config() + + +def _bundle(root): + """The items server as an env folder, deployed by the server provider, which runs it with no gateway.""" + folder = root / "envs" / "items" + shutil.copytree(PROTOCOL, folder / "agentenv_protocol", ignore=shutil.ignore_patterns("__pycache__")) + for name in ("server.py", "Dockerfile", "seed.json"): + shutil.copy(ITEMS / name, folder / name) + (folder / "env.toml").write_text('env_provider_type = "server"\n') + (root / "tasks").mkdir() + (root / "tasks/items.json").write_text(json.dumps( + [{"id": "env", "type": "deploy_env", "env_id": "items", "sandbox_type": "local"}])) + return root + + +def _run(root, *args): + return CliRunner().invoke(cli, ["run", str(root), *args]) + + +def test_an_env_folder_is_built_named_by_its_card_deployed_and_rebuilt_only_when_it_changes(state): + root = _bundle(state / "envs-bundle") + + first = _run(root) + + assert first.exit_code == 0, first.output + assert f"{IMAGE}: building with docker" in first.output, first.output + assert f"{IMAGE}: v1 (new)" in first.output and "envs/items: v1 (new)" in first.output, first.output + assert "tasks/items.json v1: unscored" in first.output, first.output + with namespace_routing(): + env = Env.get(next(entry.id for entry in parse_bundle(root).entries if entry.name == "items")) + assert (env.environment_name, env.env_provider_type) == ("items", "server") + + again = _run(root) + + assert again.exit_code == 0, again.output + assert "building with docker" not in again.output, again.output + assert f"{IMAGE}: v1, unchanged" in again.output and "envs/items: v1, unchanged" in again.output, again.output + + (root / "envs/items/seed.json").write_text('{"items": ["edited"]}\n') + edited = _run(root) + + assert edited.exit_code == 0, edited.output + assert f"{IMAGE}: v2 (files changed: seed.json)" in edited.output, edited.output + assert "envs/items: v2 (" in edited.output and "tasks/items.json v1: unscored" in edited.output, edited.output + assert edited.output.count("building with docker") == 1, edited.output + + +def _server(build): + """The items server's source, its list_items answering with ``build``.""" + source = (ITEMS / "server.py").read_text() + server = source.replace('json.dumps({"items": self.store})', + f'json.dumps({{"items": self.store, "build": "{build}"}})') + assert server != source + return server + + +def _items(folder, build, dockerfile="Dockerfile"): + """The items server of ``build``, built from ``dockerfile`` in its folder.""" + shutil.copytree(PROTOCOL, folder / "agentenv_protocol", ignore=shutil.ignore_patterns("__pycache__")) + (folder / "server.py").write_text(_server(build)) + shutil.copy(ITEMS / "seed.json", folder / "seed.json") + (folder / dockerfile).parent.mkdir(parents=True, exist_ok=True) + shutil.copy(ITEMS / "Dockerfile", folder / dockerfile) + + +def _shop(folder): + """The slack website as an env folder: its backend and frontend built from Dockerfile.backend and + Dockerfile.frontend, with the folder as their context.""" + no_dockerfiles = shutil.ignore_patterns("Dockerfile", "__pycache__") + for part in ("backend", "frontend"): + shutil.copytree(DATA / "slack_website" / part, folder / part, ignore=no_dockerfiles) + dockerfile = (DATA / "slack_website" / part / "Dockerfile").read_text() + (folder / f"Dockerfile.{part}").write_text(dockerfile.replace("slack_website/", "")) + for package in ("base_mcp", "slack_mcp"): + shutil.copytree(DATA / package, folder / package, ignore=no_dockerfiles) + (folder / "env.toml").write_text('type = "website"\nenvironment_name = "shop"\n') + + +def _check(root, env, *, items=(), slack=False, websites=(), build="v1"): + """A task deploying ``env`` and verifying what it serves, its check a file artifact of the bundle.""" + config = {"items": list(items), "slack": slack, "websites": list(websites), "build": build} + (root / f"artifacts/check-{env}").mkdir(parents=True, exist_ok=True) + (root / f"artifacts/check-{env}/verify.py").write_text(f"CONFIG = {config!r}\n{VERIFY}") + (root / "tasks").mkdir(exist_ok=True) + (root / f"tasks/{env}.json").write_text(json.dumps([ + {"id": "env", "type": "deploy_env", "env_id": env, "sandbox_type": "local"}, + {"id": "check", "type": "env_outcome_verifier", "env_id": env, "file_artifact_id": f"check-{env}", + "verifier_id": env, "depends_on": ["env"]}, + ])) + + +def _gateway_bundle(root): + """An env of each kind a bundle writes, all deployed through the gateway: items and stock are the items server, + built from its folder and named by its card, and built from docker/Dockerfile under a name of its own; relay is an + MCP server over the store image the CLI put for mail; shop is a website; suite is a multi of items, mail and + shop.""" + envs = root / "envs" + _items(envs / "items", "v1") + _items(envs / "stock", "v1", dockerfile="docker/Dockerfile") + (envs / "stock/env.toml").write_text('image = { dockerfile = "docker/Dockerfile" }\nenvironment_name = "stock"\n') + (envs / "relay").mkdir() + (envs / "relay/env.toml").write_text('image = "mail__env_image"\nenvironment_name = "relay"\n') + _shop(envs / "shop") + (envs / "suite").mkdir() + (envs / "suite/env.toml").write_text( + 'type = "multi"\nmcp_server_envs = ["items", "mail"]\nwebsite_envs = ["shop"]\nname = "suite"\n') + _check(root, "items", items=["items"]) + _check(root, "stock", items=["stock"]) + _check(root, "relay", slack=True) + _check(root, "shop", websites=["shop"]) + _check(root, "suite", items=["items"], slack=True, websites=["shop"]) + return root + + +def _put_mail(): + """The slack server as a store env, mail, put by the CLI as a user would before a bundle names it.""" + put = CliRunner().invoke(cli, ["env", "mcp-server", "put", "--id", "mail", "--environment-name", "mail", + "--dockerfile", str(DATA / "slack_mcp/Dockerfile"), "--context", str(DATA), + "--platform", ""]) + assert put.exit_code == 0, put.output + + +GATEWAY_IMAGES = ("envs/items (Dockerfile image)", "envs/stock (docker/Dockerfile image)", + "envs/shop (Dockerfile.backend image)", "envs/shop (Dockerfile.frontend image)") +GATEWAY_ENVS = ("items", "stock", "relay", "shop", "suite") + + +def test_each_kind_of_bundle_env_deploys_through_the_gateway_and_a_child_edit_rewrites_its_multi(state): + _put_mail() + root = _gateway_bundle(state / "gateway-bundle") + + first = _run(root) + + assert first.exit_code == 0, first.output + for image in GATEWAY_IMAGES: + assert f"{image}: building with docker" in first.output and f"{image}: v1 (new)" in first.output, first.output + for env in GATEWAY_ENVS: + assert f"envs/{env}: v1 (new)" in first.output, first.output + assert f"tasks/{env}.json v1: passed (check: 1)" in first.output, first.output + with namespace_routing(): + ids = {entry.name: entry.id for entry in parse_bundle(root).entries} + written = {name: Env.get(ids[name]) for name in GATEWAY_ENVS} + mail = Env.get("mail") + assert {name: env.environment_name for name, env in written.items() if name != "suite"} == { + "items": "items", "stock": "stock", "relay": "relay", "shop": "shop"} + assert written["relay"].docker_image_artifact.id == mail.docker_image_artifact.id == "mail__env_image" + assert [child.id for child in written["suite"].mcp_server_envs] == [ids["items"], "mail"] + assert [child.id for child in written["suite"].website_envs] == [ids["shop"]] + assert written["suite"].name == "suite" + + # The checks now expect build v2 from items, alone and inside suite. + (root / "envs/items/server.py").write_text(_server("v2")) + _check(root, "items", items=["items"], build="v2") + _check(root, "suite", items=["items"], slack=True, websites=["shop"], build="v2") + edited = _run(root, "--task", "items", "--task", "suite") + + assert edited.exit_code == 0, edited.output + assert f"{IMAGE}: v2 (files changed: server.py)" in edited.output, edited.output + assert edited.output.count("building with docker") == 1, edited.output + assert "envs/items: v2 (" in edited.output and "envs/suite: v2 (" in edited.output, edited.output + assert "envs/shop: v1, unchanged" in edited.output, edited.output + for env in ("items", "suite"): + assert f"artifacts/check-{env}: v2 (files changed: verify.py)" in edited.output, edited.output + assert f"tasks/{env}.json v1: passed (check: 1)" in edited.output, edited.output + + planned = _run(root, "--dry-run") + + assert planned.exit_code == 0, planned.output + versions = {"items": 2, "suite": 2} + for image in GATEWAY_IMAGES: + assert f"{image}: v{2 if image == IMAGE else 1}, unchanged" in planned.output, planned.output + for env in GATEWAY_ENVS: + assert f"envs/{env}: v{versions.get(env, 1)}, unchanged" in planned.output, planned.output + + +def _files(root): + """A digest of every file under ``root`` but the locks a run takes.""" + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if path.is_file() and "locks" not in path.parts: + digest.update(str(path.relative_to(root)).encode() + path.read_bytes()) + return digest.hexdigest() + + +def test_a_multi_whose_children_name_one_container_is_refused_before_anything_is_built_or_written(state): + root = state / "clash-bundle" + envs = root / "envs" + _items(envs / "items", "v1") + _items(envs / "stock", "v1") + (envs / "stock/env.toml").write_text('environment_name = "items"\n') + (envs / "suite").mkdir() + (envs / "suite/env.toml").write_text('type = "multi"\nmcp_server_envs = ["items", "stock"]\n') + (root / "tasks").mkdir() + (root / "tasks/suite.json").write_text(json.dumps( + [{"id": "env", "type": "deploy_env", "env_id": "suite", "sandbox_type": "local"}])) + before = _files(state) + + result = _run(root) + + assert result.exit_code == 1, result.output + assert ("mcp_server_envs[1]: environment_name 'items' names the container 'items', as mcp_server_envs[0]'s does" + in result.output), result.output + assert "building with docker" not in result.output and "Traceback" not in result.output, result.output + assert _files(state) == before diff --git a/tst/unit/bundle/_support.py b/tst/unit/bundle/_support.py index 20b3f09d..c74a0321 100644 --- a/tst/unit/bundle/_support.py +++ b/tst/unit/bundle/_support.py @@ -1,6 +1,8 @@ """What the bundle tests share: laying out and planning a folder, the @local namespace's store, and a configured store that fails any test reaching it.""" +from pathlib import Path + from agent_env.bundle import parse_bundle from agent_env.bundle.plan import plan_bundle from agent_env.bundle.resolve import resolve_bundle @@ -15,6 +17,12 @@ def layout(root, files): return root +def listing(folder): + """The files under ``folder``, as POSIX paths relative to it, a link marked with a trailing ``@``.""" + return sorted(path.relative_to(folder).as_posix() + ("@" if path.is_symlink() else "") + for path in Path(folder).rglob("*") if not path.is_dir() or path.is_symlink()) + + def plan_of(root): return plan_bundle(resolve_bundle(parse_bundle(root))) diff --git a/tst/unit/bundle/authoring_test.py b/tst/unit/bundle/authoring_test.py index 8c557e69..4611b758 100644 --- a/tst/unit/bundle/authoring_test.py +++ b/tst/unit/bundle/authoring_test.py @@ -18,6 +18,7 @@ LAYOUT = { "envs/tickets/Dockerfile": "FROM scratch\n", + "envs/tickets/env.toml": 'environment_name = "tickets"\n', "envs/both/env.toml": 'type = "multi"\n', "artifacts/golden/artifact.toml": 'type = "vm_image"\n', } diff --git a/tst/unit/bundle/conftest.py b/tst/unit/bundle/conftest.py new file mode 100644 index 00000000..ffcdd665 --- /dev/null +++ b/tst/unit/bundle/conftest.py @@ -0,0 +1,32 @@ +"""Fixtures the bundle tests share.""" + +import pytest + +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.bundle import materialize as materialize_module +from tst.unit.bundle._support import listing + + +@pytest.fixture +def docker_on_path(monkeypatch): + monkeypatch.setattr(materialize_module.shutil, "which", lambda name: f"/usr/bin/{name}") + + +@pytest.fixture +def builds(monkeypatch, docker_on_path): + """Stands in for docker: each build is recorded with the files of its context, a link marked with a + trailing ``@``, and the image written as a docker_image document.""" + calls = [] + + def build(dockerfile, context, tag, *, platform): + calls.append({"build": (dockerfile.relative_to(context).as_posix(), listing(context), tag, platform)}) + + def put(id, *, description, image_name, build_context_path=None, dockerfile_path=None): + calls[-1]["put"] = (id, image_name, listing(build_context_path), dockerfile_path) + return get_artifact_store().put_document(DockerImageArtifact( + id=id, description=description, image_name=image_name, tar_gz_s3_url=f"file:///{id}.tar.gz")) + + monkeypatch.setattr(materialize_module, "build_image", build) + monkeypatch.setattr(materialize_module.DockerImageArtifact, "put", put) + return calls diff --git a/tst/unit/bundle/env_toml_test.py b/tst/unit/bundle/env_toml_test.py new file mode 100644 index 00000000..3bc03ee1 --- /dev/null +++ b/tst/unit/bundle/env_toml_test.py @@ -0,0 +1,278 @@ +"""Envs written from a bundle's env folders: an MCP server, a website and a multi, each over the images built from +its folder or named in the store, named by its env.toml or its source's environment card, and refused before any +write when its env.toml can't be written.""" + +import json + +import pytest + +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.bundle import BundleError, parse_bundle +from agent_env.bundle import authoring as authoring_module +from agent_env.bundle.plan import check_bundle +from agent_env.bundle.resolve import resolve_bundle +from agent_env.bundle import plan as plan_module +from agent_env.bundle.materialize import materialize +from agent_env.env import Env +from agent_env.env.envs.mcp_server import MCPServerEnv +from agent_env.env.envs.multi_env import MultiEnv +from agent_env.env.envs.website import WebsiteEnv +from agent_env.providers.env_providers import env_provider as env_provider_module +from agent_env.store.routing import namespace_routing +from tst.unit.bundle._support import RefusingStore, layout, local_store, plan_of + +ROOT = "@local/~/triage" + + +class _OneCard: + """A plugin's provider, which deploys a multi behind one env card rather than a sandbox per env.""" + +CARD = 'from agentenv_protocol import environment_card\n\n\n@environment_card(name="{}")\nclass Server:\n pass\n' +DOCKERFILE = "FROM scratch\nCOPY . /app\n" + + +@pytest.fixture +def bundle_dir(tmp_path, monkeypatch, local_stores, cli_routing): + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path / "triage" + + +def _bundle(root, files, *deployed): + layout(root, {**files, "tasks/t.json": json.dumps( + [{"id": f"deploy-{name}", "type": "deploy_env", "env_id": name} for name in deployed])}) + return root + + +def _mcp(name, card=None): + return {f"envs/{name}/Dockerfile": DOCKERFILE, f"envs/{name}/server.py": CARD.format(card or name)} + + +def _run(root, dry_run=False): + return materialize(plan_of(root), dry_run=dry_run) + + +def _summary(materialization): + return {done.write.id: (done.version, done.reused, done.reasons) for done in materialization.writes} + + +def _store_image(id, version=None): + with namespace_routing(): + return get_artifact_store().put_document(DockerImageArtifact( + id=id, description=id, image_name=f"registry.example/{id}:v1", tar_gz_s3_url=f"s3://bucket/{id}.tar.gz")) + + +def _problems(root): + with pytest.raises(BundleError) as caught: + plan_of(root) + return caught.value.problems + + +# MCP servers + + +def test_an_mcp_server_folder_is_written_over_the_image_built_from_it_named_by_its_card(bundle_dir, builds): + _bundle(bundle_dir, _mcp("crm"), "crm") + + first = _summary(_run(bundle_dir)) + env = Env.get(f"{ROOT}/crm") + + assert first[f"{ROOT}/crm__env_image"] == (1, False, ("new",)) and first[f"{ROOT}/crm"] == (1, False, ("new",)) + assert isinstance(env, MCPServerEnv) + assert (env.environment_name, env.env_provider_type, env.metadata) == ("crm", "gateway", {}) + assert (env.docker_image_artifact.id, env.docker_image_artifact.version) == (f"{ROOT}/crm__env_image", 1) + assert [call["build"][:2] for call in builds] == [("Dockerfile", ["Dockerfile", "server.py"])] + + assert all(reused for _, reused, _ in _summary(_run(bundle_dir)).values()) and len(builds) == 1 + (bundle_dir / "envs/crm/server.py").write_text(CARD.format("crm") + "# edited\n") + rebuilt = _summary(_run(bundle_dir)) + assert rebuilt[f"{ROOT}/crm__env_image"] == (2, False, ("files changed: server.py",)) + assert rebuilt[f"{ROOT}/crm"] == (2, False, (f"artifact {ROOT}/crm__env_image is written anew (v1 → v2)",)) + assert Env.get(f"{ROOT}/crm").docker_image_artifact.version == 2 + + +def test_an_environment_name_in_env_toml_wins_over_the_card_and_a_dockerfile_elsewhere_names_where_to_look( + bundle_dir, builds, +): + _bundle(bundle_dir, { + "envs/crm/docker/Dockerfile": DOCKERFILE, "envs/crm/docker/server.py": CARD.format("crm"), + "envs/crm/other.py": CARD.format("not-this"), "envs/crm/env.toml": 'image = { dockerfile = "docker/Dockerfile" }\n', + "envs/named/Dockerfile": DOCKERFILE, "envs/named/server.py": CARD.format("crm"), + "envs/named/env.toml": 'environment_name = "tickets"\nenv_provider_type = "server"\n', + }, "crm", "named") + + _run(bundle_dir) + + assert Env.get(f"{ROOT}/crm").environment_name == "crm" + named = Env.get(f"{ROOT}/named") + assert (named.environment_name, named.env_provider_type) == ("tickets", "server") + + +def test_an_env_over_a_store_image_pins_the_version_the_plan_read_and_is_rewritten_when_the_store_gains_one( + bundle_dir, builds, +): + _store_image("base") + _bundle(bundle_dir, {"envs/crm/env.toml": 'image = "base"\nenvironment_name = "crm"\n'}, "crm") + + _run(bundle_dir) + assert Env.get(f"{ROOT}/crm").docker_image_artifact.version == 1 + _store_image("base") + rewritten = _summary(_run(bundle_dir)) + + assert rewritten[f"{ROOT}/crm"] == (2, False, ("artifact base has a new version in the store (v1 → v2)",)) + assert Env.get(f"{ROOT}/crm").docker_image_artifact.version == 2 + assert builds == [] + + +# Websites and multis + + +def test_a_website_builds_dockerfile_backend_and_dockerfile_frontend_and_takes_its_name_from_the_backend( + bundle_dir, builds, +): + _bundle(bundle_dir, {"envs/shop/Dockerfile.backend": DOCKERFILE, "envs/shop/Dockerfile.frontend": DOCKERFILE, + "envs/shop/server.py": CARD.format("shop"), "envs/shop/env.toml": 'type = "website"\n'}, "shop") + + _run(bundle_dir) + env = Env.get(f"{ROOT}/shop") + + assert isinstance(env, WebsiteEnv) and env.environment_name == "shop" + assert (env.backend_docker_image_artifact.id, env.frontend_docker_image_artifact.id) == ( + f"{ROOT}/shop__backend_image", f"{ROOT}/shop__frontend_image") + assert [call["build"][0] for call in builds] == ["Dockerfile.backend", "Dockerfile.frontend"] + + +def test_a_multi_names_bundle_and_store_envs_and_is_rewritten_when_a_child_is(bundle_dir, builds): + with namespace_routing(): + MCPServerEnv.put(id="mail", docker_image_artifact=_store_image("mail-image"), environment_name="mail") + _bundle(bundle_dir, { + **_mcp("crm"), "envs/shop/Dockerfile.backend": DOCKERFILE, "envs/shop/Dockerfile.frontend": DOCKERFILE, + "envs/shop/env.toml": 'type = "website"\nenvironment_name = "shop"\n', + "envs/suite/env.toml": 'type = "multi"\nmcp_server_envs = ["crm", "mail"]\nwebsite_envs = ["shop"]\nname = "suite"\n', + }, "suite") + + _run(bundle_dir) + suite = Env.get(f"{ROOT}/suite") + assert isinstance(suite, MultiEnv) and suite.name == "suite" + assert [(env.id, env.version) for env in [*suite.mcp_server_envs, *suite.website_envs]] == [ + (f"{ROOT}/crm", 1), ("mail", 1), (f"{ROOT}/shop", 1)] + + (bundle_dir / "envs/crm/server.py").write_text(CARD.format("crm") + "# edited\n") + rewritten = _summary(_run(bundle_dir)) + assert rewritten[f"{ROOT}/suite"] == (2, False, (f"env {ROOT}/crm is written anew (v1 → v2)",)) + assert rewritten[f"{ROOT}/shop"][1] and rewritten[f"{ROOT}/shop__backend_image"][1] + + +# Refused before any write + + +def _website(name, toml=""): + return {f"envs/{name}/Dockerfile.backend": DOCKERFILE, f"envs/{name}/Dockerfile.frontend": DOCKERFILE, + f"envs/{name}/env.toml": f'type = "website"\nenvironment_name = "{name}"\n{toml}'} + + +@pytest.mark.parametrize("files, problem", [ + ({"envs/x/Dockerfile": DOCKERFILE}, + "envs/x: env.toml: environment_name isn't set, and the source its image is built from declares no " + "@environment_card(name=...); set it"), + ({"envs/x/Dockerfile": DOCKERFILE, "envs/x/a.py": CARD.format("a"), "envs/x/b.py": CARD.format("b")}, + "envs/x: env.toml: environment_name isn't set, and the source its image is built from declares several " + "environment cards ('a', 'b'); set it"), + ({"envs/x/env.toml": 'image = "base"\n'}, + "envs/x: env.toml: environment_name isn't set, and its image isn't built from this folder, so there's no source " + "to read it from; set it"), + ({**_mcp("x"), "envs/x/env.toml": '[metadata]\nowner = "me"\n'}, + "envs/x: env.toml: unknown key 'metadata'; a mcp_server env takes image, environment_name, env_provider_type, " + "type and id"), + ({**_mcp("x"), "envs/x/env.toml": 'environment_name = ""\n'}, "envs/x: env.toml: environment_name can't be empty"), + ({**_mcp("x"), "envs/x/env.toml": 'environment_name = "gateway"\n'}, + "envs/x: env.toml: environment_name 'gateway' is one a gateway deploy names its own containers (db-mcp, gateway, " + "pgweb, servicedb, website-browser); choose another"), + (_website("x", 'env_provider_type = "server"\n'), + "envs/x: env.toml: env_provider_type 'server' deploys one MCP server, not a website env"), + ({**_mcp("x"), "envs/x/env.toml": 'image = { dockerfile = "Dockerfile", build_args = { A = "1" } }\n'}, + "envs/x: image: an image built from this folder takes only dockerfile, not build_args"), + ({"envs/x/env.toml": 'type = "multi"\nname = "suite"\n'}, + "envs/x: env.toml: a multi env needs at least one env in mcp_server_envs or website_envs"), + ({**_mcp("crm"), "envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["crm"]\nname = "my suite"\n'}, + "envs/x: env.toml: name must be non-empty with no whitespace, not 'my suite'"), + ({**_website("shop"), "envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["shop"]\n'}, + "envs/x: mcp_server_envs[0]: 'shop' is this bundle's website, but this field takes mcp_server"), + ({**_mcp("a", "crm"), **_mcp("b", "crm"), "envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["a", "b"]\n'}, + "envs/x: mcp_server_envs[1]: environment_name 'crm' names the container 'crm', as mcp_server_envs[0]'s does; " + "each env of a multi needs its own"), + ({**_mcp("a", "shop-website-backend"), **_website("shop"), + "envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["a"]\nwebsite_envs = ["shop"]\n'}, + "envs/x: website_envs[0]: environment_name 'shop' names the container 'shop-website-backend', as " + "mcp_server_envs[0]'s does; each env of a multi needs its own"), + ({"envs/x/env.toml": 'type = "gateway_server"\n'}, + "envs/x: a gateway_server env isn't written from a bundle: config names the one every deploy uses " + "(default_gateway_env_id), and a run builds it when it's missing"), + ({"envs/x/env.toml": 'type = "service_db"\n'}, + "envs/x: a service_db env isn't written from a bundle: config names the one every deploy uses " + "(default_service_db_env_id), and a run builds it when it's missing"), +], ids=["no-card", "several-cards", "store-image-unnamed", "metadata", "empty-name", "reserved-name", + "website-on-server", "build-args", "multi-without-envs", "multi-name", "multi-wrong-child", "multi-same-names", + "multi-same-container", + "gateway", "service-db"]) +def test_an_env_toml_that_cant_be_written_is_refused_before_any_write(bundle_dir, files, problem): + _store_image("base") + _bundle(bundle_dir, files, "x") + + assert problem in _problems(bundle_dir) + assert not local_store().path.exists() + + +def test_an_unknown_env_provider_type_is_refused_naming_the_known_ones(bundle_dir): + _bundle(bundle_dir, {**_mcp("x"), "envs/x/env.toml": 'env_provider_type = "nope"\n'}, "x") + + (problem,) = _problems(bundle_dir) + assert problem.startswith("envs/x: env.toml: Unknown env_provider_type: 'nope' (expected one of") + + +def test_a_plugin_provider_multi_with_an_mcp_server_and_a_website_of_one_name_is_refused_before_any_write( + bundle_dir, monkeypatch, +): + real = env_provider_module._env_provider_class + providers = lambda name: _OneCard if name == "one_card_test" else real(name) + for module in (env_provider_module, authoring_module, plan_module): + monkeypatch.setattr(module, "_env_provider_class", providers) + _bundle(bundle_dir, {**_mcp("crm", "shop"), **_website("shop"), "envs/x/env.toml": + 'type = "multi"\nmcp_server_envs = ["crm"]\nwebsite_envs = ["shop"]\n' + 'env_provider_type = "one_card_test"\n'}, "x") + + assert _problems(bundle_dir) == ( + "envs/x: env_provider_type 'one_card_test' gives a multi one env card, so it can't tell an MCP server and a " + "website apart by name, and both are named 'shop'; rename one",) + assert not local_store().path.exists() + + +def test_a_multi_naming_a_store_env_of_another_type_is_refused(bundle_dir): + with namespace_routing(): + WebsiteEnv.put(id="web", backend_docker_image_artifact=_store_image("b"), frontend_docker_image_artifact=_store_image("f"), + environment_name="web") + _bundle(bundle_dir, {"envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["web"]\n'}, "x") + + assert _problems(bundle_dir) == ("envs/x: mcp_server_envs[0]: 'web' is a website in the store, but this field takes " + "mcp_server",) + + +def test_an_env_folder_whose_id_is_another_type_of_env_in_the_store_is_refused(bundle_dir): + with namespace_routing(): + WebsiteEnv.put(id=f"{ROOT}/x", backend_docker_image_artifact=_store_image("b"), + frontend_docker_image_artifact=_store_image("f"), environment_name="x") + _bundle(bundle_dir, _mcp("x"), "x") + + assert _problems(bundle_dir) == (f"envs/x: '{ROOT}/x' is a website env in the store, and an env keeps its type " + "across versions; rename the folder",) + + +def test_a_check_reports_an_env_toml_problem_without_reading_a_store(bundle_dir, monkeypatch): + _bundle(bundle_dir, {"envs/x/Dockerfile": DOCKERFILE}, "x") + monkeypatch.setattr("agent_env.config.runtime.Config.get_document_store", lambda self: RefusingStore()) + + with pytest.raises(BundleError) as caught: + check_bundle(resolve_bundle(parse_bundle(bundle_dir))) + + assert caught.value.problems == ("envs/x: env.toml: environment_name isn't set, and the source its image is built " + "from declares no @environment_card(name=...); set it",) diff --git a/tst/unit/bundle/ledger_test.py b/tst/unit/bundle/ledger_test.py index c66e9820..d8f2b12b 100644 --- a/tst/unit/bundle/ledger_test.py +++ b/tst/unit/bundle/ledger_test.py @@ -29,6 +29,7 @@ GREETING = f"{ROOT}/greeting" LAYOUT = { "envs/tickets/Dockerfile": "FROM scratch\n", + "envs/tickets/env.toml": 'environment_name = "tickets"\n', "envs/shelf/env.toml": 'type = "shelf_ledger_test"\ndata = "greeting"\n', "artifacts/greeting/hello.txt": "hello\n", "tasks/t.json": json.dumps([ @@ -161,7 +162,7 @@ def _steps(*extra): def test_a_rerun_reuses_every_version_whose_inputs_havent_changed(bundle_dir): first = _run(bundle_dir) assert {id: check.reasons for id, check in first.items()} == dict.fromkeys( - [f"{ROOT}/tickets", GREETING, f"{ROOT}/shelf", f"{ROOT}/t"], ("new",)) + [f"{ROOT}/tickets__env_image", f"{ROOT}/tickets", GREETING, f"{ROOT}/shelf", f"{ROOT}/t"], ("new",)) second = _run(bundle_dir) diff --git a/tst/unit/bundle/materialize_test.py b/tst/unit/bundle/materialize_test.py index c912d39b..e5d895d8 100644 --- a/tst/unit/bundle/materialize_test.py +++ b/tst/unit/bundle/materialize_test.py @@ -109,17 +109,20 @@ def from_toml(cls, data, ctx): raise NotImplementedError -class _Imaged(Env): - """An env type whose image is built from the folder's Dockerfile.""" +class _OwnEnv(Env): + """A plugin's env type with a from_toml of its own.""" - type = "imaged_materialize_test" - toml_refs = (EntityRef.artifact("image", artifact_type="docker_image"),) + type = "own_env_materialize_test" + + @classmethod + def from_toml(cls, data, ctx): + raise NotImplementedError @pytest.fixture(autouse=True) def registries(monkeypatch): steps = {**Config().task_step_registry(), **{cls.type: cls for cls in (_Checked, _Writes, _Consuming)}} - envs = {**Config().env_registry(), _Imaged.type: _Imaged} + envs = {**Config().env_registry(), _OwnEnv.type: _OwnEnv} plugins = {cls.model_fields["type"].default: cls for cls in (_PluginFile, _OwnFile)} artifacts = {**Config().artifact_registry(), **plugins} monkeypatch.setattr(Config, "task_step_registry", lambda self: steps) @@ -186,8 +189,8 @@ def test_a_rerun_reuses_every_version_and_an_edit_rewrites_only_what_it_changed( def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir, dry_run): layout(bundle_dir, { "envs/tickets/Dockerfile": "FROM scratch\n", - "envs/imaged/env.toml": 'type = "imaged_materialize_test"\n', - "envs/imaged/Dockerfile": "FROM scratch\n", + "envs/tickets/env.toml": 'environment_name = "tickets"\n', + "envs/own/env.toml": 'type = "own_env_materialize_test"\n', "agents/solver/Dockerfile": "FROM scratch\n", "skills/pdf/SKILL.md": "---\nname: pdf\n---\n", "artifacts/base-mcp/Dockerfile": "FROM scratch\n", @@ -196,7 +199,7 @@ def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir }) _steps(bundle_dir, [ {"id": "tickets", "type": "deploy_env", "env_id": "tickets"}, - {"id": "imaged", "type": "deploy_env", "env_id": "imaged"}, + {"id": "own", "type": "deploy_env", "env_id": "own"}, {"id": "agent", "type": "deploy_agent", "env_ids": ["tickets"], "a2a_agent_id": "solver"}, {"id": "pdf", "type": "load_artifact", "env_id": "tickets", "artifact_id": "pdf"}, {"id": "image", "type": "load_artifact", "env_id": "tickets", "artifact_id": "base-mcp"}, @@ -206,8 +209,7 @@ def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir assert sorted(_problems(lambda: _run(bundle_dir, dry_run))) == [ "artifacts/base-mcp: writing a docker_image artifact isn't supported yet", "artifacts/snap: writing an environment artifact isn't supported yet", - "envs/imaged: writing an env isn't supported yet", - "envs/tickets: writing an env isn't supported yet", + "envs/own: writing an own_env_materialize_test env isn't supported yet", "skills/pdf: writing a skill isn't supported yet", ] assert not local_store().path.exists() @@ -313,35 +315,6 @@ def test_another_bundles_entities_are_read_like_store_ids_and_an_agent_pins_the_ assert A2AAgent.get(f"{ROOT}/solver").docker_image_artifact.version == 2 -@pytest.fixture -def docker_on_path(monkeypatch): - monkeypatch.setattr(materialize_module.shutil, "which", lambda name: f"/usr/bin/{name}") - - -@pytest.fixture -def builds(monkeypatch, docker_on_path): - """Stands in for docker: each build is recorded with the files of its context, a link marked with a - trailing ``@``, and the image written as a docker_image document.""" - calls = [] - - def build(dockerfile, context, tag, *, platform): - calls.append({"build": (dockerfile.relative_to(context).as_posix(), _listing(context), tag, platform)}) - - def put(id, *, description, image_name, build_context_path=None, dockerfile_path=None): - calls[-1]["put"] = (id, image_name, _listing(build_context_path), dockerfile_path) - return get_artifact_store().put_document(DockerImageArtifact( - id=id, description=description, image_name=image_name, tar_gz_s3_url=f"file:///{id}.tar.gz")) - - monkeypatch.setattr(materialize_module, "build_image", build) - monkeypatch.setattr(materialize_module.DockerImageArtifact, "put", put) - return calls - - -def _listing(folder): - return sorted(path.relative_to(folder).as_posix() + ("@" if path.is_symlink() else "") - for path in Path(folder).rglob("*") if not path.is_dir() or path.is_symlink()) - - def test_an_agent_folder_with_a_dockerfile_is_built_and_the_agent_written_over_its_image(bundle_dir, builds): layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\nCOPY run.sh /\n", "agents/solver/run.sh": "echo hi\n"}) _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) diff --git a/tst/unit/bundle/plan_test.py b/tst/unit/bundle/plan_test.py index 4035e9d7..b2ae92f7 100644 --- a/tst/unit/bundle/plan_test.py +++ b/tst/unit/bundle/plan_test.py @@ -27,6 +27,7 @@ ROOT = "@local/~/triage" LAYOUT = { "envs/tickets/Dockerfile": "FROM scratch\n", + "envs/tickets/env.toml": 'environment_name = "tickets"\n', "agents/solver/Dockerfile": "FROM scratch\n", "artifacts/greeting/hello.txt": "hello\n", "artifacts/greeting/check.py": "print('ok')\n", @@ -152,7 +153,8 @@ def test_a_bundle_without_evals_runs_every_task_and_writes_only_what_they_reach( "t": [deploy("tickets"), load("greeting"), {"id": "agent", "type": "deploy_agent", "env_ids": ["tickets"], "a2a_agent_id": "solver"}], })) - assert writes(plan) == [(BundleKind.ENV, f"{ROOT}/tickets"), (BundleKind.ARTIFACT, f"{ROOT}/solver__agent_image"), + assert writes(plan) == [(BundleKind.ARTIFACT, f"{ROOT}/tickets__env_image"), (BundleKind.ENV, f"{ROOT}/tickets"), + (BundleKind.ARTIFACT, f"{ROOT}/solver__agent_image"), (BundleKind.AGENT, f"{ROOT}/solver"), (BundleKind.ARTIFACT, f"{ROOT}/greeting"), (BundleKind.TASK, f"{ROOT}/t")] assert set(plan.writes[-1].needs) == {("env", f"{ROOT}/tickets"), ("agent", f"{ROOT}/solver"), @@ -163,7 +165,8 @@ def test_a_bundle_without_evals_runs_every_task_and_writes_only_what_they_reach( def test_a_bundle_with_evals_runs_every_eval_and_writes_the_tasks_they_name(make): plan = plan_bundle(make(tasks={"t": [deploy("tickets")], "u": [deploy("tickets")]}, evals={"regression": 'tasks = ["t"]'})) - assert writes(plan) == [(BundleKind.ENV, f"{ROOT}/tickets"), (BundleKind.TASK, f"{ROOT}/t"), + assert writes(plan) == [(BundleKind.ARTIFACT, f"{ROOT}/tickets__env_image"), (BundleKind.ENV, f"{ROOT}/tickets"), + (BundleKind.TASK, f"{ROOT}/t"), (BundleKind.EVAL, f"{ROOT}/regression")] assert plan.writes[-1].needs == (("task", f"{ROOT}/t"),) assert (plan.tasks, names(plan.evals)) == ((), ["regression"]) @@ -199,10 +202,11 @@ def test_each_write_comes_after_what_it_references(make): plan = plan_bundle(make(tasks={"t": [deploy("both")]}, files={ "envs/both/Dockerfile": "FROM scratch\n", "envs/both/env.toml": composite(mcp_server_envs=["tickets"]), })) - assert writes(plan) == [(BundleKind.ARTIFACT, f"{ROOT}/both__env_image"), (BundleKind.ENV, f"{ROOT}/tickets"), + assert writes(plan) == [(BundleKind.ARTIFACT, f"{ROOT}/both__env_image"), + (BundleKind.ARTIFACT, f"{ROOT}/tickets__env_image"), (BundleKind.ENV, f"{ROOT}/tickets"), (BundleKind.ENV, f"{ROOT}/both"), (BundleKind.TASK, f"{ROOT}/t")] assert plan.writes[0].needs == () - assert plan.writes[2].needs == (("artifact", f"{ROOT}/both__env_image"), ("env", f"{ROOT}/tickets")) + assert plan.writes[3].needs == (("artifact", f"{ROOT}/both__env_image"), ("env", f"{ROOT}/tickets")) @pytest.mark.parametrize(("files", "problem"), [ diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 0d1eeef7..2372c98a 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -11,6 +11,7 @@ from click.testing import CliRunner import agent_env.bundle.preflight as preflight_module +import agent_env.bundle.run as run_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 @@ -382,6 +383,78 @@ def test_a_problem_several_deploys_share_is_reported_once_naming_the_first(bundl ] +# Envs the bundle writes, checked from their planned env.toml + +BUNDLE = "@local/~/triage" +SERVER = 'from agentenv_protocol import environment_card\n\n\n@environment_card(name="{}")\nclass S:\n pass\n' + + +def _env_folder(root, name, toml="", kind="mcp_server"): + files = ({f"envs/{name}/Dockerfile": "FROM scratch\n"} if kind == "mcp_server" else + {f"envs/{name}/Dockerfile.backend": "FROM scratch\n", f"envs/{name}/Dockerfile.frontend": "FROM scratch\n"}) + layout(root, {**files, f"envs/{name}/server.py": SERVER.format(name), + f"envs/{name}/env.toml": f'type = "{kind}"\n{toml}'}) + + +def test_an_image_the_bundle_builds_for_an_env_is_refused_on_another_provider(bundle_dir): + _env_folder(bundle_dir, "crm") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + + assert (f"tasks/t.json: step 'env': deploys env '{BUNDLE}/crm''s image '{BUNDLE}/crm__env_image' on the 'modal_vm' " + "sandbox provider, which can't reach it: it's built on this machine from envs/crm/Dockerfile; run it with " + "--sandbox local") in _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) + + +def test_a_website_the_bundle_writes_is_refused_on_modal_and_a_multi_holding_one_too(bundle_dir): + _env_folder(bundle_dir, "shop", kind="website") + layout(bundle_dir, {"envs/suite/env.toml": 'type = "multi"\nwebsite_envs = ["shop"]\n'}) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "suite"}]) + + assert (f"tasks/t.json: step 'env': deploys env '{BUNDLE}/suite', 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") in _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) + + +@pytest.mark.parametrize("folders, deployed, kinds", [ + ([("crm", "", "mcp_server")], "crm", ["service-db", "gateway"]), + ([("shop", "", "website")], "shop", ["service-db", "gateway", "website-browser"]), + ([("crm", "", "mcp_server"), ("shop", "", "website"), + ("suite", 'mcp_server_envs = ["crm"]\nwebsite_envs = ["shop"]\n', "multi")], "suite", + ["service-db", "gateway", "website-browser"]), + ([("crm", 'env_provider_type = "server"\n', "mcp_server")], "crm", []), +], ids=["mcp-server", "website", "multi", "server-provider"]) +def test_an_env_the_bundle_writes_names_the_infra_its_deploy_on_the_local_provider_builds( + bundle_dir, folders, deployed, kinds, +): + for name, toml, kind in folders: + if kind == "multi": + layout(bundle_dir, {f"envs/{name}/env.toml": f'type = "multi"\n{toml}'}) + else: + _env_folder(bundle_dir, name, toml, kind) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": deployed}]) + + assert [build.kind for build in dry_run_bundle(bundle_dir).infra] == kinds + + +def test_a_store_image_an_env_names_without_a_version_is_checked_at_the_version_the_plan_read( + bundle_dir, monkeypatch, +): + _image("base", REMOTE) + _infra(REMOTE) + layout(bundle_dir, {"envs/crm/env.toml": 'image = "base"\nenvironment_name = "crm"\n'}) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + planned = run_module.plan_bundle + + def a_local_image_lands_once_planned(*args, **kwargs): + plan = planned(*args, **kwargs) + _image("base", LOCAL) # v2, after the plan read v1, which the env is written at + return plan + + monkeypatch.setattr(run_module, "plan_bundle", a_local_image_lands_once_planned) + + assert dry_run_bundle(bundle_dir, sandbox="modal_vm").runs + + 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"}]) diff --git a/tst/unit/bundle/resolve_test.py b/tst/unit/bundle/resolve_test.py index 818ae1be..874711f7 100644 --- a/tst/unit/bundle/resolve_test.py +++ b/tst/unit/bundle/resolve_test.py @@ -15,6 +15,7 @@ ROOT = "@local/~/triage" LAYOUT = { "envs/tickets/Dockerfile": "FROM scratch\n", + "envs/tickets/env.toml": 'environment_name = "tickets"\n', "agents/solver/Dockerfile": "FROM scratch\n", "artifacts/greeting/hello.txt": "hello\n", "artifacts/greeting/check.py": "print('ok')\n", @@ -331,14 +332,15 @@ def test_an_eval_type_other_than_eval_is_refused(make): def test_a_toml_names_envs_and_images_in_the_bundle_or_the_store(make): toml = ('type = "composite_test"\nmcp_server_envs = ["tickets", { env = "crm", version = 2 }]\n' - 'image = "base-mcp"\n') + 'image = "base-mcp"\nbackend_image = { artifact = "store-backend", version = 4 }\n') entry = resolved(make(files={"envs/both/env.toml": toml}), "both") assert entry.config == {"type": "composite_test", "mcp_server_envs": [f"{ROOT}/tickets", {"env": "crm", "version": 2}], - "image": f"{ROOT}/base-mcp"} + "image": f"{ROOT}/base-mcp", "backend_image": {"artifact": "store-backend", "version": 4}} assert refs(entry) == [ (EntityKind.ENV, f"{ROOT}/tickets", None, "tickets"), (EntityKind.ENV, "crm", 2, None), (EntityKind.ARTIFACT, f"{ROOT}/base-mcp", None, "base-mcp"), + (EntityKind.ARTIFACT, "store-backend", 4, None), ] @@ -365,7 +367,8 @@ def test_an_agent_names_a_store_image_or_builds_its_folder(make): solver, judge = (next(e for e in result.entries if e.entry.name == name) for name in ("solver", "judge")) assert (solver.config, judge.config) == ( {"image": f"{ROOT}/solver__agent_image"}, {"image": {"artifact": "claude-image", "version": 3}}) - assert result.built_images == (BuiltImage(f"{ROOT}/solver__agent_image", solver.entry, "Dockerfile"),) + assert [image for image in result.built_images if image.entry.kind is BundleKind.AGENT] == [ + BuiltImage(f"{ROOT}/solver__agent_image", solver.entry, "Dockerfile")] assert (refs(solver), refs(judge)) == ( [(EntityKind.ARTIFACT, f"{ROOT}/solver__agent_image", None, "Dockerfile")], [(EntityKind.ARTIFACT, "claude-image", 3, None)], @@ -386,12 +389,13 @@ def test_an_agent_names_a_store_image_or_builds_its_folder(make): "or { env = \"\", version = }, the version optional, not {'env': 'crm', 'extra': 1}"), ]) def test_a_bad_toml_reference_is_refused(make, toml, problem): - bundle = make(files={"envs/web/env.toml": f'type = "composite_test"\n{toml}\n'}) + bundle = make(files={"envs/web/env.toml": f'type = "composite_test"\n{toml}\n', "envs/web/Dockerfile.backend": "FROM x\n"}) assert problems(bundle) == (f"envs/web: {problem}",) -def test_an_image_left_out_needs_a_dockerfile_to_build(make): +def test_an_image_left_out_needs_its_dockerfile_to_build_image_for_image_and_dockerfile_role_for_role_image(make): assert problems(make(files={"envs/web/env.toml": 'type = "composite_test"\n'})) == ( + "envs/web: backend_image: there is no 'Dockerfile.backend' in this folder to build", "envs/web: image: there is no 'Dockerfile' in this folder to build", ) @@ -433,11 +437,11 @@ def test_every_problem_is_reported_together(make): def test_entities_without_references_resolve_to_a_copy_of_their_config(make): result = resolve_bundle(make()) - assert {(e.entry.kind, e.entry.name): e.references for e in result.entries if e.entry.kind is not BundleKind.AGENT} == { - (BundleKind.ENV, "tickets"): (), (BundleKind.ARTIFACT, "greeting"): (), - (BundleKind.ARTIFACT, "base-mcp"): (), (BundleKind.SKILL, "pdf"): (), + built = (BundleKind.AGENT, BundleKind.ENV) # each names the image built from its folder + assert {(e.entry.kind, e.entry.name): e.references for e in result.entries if e.entry.kind not in built} == { + (BundleKind.ARTIFACT, "greeting"): (), (BundleKind.ARTIFACT, "base-mcp"): (), (BundleKind.SKILL, "pdf"): (), } - assert [image.entry.kind for image in result.built_images] == [BundleKind.AGENT] + assert {image.entry.kind for image in result.built_images} == set(built) @pytest.mark.parametrize(("depends_on", "problem"), [ diff --git a/tst/unit/bundle/toml_refs_test.py b/tst/unit/bundle/toml_refs_test.py new file mode 100644 index 00000000..59eaf52a --- /dev/null +++ b/tst/unit/bundle/toml_refs_test.py @@ -0,0 +1,80 @@ +"""Every type written from a bundle's toml declares exactly the keys that name other entities: each ``toml_refs`` +path is a key it takes, ``from_toml`` loads what each declared path names, and what it writes references nothing +else.""" + +import pytest + +from agent_env.a2a_agent import A2AAgent +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.bundle import parse_bundle +from agent_env.bundle.authoring import AuthoringContext +from agent_env.bundle.parse import _EVAL_KEYS +from agent_env.env.envs.mcp_server import MCPServerEnv +from agent_env.env.envs.multi_env import MultiEnv +from agent_env.env.envs.website import WebsiteEnv +from agent_env.eval.eval import Eval +from tst.unit.bundle._support import layout + +WRITTEN_FROM_TOML = { # each type, the folder that holds one, and the keys it needs beside its references + MCPServerEnv: ("envs/server", {"environment_name": "server"}), + WebsiteEnv: ("envs/site", {"environment_name": "site"}), + MultiEnv: ("envs/suite", {}), + A2AAgent: ("agents/solver", {}), +} + + +@pytest.mark.parametrize("cls", [*WRITTEN_FROM_TOML, Eval], ids=lambda cls: cls.__name__) +def test_each_declared_path_starts_at_a_key_the_type_takes(cls): + takes = set(_EVAL_KEYS) if cls is Eval else set(cls.toml_keys) + assert cls.toml_refs and {ref.field for ref in cls.toml_refs} <= takes + + +def _image(id): + return DockerImageArtifact(id=id, version=1, description=id, image_name=f"registry.example/{id}:v1", + tar_gz_s3_url=f"s3://bucket/{id}.tar.gz") + + +_STAND_INS = { # what a load returns for each type a from_toml asks for + DockerImageArtifact: _image, + MCPServerEnv: lambda id: MCPServerEnv(id, 1, docker_image_artifact=_image(f"{id}-image"), environment_name=id), + WebsiteEnv: lambda id: WebsiteEnv(id, 1, backend_docker_image_artifact=_image(f"{id}-back"), + frontend_docker_image_artifact=_image(f"{id}-front"), environment_name=id), +} + + +@pytest.mark.parametrize("cls", WRITTEN_FROM_TOML, ids=lambda cls: cls.__name__) +def test_from_toml_loads_each_declared_ref_and_writes_no_other(tmp_path, monkeypatch, cls): + folder, needed = WRITTEN_FROM_TOML[cls] + root = layout(tmp_path / "b", {f"{folder}/{'agent' if folder.startswith('agents') else 'env'}.toml": + f'type = "{cls.type}"\n'}) + entry = next(entry for entry in parse_bundle(root).entries if entry.name == folder.split("/")[1]) + sentinels = {ref: f"sentinel-{ref.field}" for ref in cls.toml_refs} + data = {"type": cls.type, **needed, + **{ref.field: [id] if ref.path.endswith("[]") else id for ref, id in sentinels.items()}} + loads, written = [], {} + + def load(self, base, kind, ref, expect): + loads.append((kind, ref)) + return _STAND_INS[expect](ref) + + def put(**kwargs): + written.update(kwargs) + return cls(version=1, **kwargs) + + monkeypatch.setattr(AuthoringContext, "_load", load) + monkeypatch.setattr(cls, "put", put) + entity = cls.from_toml(data, AuthoringContext(parse_bundle(root), entry)) + + assert sorted(loads) == sorted((ref.kind, id) for ref, id in sentinels.items()) + assert _referenced(entity.to_dict()) <= set(sentinels.values()) + + +def _referenced(doc): + """The ids of the entities a written document names: each nested ``{id, version, ...}`` table.""" + found = set() + for value in doc.values(): + for item in value if isinstance(value, list) else [value]: + if isinstance(item, dict) and "id" in item and "version" in item: + found.add(item["id"]) + return found + diff --git a/tst/unit/env/envs/test_multi_env_plugin_provider.py b/tst/unit/env/envs/test_multi_env_plugin_provider.py index 504bdff4..37b3cbef 100644 --- a/tst/unit/env/envs/test_multi_env_plugin_provider.py +++ b/tst/unit/env/envs/test_multi_env_plugin_provider.py @@ -240,7 +240,8 @@ def _preflight(env, **options) -> list[str]: @pytest.mark.asyncio @pytest.mark.parametrize("make, refusal", [ - (lambda: _multi(websites=("slack",)), "whose one env card can't tell an MCP server and a website apart by name, and both are named 'slack'"), + (lambda: _multi(websites=("slack",)), + "which gives a multi one env card, so it can't tell an MCP server and a website apart by name, and both are named 'slack'"), (lambda: _multi("server"), "which deploys one MCP server, not a multi env"), (lambda: _site("shop", "server"), "which deploys one MCP server, not a website env"), (lambda: _multi("plugin_named"), "which doesn't take cpu; name it in the provider's deploy() or take **options"),