diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index a4358d10e..f9edde853 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -80,8 +80,11 @@ the container before using it. ## Register it In a bundle, `env.toml` declares the env: copied to a bundle's `envs/notes/`, `agent-env bundle register ` -builds the image with that folder as its context. Its keys are `type`, `environment_name` and `backend_image`; domains -are not one of them, as they come from the card. Without a bundle: +builds the image with that folder as its context. Its keys are `type`, `environment_name` and `backend_image`, and it +may name `domains`, the names the site is reached by, such as `domains = ["notes.example"]`. These win over the +card's; without them a computer reaches the site by the names its card declares (`notes.test`), or as +`notes.localhost` when it declares none. Two sites of one multi env can't claim one name. Without a bundle (`--domain` +names the site, and can be repeated): ```bash agent-env env website put --id notes-site --server-dockerfile Dockerfile --server-docker-context . diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 9ec3aca68..f9e93ec6c 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -37,6 +37,7 @@ from agent_env.env.envs.computer.env import ComputerEnv from agent_env.env.envs.computer.app_env import ComputerAppEnv from agent_env.env.envs.computer.driver_env import ComputerDriverEnv +from agent_env.env.envs.computer.utils import sites 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 @@ -893,7 +894,7 @@ def _check_multi_names(self, closure: set[_Key], store_latest: dict[tuple[Entity """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.""" + apart. Nor may two of its websites claim one name.""" for key in sorted(closure, key=self.rank.__getitem__): source = self.nodes[key] if isinstance(source, BundleImage) or source.entry.kind is not BundleKind.ENV: @@ -903,6 +904,12 @@ def _check_multi_names(self, closure: set[_Key], store_latest: dict[tuple[Entity 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 + claimed = [sites.Claim(domain, sites.SYNTHETIC, ref.where) for ref in source.references + if ref.where.startswith("website_envs[") + for domain in dict.fromkeys(self._domains(ref, store_latest))] + if conflicts := sites.name_conflicts(claimed): + self.problems.append(f"{where}: website_envs: names claimed twice: {'; '.join(conflicts)}; each site of " + "a multi needs names of its own") for ref in source.references: name = self._environment_name(ref, store_latest) if name is None: @@ -1041,14 +1048,24 @@ def _environment_name(self, ref: Reference, store_latest: dict[tuple[EntityKind, return next((name for path, name in self._folder_environments(ref.local) or () if not path), None) if ref.local is not None: return self.accepted.get(_key(ref.local), {}).get("environment_name") + return getattr(self._store_entity(ref, store_latest), "environment_name", None) + + def _domains(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> list[str]: + """The names the website ``ref`` names stores: a bundle env's from its accepted toml, a store one's at the + version the plan read, or none when that's unknown.""" + if ref.local is not None: + return list(self.accepted.get(_key(ref.local), {}).get("domains") or []) + return list(getattr(self._store_entity(ref, store_latest), "domains", None) or []) + + def _store_entity(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> Any: + """The store's entity ``ref`` names at the version the plan read, or None when that's unknown.""" if store_latest is None: return None try: - entity = STORE_GETTERS[ref.kind](ref.id, ref.version if ref.version is not None - else store_latest.get((ref.kind, ref.id))) + return STORE_GETTERS[ref.kind](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(entity, "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.""" diff --git a/src/agent_env/cli/env/website.py b/src/agent_env/cli/env/website.py index 7332e3df9..4da68bf44 100644 --- a/src/agent_env/cli/env/website.py +++ b/src/agent_env/cli/env/website.py @@ -17,7 +17,7 @@ ) from agent_env.utils.card_naming import card_name_from_github, card_name_from_source from agent_env.utils.docker.build import DEFAULT_BUILD_PLATFORM, build_image -from agent_env.env.envs.website import WebsiteEnv, health_path_problem +from agent_env.env.envs.website import WebsiteEnv, domains_problem, health_path_problem from agent_env.providers.env_providers.constants import AGENT_ENV_WEBSITE_HEALTH_PATH from agent_env.store.ids import derive_id, image_repository @@ -49,6 +49,9 @@ def website(): @click.option("--frontend-docker-context-github-url", default=None, help="GitHub URL to frontend build context directory") @click.option("--health-path", default=AGENT_ENV_WEBSITE_HEALTH_PATH, show_default=True, help="The path that answers 200 once the site is usable") +@click.option("--domain", "domains", multiple=True, + help="A name the site is reached by, such as slack.com or *.slack.com (repeatable); without one, the " + "names its card declares") @environment_name_options @env_provider_type_option("What deploys the env: 'gateway' (a gateway in front of the website), or the type of an installed " "agent_env.env_providers plugin", "website") @@ -67,6 +70,7 @@ def put( frontend_dockerfile_github_url: str | None, frontend_docker_context_github_url: str | None, health_path: str, + domains: tuple[str, ...], environment_name: str | None, env_provider_type: str, metadata_pairs: tuple[str, ...], @@ -93,7 +97,7 @@ def put( click.echo("Error: --server-image-ref needs --environment-name, which a registry image's card can't give here", err=True) sys.exit(1) - if problem := health_path_problem(health_path): + if problem := health_path_problem(health_path) or domains_problem(list(domains)): click.echo(f"Error: {problem}", err=True) sys.exit(1) if frontend_dockerfile or frontend_dockerfile_github_url: @@ -107,7 +111,7 @@ def put( sys.exit(1) key, value = pair.split("=", 1) user_metadata[key] = value - site = {"health_path": health_path} + site = {"health_path": health_path, "domains": list(domains)} if use_github: if environment_name is None: diff --git a/src/agent_env/env/envs/computer/utils/sites.py b/src/agent_env/env/envs/computer/utils/sites.py index 39e6f8995..97c75ff55 100644 --- a/src/agent_env/env/envs/computer/utils/sites.py +++ b/src/agent_env/env/envs/computer/utils/sites.py @@ -32,8 +32,10 @@ from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID +from agent_env.env.env import Env, gateway_url_of from agent_env.env.envs.computer.utils.site_proxy import is_local, matches from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, VmSandbox +from agent_env.store.base import NotFoundError if TYPE_CHECKING: from agent_env.env.env import DeployedEnv @@ -148,6 +150,13 @@ def claims(sites: Iterable[Site], allow_hosts: Iterable[str] = ()) -> list[Claim return found + [Claim(host.lower(), ALLOWLIST, "network") for host in dict.fromkeys(allow_hosts)] +def name_conflicts(every: list[Claim]) -> list[str]: + """Each overlap of two claims of one rank by different owners, the later named first, as ``site_table`` refuses + them.""" + return [f"{claim.name!r} ({claim.owner}) overlaps {k.name!r} ({k.owner})" for i, claim in enumerate(every) + for k in every[:i] if k.rank == claim.rank and k.owner != claim.owner and overlap(k.name, claim.name)] + + def site_table(sites: list[Site], allow_hosts: Iterable[str] = ()) -> SiteTable: """The deployment's names: a synthetic site's routed, the rest left to the network but those a stronger claim covers, a stronger claim shadowing each weaker one that overlaps it. Raises SiteConflict naming every name two sites @@ -161,17 +170,15 @@ def site_table(sites: list[Site], allow_hosts: Iterable[str] = ()) -> SiteTable: if not re.fullmatch(r"[A-Za-z0-9_-]+", name)] if invalid: raise ValueError("; ".join(invalid)) - conflicts, kept, table = [], [], SiteTable() + if conflicts := name_conflicts(every): + raise SiteConflict("names claimed twice: " + "; ".join(conflicts)) + kept, table = [], SiteTable() for rank in RANKS: for claim in (c for c in every if c.rank == rank): - same = [k for k in kept if k.rank == rank and k.owner != claim.owner and overlap(k.name, claim.name)] - conflicts += [f"{claim.name!r} ({claim.owner}) overlaps {k.name!r} ({k.owner})" for k in same] stronger = [k for k in kept if k.rank != rank and overlap(k.name, claim.name)] if stronger := sorted(stronger, key=lambda k: not covers(k.name, claim.name))[:1]: table.shadowed.append((claim, stronger[0])) kept.append(claim) - if conflicts: - raise SiteConflict("names claimed twice: " + "; ".join(conflicts)) by_site = {site.name: site for site in sites} for owner in dict.fromkeys(c.owner for c in kept if c.rank == SYNTHETIC): table.routes.append(Route(owner, by_site[owner].url, @@ -405,19 +412,18 @@ async def route(sandbox: Sandbox, sites: list[Site], *, user: Optional[str], net async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[str]] = None) -> list[dict]: """The websites a run has deployed, as ``env_params.sites`` names them: each ``website/v1`` child env of a deployed env's stored card, with the route its composer serves it to a browser at (its params' ``browse_url``, joined onto - the env's address), and the names its params declare. A record whose card names no ``browse_url``, as - one a gateway made before it did, takes the route from its ``website_frontend_urls``. A site those name that the - stored card doesn't hold, every site of a record without a card or one whose card the gateway couldn't read when it - composed its own, is read live at ``/svc/``, and has no names when it serves no card (404 or no JSON). - Reading one fails otherwise, after ``CARD_ATTEMPTS`` tries, rather than route the site by names it didn't declare. + the env's address), and its names: those its env stores (a website env's ``domains``), else those its params + declare. A record whose card names no ``browse_url``, as one a gateway made before it did, takes the route from + its ``website_frontend_urls``. A site those name that the stored card doesn't hold, every site of a record without + a card or one whose card the gateway couldn't read when it composed its own, is read live at ``/svc/`` + unless its env stores its names, and has no names when it serves no card (404 or no JSON). Reading one fails + otherwise, after ``CARD_ATTEMPTS`` tries, rather than route the site by names it didn't declare. ``only`` keeps the websites of the envs it names, each of which must be deployed. Sites of different envs that share a name are each named for their env (``_env_qualified``), so they're routed apart, while two that claim one domain are still refused.""" from agentenv_protocol import client as protocol_v1 from agentenv_protocol.website import WEBSITE_BROWSE_URL_PARAM, WEBSITE_EXTENSION_URI - from agent_env.env.env import gateway_url_of - records = list(deployed_envs) if only is not None: missing = [env_id for env_id in only if env_id not in {d.env_id for d in deployed_envs}] @@ -429,10 +435,11 @@ async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[s for record in records: address = gateway_url_of(record) or getattr(record, "environment_url", None) frontends = getattr(record, "website_frontend_urls", None) or {} + children = [child for child in protocol_v1.children(getattr(record, "environment_card", None) or {}) + if child.find_extension(WEBSITE_EXTENSION_URI) is not None and child.name] + stored = _stored_domains(record) if children or frontends else {} named = set() - for child in protocol_v1.children(getattr(record, "environment_card", None) or {}): - if child.find_extension(WEBSITE_EXTENSION_URI) is None or not child.name: - continue + for child in children: params = protocol_v1.extension_params(child.card, WEBSITE_EXTENSION_URI) browse = params.get(WEBSITE_BROWSE_URL_PARAM) service = _served_as(child) @@ -443,11 +450,13 @@ async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[s "isn't routed", child.name, record.env_id) continue named.add(service) - found.append((record.env_id, _found(child.name, url, params))) + found.append((record.env_id, _found(child.name, url, params, stored.get(service)))) for name, url in frontends.items(): if name not in named: - card = await _site_card(protocol_v1, f"{gateway_url_of(record)}/svc/{name}", name) - found.append((record.env_id, _found(name, url, protocol_v1.extension_params(card, WEBSITE_EXTENSION_URI)))) + card = {} if name in stored else await _site_card(protocol_v1, f"{gateway_url_of(record)}/svc/{name}", + name) + params = protocol_v1.extension_params(card, WEBSITE_EXTENSION_URI) + found.append((record.env_id, _found(name, url, params, stored.get(name)))) return _env_qualified(found) @@ -468,8 +477,19 @@ def _served_as(child) -> str: return parts[1] if len(parts) > 1 and parts[0] == "svc" and parts[1] else child.name -def _found(name: str, url: str, params: dict) -> dict: - return {"name": name, "url": url, "domains": list(params.get("domains") or [])} +def _stored_domains(record: DeployedEnv) -> dict[str, list[str]]: + """The names each website of the env ``record`` deployed stores, by its environment_name: a multi env's websites', + or a website env's own. None for an env the store doesn't hold.""" + try: + env = Env.get(record.env_id, getattr(record, "env_version", None)) + except NotFoundError: + return {} + websites = getattr(env, "website_envs", None) or [env] + return {site.environment_name: list(site.domains) for site in websites if getattr(site, "domains", None)} + + +def _found(name: str, url: str, params: dict, stored: Optional[list[str]] = None) -> dict: + return {"name": name, "url": url, "domains": list(stored or params.get("domains") or [])} def _joined(address: str, url: str) -> str: diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index e193771d2..b962101f4 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -41,6 +41,7 @@ def _snapshot_after_load_default() -> bool: as_builtin, builtin_provider_for, close_deployed, close_replaced, deploy_refusal, deploy_through_provider, host_staging_refusal, plugin_provider_like_a_builtin, provider_or_class, ) +from agent_env.env.envs.computer.utils import sites from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.website import WebsiteEnv from agent_env.env.envs.service_db import DATABASE_SERVICE_NAME @@ -86,6 +87,10 @@ def __init__(self, id: str, version: Optional[int], mcp_server_envs: list[MCPSer self.env_provider_type = env_provider_type # the children's own types are ignored: this one deploys them all self.mcp_server_envs = mcp_server_envs self.website_envs = website_envs or [] + if conflicts := sites.name_conflicts([sites.Claim(name, sites.SYNTHETIC, f"{env.environment_name} at website_envs[{i}]") + for i, env in enumerate(self.website_envs) + for name in dict.fromkeys(env.domains)]): + raise sites.SiteConflict(f"multi env {id!r}: its websites claim names twice: {'; '.join(conflicts)}") self._sandbox = None self._gateway_url = None self._gateway_mode = GatewayMode.PERFORMANCE diff --git a/src/agent_env/env/envs/website.py b/src/agent_env/env/envs/website.py index 64d4b2d5c..474a2a9a6 100644 --- a/src/agent_env/env/envs/website.py +++ b/src/agent_env/env/envs/website.py @@ -22,6 +22,7 @@ from agent_env.artifact.artifacts.docker_image import 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.env.envs.computer.utils.sites import name_problem from agent_env.store.ids import derive_id from agent_env.env import legacy_protocol from agent_env.env.envs._deployment import ( @@ -51,7 +52,7 @@ class WebsiteEnv(Env): 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, - "health": str, "env_provider_type": str} + "health": str, "domains": list, "env_provider_type": str} def __init__( self, @@ -63,6 +64,7 @@ def __init__( *, backend_docker_image_artifact: Optional[DockerImageArtifact] = None, health_path: str = DEFAULT_HEALTH_PATH, + domains: Optional[list[str]] = None, metadata: Optional[dict[str, str]] = None, env_provider_type: str = "gateway", ): @@ -79,6 +81,9 @@ def __init__( if problem := health_path_problem(health_path): raise ValueError(f"website env {id!r}: {problem}") self.health_path = health_path + if problem := domains_problem(domains or []): + raise ValueError(f"website env {id!r}: {problem}") + self.domains = list(domains or []) self.environment_name = environment_name self.env_provider_type = env_provider_type self._sandbox = None @@ -106,6 +111,8 @@ def to_dict(self) -> dict: "type": self.frontend_docker_image_artifact.type, } base["health_path"] = self.health_path + if self.domains: + base["domains"] = list(self.domains) 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 @@ -123,6 +130,7 @@ def from_dict(cls, data: dict) -> WebsiteEnv: 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), + domains=data.get("domains") or [], metadata=data.get("metadata", {}), env_provider_type=data.get("env_provider_type", "gateway"), ) @@ -147,6 +155,7 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> WebsiteEnv: 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), + domains=fields.get("domains") or [], env_provider_type=fields["env_provider_type"]) def to_toml(self, out: ExportContext) -> dict: @@ -157,6 +166,8 @@ def to_toml(self, out: ExportContext) -> dict: toml["frontend_image"] = out.image(self.frontend_docker_image_artifact, "frontend_image") if self.health_path != DEFAULT_HEALTH_PATH: toml["health"] = self.health_path + if self.domains: + toml["domains"] = list(self.domains) return {**toml, **self.provider_toml(out, self.env_provider_type)} @classmethod @@ -167,6 +178,9 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: health = data.get("health", DEFAULT_HEALTH_PATH) if isinstance(health, str) and (problem := health_path_problem(health)): ctx.refuse([ctx.config_problem(problem)]) + domains = data.get("domains", []) + if isinstance(domains, list) and (problem := domains_problem(domains)): + 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: @@ -335,6 +349,7 @@ async def put_from_github( github_token: str | None = None, env_provider_type: str = "gateway", health_path: str = DEFAULT_HEALTH_PATH, + domains: list[str] | None = None, ) -> WebsiteEnv: """Build the server image, from ``backend_dockerfile_github_url``, and any deprecated frontend image from GitHub in parallel, and create a WebsiteEnv.""" @@ -384,6 +399,7 @@ async def put_from_github( metadata=combined_metadata, env_provider_type=env_provider_type, health_path=health_path, + domains=domains, ) return env @@ -394,3 +410,10 @@ def health_path_problem(health_path: str) -> str | None: if _HEALTH_PATH.fullmatch(health_path): return None return f"health: {health_path!r} is not a path of letters, digits, '.', '_', '~', '-' and '/' starting with '/'" + + +def domains_problem(domains: list[str]) -> str | None: + """Why ``domains`` can't be the names a site is reached by, or None: each a DNS name, as a computer's site table + takes it, a wildcard only as its whole first label.""" + problems = [name_problem(name) if isinstance(name, str) else f"{name!r} is not a host name" for name in domains] + return next((f"domains: {problem}" for problem in problems if problem), None) diff --git a/tst/unit/bundle/env_toml_test.py b/tst/unit/bundle/env_toml_test.py index 236be7b99..0dca18dcc 100644 --- a/tst/unit/bundle/env_toml_test.py +++ b/tst/unit/bundle/env_toml_test.py @@ -210,6 +210,8 @@ def _website(name, toml=""): "pgweb, servicedb); 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"), + (_website("x", 'domains = ["Slack.com"]\n'), + "envs/x: env.toml: domains: 'Slack.com' must be lowercase, with no trailing dot"), ({**_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'}, @@ -225,6 +227,10 @@ def _website(name, toml=""): "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"), + ({**_website("a", 'domains = ["shop.test"]\n'), **_website("b", 'domains = ["shop.test", "b.test"]\n'), + "envs/x/env.toml": 'type = "multi"\nwebsite_envs = ["a", "b"]\n'}, + "envs/x: website_envs: names claimed twice: 'shop.test' (website_envs[1]) overlaps 'shop.test' (website_envs[0]); " + "each site of a multi needs names of 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"), @@ -233,8 +239,8 @@ def _website(name, toml=""): "(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", "name-escapes-its-line", "name-with-a-space", "reserved-name", - "website-on-server", "build-args", "multi-without-envs", "multi-name", "multi-wrong-child", "multi-same-names", - "multi-same-container", + "website-on-server", "website-bad-domain", "build-args", "multi-without-envs", "multi-name", "multi-wrong-child", "multi-same-names", + "multi-same-container", "multi-same-domain", "gateway", "service-db"]) def test_an_env_toml_that_cant_be_written_is_refused_before_any_write(bundle_dir, files, problem): _store_image("base") diff --git a/tst/unit/bundle/to_toml_round_trip_test.py b/tst/unit/bundle/to_toml_round_trip_test.py index b48ed45ea..255aa451c 100644 --- a/tst/unit/bundle/to_toml_round_trip_test.py +++ b/tst/unit/bundle/to_toml_round_trip_test.py @@ -100,10 +100,10 @@ def test_a_website_env_reads_back_with_both_images_built_from_its_one_folder(hom backend = context_image(home, "shop-backend", files, "Dockerfile.backend") frontend = context_image(home, "shop-frontend", files, "Dockerfile.frontend") WebsiteEnv.put(id="shop", backend_docker_image_artifact=backend, frontend_docker_image_artifact=frontend, - environment_name="shop") + environment_name="shop", domains=["shop.test"]) with _read_back(home, [{"id": "env", "type": "deploy_env", "env_id": "shop"}], "env", "shop") as (read, _): - assert (type(read), read.environment_name) == (WebsiteEnv, "shop") + assert (type(read), read.environment_name, read.domains) == (WebsiteEnv, "shop", ["shop.test"]) assert (read.backend_docker_image_artifact.dockerfile_path, read.frontend_docker_image_artifact.dockerfile_path) == ("Dockerfile.backend", "Dockerfile.frontend") assert set(_context(read.frontend_docker_image_artifact)) >= {"site/index.html", "Dockerfile.frontend"} diff --git a/tst/unit/cli/website_server_image_cli_test.py b/tst/unit/cli/website_server_image_cli_test.py index 1a04d38c9..5d6cefd3e 100644 --- a/tst/unit/cli/website_server_image_cli_test.py +++ b/tst/unit/cli/website_server_image_cli_test.py @@ -40,10 +40,13 @@ def test_a_site_is_built_from_its_server_dockerfile_alone_with_its_build_context def test_a_site_registers_a_server_image_already_in_a_registry(local_stores): with patch("agent_env.cli.env.website.DockerImageArtifact.put_ref", side_effect=lambda id, **k: _stored(id)) as put_ref: - result = _put("--server-image-ref", "ghcr.io/acme/mail:3", "--environment-name", "mail") + result = _put("--server-image-ref", "ghcr.io/acme/mail:3", "--environment-name", "mail", + "--domain", "mail.example", "--domain", "*.mail.example") assert result.exit_code == 0, result.output assert put_ref.call_args.kwargs["image_name"] == "ghcr.io/acme/mail:3" - assert Env.get("mail-site").server_docker_image_artifact.id == "mail-site__backend_image" + env = Env.get("mail-site") + assert (env.server_docker_image_artifact.id, env.domains) == ("mail-site__backend_image", + ["mail.example", "*.mail.example"]) def test_put_refuses_no_server_image_two_of_them_an_unnamed_registry_image_and_a_bad_health_path(local_stores, tmp_path): @@ -54,7 +57,10 @@ def test_put_refuses_no_server_image_two_of_them_an_unnamed_registry_image_and_a assert "needs --environment-name" in _put("--server-image-ref", "ghcr.io/acme/mail:3").output with patch("agent_env.cli.env.website.DockerImageArtifact.put_ref", side_effect=lambda id, **k: _stored(id)) as put_ref: refused = _put("--server-image-ref", "ghcr.io/acme/mail:3", "--environment-name", "mail", "--health-path", "ok") + bad_domain = _put("--server-image-ref", "ghcr.io/acme/mail:3", "--environment-name", "mail", + "--domain", "Mail.example") assert refused.exit_code == 1 and "health: 'ok' is not a path" in refused.output + assert bad_domain.exit_code == 1 and "domains: 'Mail.example' must be lowercase" in bad_domain.output put_ref.assert_not_called() diff --git a/tst/unit/env/envs/test_website_site_names.py b/tst/unit/env/envs/test_website_site_names.py new file mode 100644 index 000000000..c13c6ca9b --- /dev/null +++ b/tst/unit/env/envs/test_website_site_names.py @@ -0,0 +1,80 @@ +"""A website env stores the names its env.toml gives it, a multi env refuses two of its sites claiming one name, and +a computer's site table takes the names a site stores before those its card declares.""" + +import pytest +from agentenv_protocol import client as protocol_v1 + +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.env import DeployedGatewayEnv +from agent_env.env.envs import MultiEnv, WebsiteEnv +from agent_env.env.envs.computer.utils import sites +from agent_env.env.envs.computer.utils.params import Site + + +def _image(id): + return get_artifact_store().put_document(DockerImageArtifact( + id=id, description="", image_name=f"registry.example/{id}@sha256:" + "a" * 64)) + + +def _site(name, *domains): + return WebsiteEnv.put(id=name, server_docker_image_artifact=_image(f"{name}-server"), environment_name=name, + domains=list(domains)) + + +def _card_child(name, *domains): + extension = {"uri": "urn:agentenv:website/v1", "params": {"domains": list(domains), "browse_url": f"/website/{name}/"}} + return {"name": name, "url": f"/svc/{name}/agentenv", "capabilities": {"extensions": [extension]}} + + +def test_a_site_stores_its_names_and_one_that_cant_be_a_name_is_refused(local_stores): + _site("slack", "slack.com", "*.slack.com") + + assert Env.get("slack").domains == ["slack.com", "*.slack.com"] + assert "domains" not in _site("notes").to_dict() + with pytest.raises(ValueError) as refused: + WebsiteEnv(id="bad", version=None, server_docker_image_artifact=_image("bad-server"), environment_name="bad", + domains=["Slack.com"]) + assert str(refused.value) == "website env 'bad': domains: 'Slack.com' must be lowercase, with no trailing dot" + + +def test_a_multi_env_whose_sites_claim_one_name_is_refused(local_stores): + slack, notes = _site("slack", "slack.com", "*.slack.com"), _site("notes") + + with pytest.raises(sites.SiteConflict) as refused: + MultiEnv.put(id="suite", mcp_server_envs=[], website_envs=[slack, notes, _site("chat", "acme.slack.com")]) + assert str(refused.value) == ("multi env 'suite': its websites claim names twice: 'acme.slack.com' (chat at " + "website_envs[2]) overlaps '*.slack.com' (slack at website_envs[0])") + assert MultiEnv.put(id="suite", mcp_server_envs=[], + website_envs=[slack, notes, _site("forum", "forum.example")]).version == 1 + + +def test_two_versions_of_one_website_env_claiming_one_name_are_refused(local_stores): + shop = _site("shop", "shop.test") + renamed = WebsiteEnv.put(id="shop", server_docker_image_artifact=_image("shop-server"), environment_name="chat", + domains=["shop.test"]) + + with pytest.raises(sites.SiteConflict, match=r"'shop.test' \(chat at website_envs\[1\]\) overlaps 'shop.test' " + r"\(shop at website_envs\[0\]\)"): + MultiEnv.put(id="suite", mcp_server_envs=[], website_envs=[shop, renamed]) + + +@pytest.mark.asyncio +async def test_a_computers_site_table_takes_the_names_a_site_stores_before_its_cards(local_stores, monkeypatch): + async def get_card(url, timeout=10, verify=True): + raise AssertionError(f"read {url} live") + + monkeypatch.setattr(protocol_v1, "get_card", get_card) + suite = MultiEnv.put(id="suite", mcp_server_envs=[], + website_envs=[_site("shop", "shop.test"), _site("notes"), _site("forum", "forum.test")]) + card = {"name": "suite", "children_environments": [_card_child("shop", "shop.example"), _card_child("notes")]} + record = DeployedGatewayEnv(env_id="suite", env_version=suite.version, sandbox_id="s", + gateway_url="https://gw.example", environment_card=card, + website_frontend_urls={"forum": "https://gw.example/website/forum/"}) + + found = await sites.deployed_sites([record]) + + table = sites.site_table([Site(**site) for site in found]) + assert {route.site: route.domains for route in table.routes} == { + "shop": ("shop.test",), "notes": ("notes.localhost",), "forum": ("forum.test",)}