From 2861aa37631fc5f7d342e1f7b2908064e62aa67b Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 29 Oct 2026 22:41:47 -0700 Subject: [PATCH] feat(website): a website env is one server image, checked at its health path (FD-4683) A site's server image serves its pages and API, so a website env no longer needs a separate frontend image: one still deploys, put warns that it's deprecated, and a bundle may leave it out. The env declares the path that answers once it's usable, which holds only plain path characters. The server image is stored as server_docker_image_artifact beside backend_docker_image_artifact, which an older document holds and is read as it, and backend_image in an env.toml names it. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/agent_env/bundle/resolve.py | 2 + src/agent_env/entity/refs.py | 2 + src/agent_env/env/envs/website.py | 151 ++++++++++++------ .../bundle/website_optional_frontend_test.py | 23 +++ .../env/envs/test_website_server_image.py | 75 +++++++++ .../serialization/golden/env/website.json | 6 + tst/unit/store/local_id_acceptance_test.py | 2 +- 8 files changed, 210 insertions(+), 53 deletions(-) create mode 100644 tst/unit/bundle/website_optional_frontend_test.py create mode 100644 tst/unit/env/envs/test_website_server_image.py diff --git a/AGENTS.md b/AGENTS.md index a847e7a7a..329c3b8a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ and deserialized through a registry. | Package | Contents | |---|---| -| `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset`, `close` and `load_file_artifact_universe`; teardown closes each env a run deployed before terminating its sandboxes by id. Built-ins in `env/envs/`: `MCPServerEnv` (`mcp_server`), `MultiEnv` (`multi`, MCP servers plus websites), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, `env/envs/computer/driver_env.py`: a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed, with the `port` it serves on and its install by OS, `[install.]`: files, a `python` requirements file for a venv, and the services the base's init runs; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch with this machine's Docker, the other arch through its emulation, tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile and build context so any env with the same inputs reuses it; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 then boots each native build on a configured sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | +| `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset`, `close` and `load_file_artifact_universe`; teardown closes each env a run deployed before terminating its sandboxes by id. Built-ins in `env/envs/`: `MCPServerEnv` (`mcp_server`), `MultiEnv` (`multi`, MCP servers plus websites), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`, one site: a server image, `backend_image` in its env.toml, serving the pages and API, checked at its `health` path; a separate frontend image is deprecated, warned of at put and optional in a bundle, and a stored `backend_docker_image_artifact` reads as the server image), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, `env/envs/computer/driver_env.py`: a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed, with the `port` it serves on and its install by OS, `[install.]`: files, a `python` requirements file for a venv, and the services the base's init runs; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch with this machine's Docker, the other arch through its emulation, tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile and build context so any env with the same inputs reuses it; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 then boots each native build on a configured sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | | `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact but an environment universe, whose `metadata` key holds its legacy metadata file ids, carries a `metadata` dict outside its versioned fields, changed by compare-and-swap (`merge_metadata`). Built-ins: `CliArtifact` (`cli`), `ComputerArtifact` (`computer`, a base computer: its architectures, OS, session user, settings and container form, whose image `put` reads from its registry into `metadata.validated_images`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | | `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, `start_computer_recording` and `end_computer_recording`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB, DynamoDB, Firestore with MongoDB compatibility), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. `store/routing.py` is the `@local` namespace: while the CLI or a `build_app` server runs, an `@local/…` id and what an `@local` task's run records go to local stores beside the configured ones, and that run's writes to the configured stores are refused. A new backend must pass the conformance kits in `tst/store/`. | diff --git a/src/agent_env/bundle/resolve.py b/src/agent_env/bundle/resolve.py index 5b7e59e1b..35c580164 100644 --- a/src/agent_env/bundle/resolve.py +++ b/src/agent_env/bundle/resolve.py @@ -316,6 +316,8 @@ def _image(self, entry: BundleEntry, config: dict, ref: EntityRef, references: l return True if value is None: dockerfile = default_dockerfile(ref.path) + if ref.optional and not (entry.path / dockerfile).is_file(): + return True elif isinstance(value, dict) and value.keys() == {"dockerfile"}: dockerfile = value["dockerfile"] elif isinstance(value, dict) and "dockerfile" in value: diff --git a/src/agent_env/entity/refs.py b/src/agent_env/entity/refs.py index 3e4568fc0..bd4401762 100644 --- a/src/agent_env/entity/refs.py +++ b/src/agent_env/entity/refs.py @@ -55,6 +55,7 @@ class EntityRef: element of a list (``env_ids[]``, ``artifacts[].id``); ``version_field`` is the key beside the ref, in the same dict, that pins its version. ``walk``, for a shape a path cannot spell, maps the value at the path's parent to the dicts that hold the last key, each with its path parts. + ``optional``, for an image a toml may leave out, means a folder with no Dockerfile for it builds none. Declarations build one with ``EntityRef.env``, ``EntityRef.agent`` or ``EntityRef.artifact``, passing the keyword fields.""" @@ -66,6 +67,7 @@ class EntityRef: artifact_type: str | None = None env_type: str | None = None walk: Walker | None = None + optional: bool = False @classmethod def env(cls, path: str, **options: Any) -> EntityRef: diff --git a/src/agent_env/env/envs/website.py b/src/agent_env/env/envs/website.py index 622c07133..4ccca1ca6 100644 --- a/src/agent_env/env/envs/website.py +++ b/src/agent_env/env/envs/website.py @@ -1,4 +1,5 @@ -"""Website environment with frontend and backend containers.""" +"""Website environment: one site, served by one server image that holds its pages and API. A site +built before the server image held its pages may still name a separate frontend image, which is deprecated.""" from __future__ import annotations @@ -6,6 +7,7 @@ import logging import os import shlex +import re import uuid from importlib.metadata import version as pkg_version from typing import TYPE_CHECKING, Any, Callable, ClassVar, Optional @@ -32,25 +34,29 @@ logger = logging.getLogger(__name__) - +DEFAULT_HEALTH_PATH = "/api/health" +_HEALTH_PATH = re.compile(r"/[A-Za-z0-9._~/-]*") 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" + description = "Website environment: one server image serving a site's pages and API, 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")) + EntityRef.artifact("frontend_image", artifact_type="docker_image", + optional=True)) toml_keys: ClassVar[dict[str, type]] = {"backend_image": object, "frontend_image": object, "environment_name": str, - "env_provider_type": str} + "health": str, "env_provider_type": str} def __init__( self, id: str, version: Optional[int], - backend_docker_image_artifact: DockerImageArtifact, - frontend_docker_image_artifact: DockerImageArtifact, + server_docker_image_artifact: Optional[DockerImageArtifact] = None, + frontend_docker_image_artifact: Optional[DockerImageArtifact] = None, environment_name: Optional[str] = None, *, + backend_docker_image_artifact: Optional[DockerImageArtifact] = None, + health_path: str = DEFAULT_HEALTH_PATH, metadata: Optional[dict[str, str]] = None, env_provider_type: str = "gateway", ): @@ -59,8 +65,14 @@ def __init__( raise ValueError("environment_name cannot be empty") if not env_provider_type: raise ValueError("env_provider_type cannot be empty") - self.backend_docker_image_artifact = backend_docker_image_artifact + if server_docker_image_artifact is not None and backend_docker_image_artifact is not None: + raise ValueError("a website env names its server image once, as server_docker_image_artifact or as its " + "older name backend_docker_image_artifact") + self.server_docker_image_artifact = server_docker_image_artifact or backend_docker_image_artifact self.frontend_docker_image_artifact = frontend_docker_image_artifact + if problem := health_path_problem(health_path): + raise ValueError(f"website env {id!r}: {problem}") + self.health_path = health_path self.environment_name = environment_name self.env_provider_type = env_provider_type self._sandbox = None @@ -70,18 +82,24 @@ def __init__( self._deployed: Optional[DeployedEnv] = None self._replaced: list[tuple] = [] # the provider and sandbox of each deployment a later deploy replaced, for close() + @property + def backend_docker_image_artifact(self) -> DockerImageArtifact: + """The server image, under the name it had while a site's pages came from a separate frontend image.""" + return self.server_docker_image_artifact + def to_dict(self) -> dict: base = super().to_dict() - base["backend_docker_image_artifact"] = { - "id": self.backend_docker_image_artifact.id, - "version": self.backend_docker_image_artifact.version, - "type": self.backend_docker_image_artifact.type, - } - base["frontend_docker_image_artifact"] = { - "id": self.frontend_docker_image_artifact.id, - "version": self.frontend_docker_image_artifact.version, - "type": self.frontend_docker_image_artifact.type, - } + server = {"id": self.server_docker_image_artifact.id, "version": self.server_docker_image_artifact.version, + "type": self.server_docker_image_artifact.type} + base["server_docker_image_artifact"] = server + base["backend_docker_image_artifact"] = server + if self.frontend_docker_image_artifact is not None: + base["frontend_docker_image_artifact"] = { + "id": self.frontend_docker_image_artifact.id, + "version": self.frontend_docker_image_artifact.version, + "type": self.frontend_docker_image_artifact.type, + } + base["health_path"] = self.health_path base["service_name"] = self.environment_name base["environment_name"] = self.environment_name # dual-write with service_name during the rename base["env_provider_type"] = self.env_provider_type @@ -89,41 +107,60 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, data: dict) -> WebsiteEnv: - backend_ref = data["backend_docker_image_artifact"] - backend_artifact = Artifact.get(backend_ref["id"], version=backend_ref["version"]) - frontend_ref = data["frontend_docker_image_artifact"] - frontend_artifact = Artifact.get(frontend_ref["id"], version=frontend_ref["version"]) + server_ref = data.get("server_docker_image_artifact") or data["backend_docker_image_artifact"] + frontend_ref = data.get("frontend_docker_image_artifact") return cls( id=data["id"], version=data.get("version"), - backend_docker_image_artifact=backend_artifact, - frontend_docker_image_artifact=frontend_artifact, + server_docker_image_artifact=Artifact.get(server_ref["id"], version=server_ref["version"]), + frontend_docker_image_artifact=(Artifact.get(frontend_ref["id"], version=frontend_ref["version"]) + if frontend_ref else None), environment_name=data["environment_name"] if "environment_name" in data else data["service_name"], + health_path=data.get("health_path", DEFAULT_HEALTH_PATH), metadata=data.get("metadata", {}), env_provider_type=data.get("env_provider_type", "gateway"), ) + @classmethod + def put(cls, **kwargs: Any) -> WebsiteEnv: + """Write the website env, warning when it names a separate frontend image: a site's server image serves its + pages, and a frontend image is deprecated, still deployed for the sites built on one.""" + if kwargs.get("frontend_docker_image_artifact") is not None: + logger.warning(f"website env {kwargs.get('id')!r} names a separate frontend image, which is deprecated: " + "build the site's pages into its server image instead") + return super().put(**kwargs) + @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.""" + """Write the env authored as ``data`` (its env.toml, with ``backend_image``, the server image, and any + ``frontend_image`` resolved to image artifacts' ids) under ``ctx.id`` and return it.""" fields = cls.accept_toml(data, ctx) + frontend = fields.get("frontend_image") 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"]) + server_docker_image_artifact=ctx.artifact(fields["backend_image"], DockerImageArtifact), + frontend_docker_image_artifact=ctx.artifact(frontend, DockerImageArtifact) if frontend else None, + environment_name=fields["environment_name"], + health_path=fields.get("health", DEFAULT_HEALTH_PATH), + env_provider_type=fields["env_provider_type"]) def to_toml(self, out: ExportContext) -> dict: - """The env.toml of a folder both of this env's images are built from, which ``from_toml`` reads back as it.""" - return {"type": self.type, "environment_name": self.environment_name, - "backend_image": out.image(self.backend_docker_image_artifact, "backend_image"), - "frontend_image": out.image(self.frontend_docker_image_artifact, "frontend_image"), - **self.provider_toml(out, self.env_provider_type)} + """The env.toml of a folder this env's images are built from, which ``from_toml`` reads back as it.""" + toml = {"type": self.type, "environment_name": self.environment_name, + "backend_image": out.image(self.server_docker_image_artifact, "backend_image")} + if self.frontend_docker_image_artifact is not None: + toml["frontend_image"] = out.image(self.frontend_docker_image_artifact, "frontend_image") + if self.health_path != DEFAULT_HEALTH_PATH: + toml["health"] = self.health_path + return {**toml, **self.provider_toml(out, self.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.""" + """The keys of ``data``, an env.toml, a website env takes, ``backend_image`` naming its server image, its + environment_name read from that image's @environment_card when it isn't set. Raises BundleError listing every + problem.""" + health = data.get("health", DEFAULT_HEALTH_PATH) + if isinstance(health, str) and (problem := health_path_problem(health)): + ctx.refuse([ctx.config_problem(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, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: @@ -137,6 +174,9 @@ async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gatew def deploy_refusal(self, *, sandbox_type: str | None = None, **options) -> str | None: """Why deploy() would refuse these options before building anything, or None; raises, as deploy() does, for a type this process can't find. ``sandbox_type`` is deploy()'s own, which no provider takes.""" + if self.frontend_docker_image_artifact is None: + return (f"website env '{self.id}' has no frontend image, and its server serving its own pages deploys " + "once the gateway serves a one-image site") return deploy_refusal(self, provider_or_class(self), options) @classmethod @@ -278,24 +318,25 @@ async def put_from_github( github_token: str | None = None, env_provider_type: str = "gateway", ) -> WebsiteEnv: - """Build backend and frontend Docker images from GitHub in parallel and create a WebsiteEnv.""" + """Build the server image, from ``backend_dockerfile_github_url``, and any deprecated frontend image from + GitHub in parallel, and create a WebsiteEnv.""" refuse_local_github_build(id) - backend, frontend = await asyncio.gather( - DockerImageArtifact.put_from_github( - id=derive_id(id, "backend_image"), - dockerfile_github_url=backend_dockerfile_github_url, - docker_context_github_url=backend_docker_context_github_url, - on_progress=on_backend_progress, - github_token=github_token, - ), - DockerImageArtifact.put_from_github( + builds = [DockerImageArtifact.put_from_github( + id=derive_id(id, "backend_image"), + dockerfile_github_url=backend_dockerfile_github_url, + docker_context_github_url=backend_docker_context_github_url, + on_progress=on_backend_progress, + github_token=github_token, + )] + if frontend_dockerfile_github_url: + builds.append(DockerImageArtifact.put_from_github( id=derive_id(id, "frontend_image"), dockerfile_github_url=frontend_dockerfile_github_url, docker_context_github_url=frontend_docker_context_github_url, on_progress=on_frontend_progress, github_token=github_token, - ), - ) + )) + backend, *frontend = await asyncio.gather(*builds) combined_metadata: dict[str, str] = {} user = os.getenv("USER") @@ -305,7 +346,7 @@ async def put_from_github( combined_metadata["agent_env_version"] = pkg_version("agentenv-framework") except Exception: pass - for prefix, build in [("backend", backend), ("frontend", frontend)]: + for prefix, build in [("backend", backend), *(("frontend", build) for build in frontend)]: combined_metadata[f"{prefix}_dockerfile_github_url"] = build.dockerfile_github_url combined_metadata[f"{prefix}_github_owner"] = build.github_owner combined_metadata[f"{prefix}_github_repo"] = build.github_repo @@ -319,10 +360,18 @@ async def put_from_github( env = cls.put( id=id, - backend_docker_image_artifact=backend.artifact, - frontend_docker_image_artifact=frontend.artifact, + server_docker_image_artifact=backend.artifact, + frontend_docker_image_artifact=frontend[0].artifact if frontend else None, environment_name=environment_name, metadata=combined_metadata, env_provider_type=env_provider_type, ) return env + + +def health_path_problem(health_path: str) -> str | None: + """Why ``health_path`` can't be where a site answers once it's usable, or None: it's a path of plain characters, + since the gateway writes it into a health check.""" + if _HEALTH_PATH.fullmatch(health_path): + return None + return f"health: {health_path!r} is not a path of letters, digits, '.', '_', '~', '-' and '/' starting with '/'" diff --git a/tst/unit/bundle/website_optional_frontend_test.py b/tst/unit/bundle/website_optional_frontend_test.py new file mode 100644 index 000000000..76672c325 --- /dev/null +++ b/tst/unit/bundle/website_optional_frontend_test.py @@ -0,0 +1,23 @@ +"""A website's frontend image is optional in a bundle: a folder with no Dockerfile for it builds none.""" + +from pathlib import Path +from types import SimpleNamespace + +from agent_env.bundle.resolve import _Resolver +from agent_env.entity.refs import EntityRef + + +def test_an_optional_image_key_with_no_dockerfile_builds_nothing_and_a_required_one_is_refused(tmp_path): + (tmp_path / "Dockerfile.backend").write_text("FROM scratch\n") + entry = SimpleNamespace(path=Path(tmp_path), id="site", kind=SimpleNamespace(store="env")) + resolver = _Resolver.__new__(_Resolver) + resolver.problems, resolver.images = [], [] + resolver._problem = lambda entry, problem: resolver.problems.append(problem) + resolver._implied = lambda image, owner, key, ref, references: resolver.images.append(image.dockerfile) + + for ref in (EntityRef.artifact("backend_image", artifact_type="docker_image"), + EntityRef.artifact("frontend_image", artifact_type="docker_image", optional=True)): + assert resolver._image(entry, {}, ref, []) + assert (resolver.images, resolver.problems) == (["Dockerfile.backend"], []) + assert resolver._image(entry, {}, EntityRef.artifact("frontend_image", artifact_type="docker_image"), []) + assert resolver.problems == ["frontend_image: there is no 'Dockerfile.frontend' in this folder to build"] diff --git a/tst/unit/env/envs/test_website_server_image.py b/tst/unit/env/envs/test_website_server_image.py new file mode 100644 index 000000000..303308358 --- /dev/null +++ b/tst/unit/env/envs/test_website_server_image.py @@ -0,0 +1,75 @@ +"""A website env is one server image serving the site's pages and API, checked at its health path; a site still built +on a separate frontend image keeps loading and deploying, and put warns that it's deprecated.""" + +import logging +from types import SimpleNamespace + +import pytest + +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.env import Env +from agent_env.env.envs.website import WebsiteEnv + + +def _image(id): + return get_artifact_store().put_document(DockerImageArtifact( + id=id, description="", image_name=f"registry.example/{id}@sha256:" + "a" * 64)) + + +def test_a_site_is_one_server_image_checked_at_its_health_path(local_stores): + env = WebsiteEnv.put(id="shop-site", server_docker_image_artifact=_image("shop-server"), environment_name="shop") + stored = Env.get("shop-site") + assert (stored.server_docker_image_artifact.id, stored.frontend_docker_image_artifact) == ("shop-server", None) + assert stored.health_path == "/api/health" + assert stored.backend_docker_image_artifact is stored.server_docker_image_artifact + doc = env.to_dict() + assert doc["server_docker_image_artifact"] == doc["backend_docker_image_artifact"] + assert "frontend_docker_image_artifact" not in doc + + +def test_a_site_declares_its_health_path_and_a_bad_one_is_refused(local_stores): + WebsiteEnv.put(id="slack-site", server_docker_image_artifact=_image("slack-server"), environment_name="slack", + health_path="/healthz") + assert Env.get("slack-site").health_path == "/healthz" + with pytest.raises(ValueError) as refused: + WebsiteEnv(id="bad", version=None, server_docker_image_artifact=_image("bad-server"), environment_name="bad", + health_path="healthz") + assert str(refused.value) == ("website env 'bad': health: 'healthz' is not a path of letters, digits, '.', '_', " + "'~', '-' and '/' starting with '/'") + + +def test_a_site_stored_before_the_server_image_reads_its_backend_image_as_it(local_stores): + backend, frontend = _image("old-backend"), _image("old-frontend") + old = {"type": "website", "id": "old-site", "version": 1, "environment_name": "old", "metadata": {}, + "backend_docker_image_artifact": {"id": backend.id, "version": backend.version}, + "frontend_docker_image_artifact": {"id": frontend.id, "version": frontend.version}} + env = WebsiteEnv.from_dict(old) + assert (env.server_docker_image_artifact.id, env.frontend_docker_image_artifact.id) == ("old-backend", "old-frontend") + with pytest.raises(ValueError, match="names its server image once"): + WebsiteEnv(id="x", version=None, server_docker_image_artifact=backend, backend_docker_image_artifact=backend, + environment_name="x") + + +def test_a_separate_frontend_image_is_deprecated_but_still_deploys(local_stores, caplog): + with caplog.at_level(logging.WARNING, logger="agent_env.env.envs.website"): + env = WebsiteEnv.put(id="shop-site", backend_docker_image_artifact=_image("shop-backend"), + frontend_docker_image_artifact=_image("shop-frontend"), environment_name="shop") + assert "separate frontend image, which is deprecated" in caplog.text + assert env.deploy_refusal() is None + one_image = WebsiteEnv(id="one", version=1, server_docker_image_artifact=_image("one-server"), environment_name="one") + assert "deploys once the gateway serves a one-image site" in one_image.deploy_refusal() + + +def test_an_env_toml_takes_a_health_path_and_refuses_a_bad_one(): + def refuse(problems): + raise ValueError(problems) + + ctx = SimpleNamespace(config_problem=lambda problem: f"env.toml: {problem}", refuse=refuse, + accept_env=lambda data, cls, named_by: data) + data = {"backend_image": "server", "environment_name": "slack", "health": "/healthz"} + assert WebsiteEnv.accept_toml(data, ctx) == data + with pytest.raises(ValueError, match="env.toml: health: 'healthz' is not a path"): + WebsiteEnv.accept_toml({**data, "health": "healthz"}, ctx) + with pytest.raises(ValueError, match="is not a path of letters"): + WebsiteEnv.accept_toml({**data, "health": '/ok" x'}, ctx) diff --git a/tst/unit/serialization/golden/env/website.json b/tst/unit/serialization/golden/env/website.json index c58fccd16..755b4f36a 100644 --- a/tst/unit/serialization/golden/env/website.json +++ b/tst/unit/serialization/golden/env/website.json @@ -11,10 +11,16 @@ "type": "docker_image", "version": 3 }, + "health_path": "/api/health", "id": "shop-site-env", "metadata": { "owner": "example-team" }, + "server_docker_image_artifact": { + "id": "shop-backend-image", + "type": "docker_image", + "version": 3 + }, "service_name": "shop", "type": "website", "version": 2 diff --git a/tst/unit/store/local_id_acceptance_test.py b/tst/unit/store/local_id_acceptance_test.py index 49b6e602d..361b6c220 100644 --- a/tst/unit/store/local_id_acceptance_test.py +++ b/tst/unit/store/local_id_acceptance_test.py @@ -304,7 +304,7 @@ def test_a_put_with_an_local_id_builds_and_writes_its_images_under_ids_derived_f saved = {d["id"]: d["image_name"] for d in _local().query("artifacts", Filter())} assert saved == {image: image_repository(image) for image in images} (owner,) = _local().query("a2a_agents" if module.endswith(".put") else "envs", Filter()) - assert sorted(ref["id"] for ref in owner.values() if isinstance(ref, dict) and "id" in ref) == images + assert sorted({ref["id"] for ref in owner.values() if isinstance(ref, dict) and "id" in ref}) == images assert not _documents().path.exists() or _documents().count("artifacts", Filter()) == 0