diff --git a/src/harbor_hypeman/environment.py b/src/harbor_hypeman/environment.py index e24243e..bae0c82 100644 --- a/src/harbor_hypeman/environment.py +++ b/src/harbor_hypeman/environment.py @@ -110,12 +110,16 @@ async def start(self, force_build: bool) -> None: raise RuntimeError("Hypeman environment is already started.") image = await self._prepare_image(force_build) + instance_name = self._instance_name() instance = await self._client.instances.create( image=image, - name=self._instance_name(), + name=instance_name, entrypoint=["/bin/sh", "-c"], cmd=["while true; do sleep 3600; done"], - env=self._startup_env(), + env={ + **self._startup_env(), + "HYPEMAN_INSTANCE_NAME": instance_name, + }, network={"enabled": self.network_policy.network_mode == NetworkMode.PUBLIC}, tags={ "harbor.managed": "true", @@ -183,11 +187,15 @@ async def _prepare_image(self, force_build: bool) -> str: ): if docker_image is None: raise RuntimeError("Prebuilt image selection requires docker_image.") - image = await self._client.images.create( - name=docker_image, - tags={_BUILD_TAG: self.environment_id}, - ) - return image.name + try: + await self._client.images.get(docker_image) + return docker_image + except NotFoundError: + image = await self._client.images.create( + name=docker_image, + tags={_BUILD_TAG: self.environment_id}, + ) + return image.name if not force_build: cached_image = await self._cached_build_image() diff --git a/tests/test_environment.py b/tests/test_environment.py index 1f018bc..65b9e29 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -8,13 +8,14 @@ from typing import Any, cast from unittest.mock import AsyncMock, MagicMock +import httpx import pytest from harbor.environments.base import SandboxBuildFailedError from harbor.environments.factory import EnvironmentFactory from harbor.models.task.config import EnvironmentConfig, NetworkMode, NetworkPolicy from harbor.models.trial.config import EnvironmentConfig as TrialEnvironmentConfig from harbor.models.trial.paths import TrialPaths -from hypeman import AsyncHypeman +from hypeman import AsyncHypeman, NotFoundError import harbor_hypeman.environment as environment_module from harbor_hypeman import HypemanEnvironment @@ -25,7 +26,10 @@ def _client() -> Any: client.images.create = AsyncMock( return_value=SimpleNamespace(name="docker.io/library/alpine:latest") ) - client.images.get = AsyncMock() + response = httpx.Response(404, request=httpx.Request("GET", "https://example.com")) + client.images.get = AsyncMock( + side_effect=NotFoundError("missing", response=response, body=None) + ) client.builds.list = AsyncMock(return_value=[]) client.builds.create = AsyncMock() client.builds.get = AsyncMock() @@ -131,8 +135,11 @@ async def test_start_prebuilt_image_maps_resources_and_network(tmp_path: Path) - assert create_kwargs["size"] == "2048MB" assert create_kwargs["overlay_size"] == "4096MB" assert create_kwargs["network"] == {"enabled": False} - assert create_kwargs["env"] == {"TASK_ENV": "value"} assert create_kwargs["name"].startswith("harbor-test-task-abc123-env-") + assert create_kwargs["env"] == { + "TASK_ENV": "value", + "HYPEMAN_INSTANCE_NAME": create_kwargs["name"], + } client.instances.wait.assert_awaited_once_with( "instance-1", state="Running", @@ -141,6 +148,26 @@ async def test_start_prebuilt_image_maps_resources_and_network(tmp_path: Path) - ) +async def test_start_uses_existing_prebuilt_image(tmp_path: Path) -> None: + client = _client() + client.images.get.side_effect = None + client.images.get.return_value = SimpleNamespace( + name="docker.io/builds/build-1:latest", + status="ready", + ) + environment = _environment( + tmp_path, + task_config=EnvironmentConfig(docker_image="builds/build-1"), + client=client, + ) + + await environment.start(force_build=False) + + client.images.get.assert_awaited_once_with("builds/build-1") + client.images.create.assert_not_awaited() + assert client.instances.create.await_args.kwargs["image"] == "builds/build-1" + + async def test_start_creates_configured_workdir( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -247,6 +274,7 @@ async def test_start_failure_deletes_created_instance(tmp_path: Path) -> None: async def test_start_reuses_ready_build(tmp_path: Path) -> None: client = _client() + client.images.get.side_effect = None client.builds.list.return_value = [ SimpleNamespace( status="ready",