diff --git a/docs/deploy.md b/docs/deploy.md index ed4412c..7ca5832 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -41,8 +41,9 @@ to keep the credential-holding control plane off other interfaces. ## Choose a provider -Three remote providers are supported and verified end to end: `exe` -(exe.dev), `aws` (EC2), and `hetzner` (Hetzner Cloud). A fourth, `docker`, +Four remote providers are supported and verified end to end: `exe` +(exe.dev), `aws` (EC2), `hetzner` (Hetzner Cloud), and `exoscale` +(Exoscale). A fifth, `docker`, runs sandboxes as local containers and needs no external account — see [Local sandboxes with Docker](#local-sandboxes-with-docker). `DEFAULT_HOST_PROVIDER` selects which one serves `POST /hosts` (default `exe`). Set the matching @@ -200,6 +201,20 @@ A fresh Hetzner server has no firewall — port 22 is open and SSH is key-only. Drukbox mints a per-VM ed25519 key in both networking modes; there is no security-group or ingress-CIDR configuration to manage. +Exoscale provider: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `EXOSCALE_API_KEY` | — (required) | Exoscale API key ID. | +| `EXOSCALE_API_SECRET` | — (required) | Exoscale API secret used to sign requests. | +| `EXOSCALE_ZONE` | — (required) | Zone for launches, e.g. `ch-gva-2`, `de-fra-1`. | +| `EXOSCALE_DEFAULT_IMAGE` | `Linux Ubuntu 24.04 LTS 64-bit` | Template used when the caller omits `image`. | +| `EXOSCALE_INSTANCE_TYPE` | `standard.medium` | Instance type when the caller omits `instance_type`. | +| `EXOSCALE_DISK_GB` | `50` | Root disk size in GB when the caller omits `disk_gb`. | +| `EXOSCALE_API_TIMEOUT` | `30.0` | Timeout for Exoscale API calls. | +| `EXOSCALE_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `120.0` | ssh-keyscan retry budget for a fresh instance. | +| `EXOSCALE_SSH_USERNAME` | `ubuntu` | In-VM user callers SSH as. Exoscale Ubuntu templates default to `ubuntu`. | + Docker provider: | Variable | Default | Purpose | diff --git a/src/providers/__init__.py b/src/providers/__init__.py index aee7d27..72d8fd2 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -1,4 +1,5 @@ import providers.aws import providers.docker import providers.exe +import providers.exoscale import providers.hetzner # noqa: F401 diff --git a/src/providers/exoscale/__init__.py b/src/providers/exoscale/__init__.py new file mode 100644 index 0000000..245144d --- /dev/null +++ b/src/providers/exoscale/__init__.py @@ -0,0 +1,4 @@ +from providers.exoscale.provider import ExoscaleProvider +from providers.registry import register_vm_provider + +register_vm_provider(ExoscaleProvider) diff --git a/src/providers/exoscale/api.py b/src/providers/exoscale/api.py new file mode 100644 index 0000000..6b04507 --- /dev/null +++ b/src/providers/exoscale/api.py @@ -0,0 +1,238 @@ +import asyncio +import base64 +import contextlib +import hashlib +import hmac +import time +from collections.abc import Generator +from typing import Any, Self +from urllib.parse import parse_qs + +import httpx + +from .exceptions import ExoscaleTransportError, ExoscaleVMNotFoundError +from .settings import ExoscaleSettings + +_RUN_TO_IP_TIMEOUT_SECONDS = 300 +_RUN_TO_IP_POLL_SECONDS = 3.0 +_SIGNATURE_TTL_SECONDS = 600 + + +class ExoscaleAuth(httpx.Auth): + def __init__(self, api_key: str, api_secret: str) -> None: + self.api_key = api_key + self.api_secret = api_secret.encode("utf-8") + + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + expires = int(time.time()) + _SIGNATURE_TTL_SECONDS + auth_header = f"EXO2-HMAC-SHA256 credential={self.api_key}" + message_parts = [ + f"{request.method} {request.url.path}".encode(), + _request_body(request), + ] + + params = parse_qs(_query_string(request)) + signed_params = sorted(params) + param_values = [] + for param in signed_params: + if len(params[param]) != 1: + continue + param_values.append(params[param][0]) + message_parts.append("".join(param_values).encode("utf-8")) + if signed_params: + auth_header += f",signed-query-args={';'.join(signed_params)}" + + message_parts.append(b"") + message_parts.append(str(expires).encode("utf-8")) + auth_header += f",expires={expires}" + + signature = hmac.HMAC( + self.api_secret, + msg=b"\n".join(message_parts), + digestmod=hashlib.sha256, + ).digest() + auth_header += f",signature={base64.standard_b64encode(signature).decode('utf-8')}" + request.headers["Authorization"] = auth_header + yield request + + +class ExoscaleAPI: + def __init__( + self, + *, + api_key: str, + api_secret: str, + zone: str, + default_image: str, + instance_type: str, + disk_gb: int, + timeout: float = 30.0, + connect_timeout: float = 5.0, + ) -> None: + self.api_key = api_key + self.api_secret = api_secret + self.zone = zone + self.default_image = default_image + self.instance_type = instance_type + self.disk_gb = disk_gb + self.base_url = f"https://api-{zone}.exoscale.com/v2" + self.timeout = httpx.Timeout(timeout, connect=connect_timeout) + self._client: httpx.AsyncClient | None = None + + @classmethod + def from_settings(cls, settings: ExoscaleSettings) -> Self: + return cls( + api_key=settings.api_key, + api_secret=settings.api_secret, + zone=settings.zone, + default_image=settings.default_image, + instance_type=settings.instance_type, + disk_gb=settings.disk_gb, + timeout=settings.api_timeout, + ) + + async def ensure_ssh_key(self, *, name: str, public_key: str, labels: dict[str, str]) -> None: + try: + await self._request("GET", f"/ssh-key/{name}") + except ExoscaleVMNotFoundError: + await self._request( + "POST", + "/ssh-key", + json={"name": name, "public-key": public_key}, + ) + + async def delete_ssh_key(self, name: str) -> None: + try: + await self._request("DELETE", f"/ssh-key/{name}") + except ExoscaleVMNotFoundError: + return + + async def create_instance( + self, + *, + name: str, + image: str, + ssh_key_name: str, + user_data: str, + labels: dict[str, str], + instance_type: str | None = None, + disk_gb: int | None = None, + zone: str | None = None, + ) -> str: + body: dict[str, Any] = { + "name": name, + "template": {"name": image}, + "instance-type": {"name": instance_type or self.instance_type}, + "zone": zone or self.zone, + "disk-size": disk_gb or self.disk_gb, + "ssh-key": {"name": ssh_key_name}, + "labels": labels, + } + if user_data: + body["user-data"] = base64.standard_b64encode(user_data.encode("utf-8")).decode("utf-8") + response = await self._request("POST", "/instance", json=body) + return str(response["reference"]["id"]) + + async def wait_for_running_with_ip(self, instance_id: str) -> str: + deadline = time.monotonic() + _RUN_TO_IP_TIMEOUT_SECONDS + while True: + instance = await self._request("GET", f"/instance/{instance_id}") + if instance["state"] == "running": + ip = None + with contextlib.suppress(KeyError, TypeError): + ip = instance["public-ip"] + if ip: + return ip + if time.monotonic() >= deadline: + raise ExoscaleTransportError( + f"instance {instance_id} did not reach running with a public IP " + f"within {_RUN_TO_IP_TIMEOUT_SECONDS}s", + ) + await asyncio.sleep(_RUN_TO_IP_POLL_SECONDS) + + async def find_instance_id_by_name(self, name: str) -> str | None: + response = await self._request("GET", "/instance") + for instance in response["instances"]: + if instance.get("name") == name: + return str(instance["id"]) + return None + + async def delete_instance(self, instance_id: str) -> None: + try: + await self._request("DELETE", f"/instance/{instance_id}") + except ExoscaleVMNotFoundError: + return + + async def list_instances_count(self) -> int: + response = await self._request("GET", "/instance") + return len(response["instances"]) + + async def aclose(self) -> None: + if not self._client: + return + await self._client.aclose() + self._client = None + + async def _request( + self, + method: str, + path: str, + *, + params: dict[str, str] | None = None, + json: dict[str, Any] | None = None, + ) -> dict[str, Any]: + try: + response = await self._get_client().request(method, path, params=params, json=json) + except httpx.RequestError as exc: + raise ExoscaleTransportError(f"Exoscale API transport failed: {exc}") from exc + + if response.status_code == 404: + raise ExoscaleVMNotFoundError(_error_message(response)) + + if response.status_code >= 400: + raise ExoscaleTransportError( + f"Exoscale API request failed with status {response.status_code}: " + f"{_error_message(response)}" + ) + + if not response.content: + return {} + try: + return response.json() + except ValueError as exc: + raise ExoscaleTransportError("Exoscale API returned non-JSON output") from exc + + def _get_client(self) -> httpx.AsyncClient: + if not self._client: + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=self.timeout, + auth=ExoscaleAuth(self.api_key, self.api_secret), + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + return self._client + + +def _request_body(request: httpx.Request) -> bytes: + try: + return request.content + except httpx.RequestNotRead: + return request.read() + + +def _query_string(request: httpx.Request) -> str: + query = request.url.query + if isinstance(query, bytes): + return query.decode("utf-8") + return query + + +def _error_message(response: httpx.Response) -> str: + try: + error = response.json()["message"] + return str(error) + except (ValueError, KeyError, TypeError): + return response.text.strip() or f"status {response.status_code}" diff --git a/src/providers/exoscale/exceptions.py b/src/providers/exoscale/exceptions.py new file mode 100644 index 0000000..90a4727 --- /dev/null +++ b/src/providers/exoscale/exceptions.py @@ -0,0 +1,10 @@ +class ExoscaleProviderError(RuntimeError): + """Base error for the Exoscale provider.""" + + +class ExoscaleVMNotFoundError(ExoscaleProviderError): + """No instance matched the lookup.""" + + +class ExoscaleTransportError(ExoscaleProviderError): + """Talking to the Exoscale API failed for transport reasons.""" diff --git a/src/providers/exoscale/provider.py b/src/providers/exoscale/provider.py new file mode 100644 index 0000000..bb42055 --- /dev/null +++ b/src/providers/exoscale/provider.py @@ -0,0 +1,118 @@ +from typing import ClassVar, Self + +from core.settings import get_settings +from providers.base import VMCreateResult, VMProvider +from providers.exceptions import ProviderNotFoundError, ProviderTransportError +from providers.setup_script import inject_env_exports +from providers.ssh_keys import generate_ed25519_keypair + +from .api import ExoscaleAPI +from .exceptions import ExoscaleProviderError +from .settings import ExoscaleSettings + + +class ExoscaleProvider(VMProvider): + name: ClassVar[str] = "exoscale" + diagnose_hint: ClassVar[str] = "check_exoscale_api_credentials_and_zone" + supports_instance_type = True + supports_disk_gb = True + + def __init__( + self, + api: ExoscaleAPI, + settings: ExoscaleSettings, + *, + service_label: str = "drukbox", + ) -> None: + self.api = api + self.settings = settings + self._service_label = service_label + + @classmethod + def from_settings(cls) -> Self: + core = get_settings() + exoscale_settings = ExoscaleSettings() # pyright: ignore[reportCallIssue] + return cls( + ExoscaleAPI.from_settings(exoscale_settings), + exoscale_settings, + service_label=core.service_label, + ) + + @property + def default_image(self) -> str: + return self.settings.default_image + + @property + def bootstrap_ssh_timeout_seconds(self) -> float: + return self.settings.bootstrap_ssh_timeout_seconds + + async def create_vm( + self, + *, + name: str, + image: str, + env: dict[str, str] | None = None, + setup_script: str | None = None, + instance_type: str | None = None, + disk_gb: int | None = None, + ) -> VMCreateResult: + key_name = f"drukbox-{name}" + private_key, public_key = generate_ed25519_keypair() + labels = {"managed-by": self._service_label, "drukbox-host-name": name} + try: + await self.api.ensure_ssh_key(name=key_name, public_key=public_key, labels=labels) + except ExoscaleProviderError as exc: + raise ProviderTransportError(str(exc)) from exc + + user_data = inject_env_exports(setup_script or "", env) + try: + instance_id = await self.api.create_instance( + name=name, + image=image, + instance_type=instance_type, + disk_gb=disk_gb or self.settings.disk_gb, + ssh_key_name=key_name, + user_data=user_data, + labels=labels, + zone=self.settings.zone, + ) + except ExoscaleProviderError as exc: + await self.api.delete_ssh_key(key_name) + raise ProviderTransportError(str(exc)) from exc + + try: + ssh_host = await self.api.wait_for_running_with_ip(instance_id) + except ExoscaleProviderError as exc: + raise ProviderTransportError(str(exc)) from exc + + return VMCreateResult( + provider_id=instance_id, + name=name, + ssh_port=22, + ssh_host=ssh_host, + ssh_username=self.settings.ssh_username, + private_key=private_key, + ) + + async def delete_vm(self, name: str) -> None: + # Delete the key first and unconditionally: delete_ssh_key is idempotent + # (a missing key is a no-op) and deleting it doesn't affect a running + # instance, so the key can't be stranded behind the instance-not-found + # short-circuit when an earlier teardown removed the instance but not the + # key. delete_instance treats a 404 as success, so a None lookup is the + # only not-found signal. + try: + await self.api.delete_ssh_key(f"drukbox-{name}") + instance_id = await self.api.find_instance_id_by_name(name) + if instance_id is None: + raise ProviderNotFoundError(f"exoscale VM '{name}' was not found") + await self.api.delete_instance(instance_id) + except ExoscaleProviderError as exc: + raise ProviderTransportError(str(exc)) from exc + + async def aclose(self) -> None: + await self.api.aclose() + + async def diagnose(self) -> str: + count = await self.api.list_instances_count() + return f"zone={self.settings.zone} instances={count}" diff --git a/src/providers/exoscale/settings.py b/src/providers/exoscale/settings.py new file mode 100644 index 0000000..353b2c5 --- /dev/null +++ b/src/providers/exoscale/settings.py @@ -0,0 +1,47 @@ +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class ExoscaleSettings(BaseSettings): + """Exoscale provider configuration.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_prefix="EXOSCALE_", + extra="ignore", + ) + + api_key: str = Field( + description="Exoscale API key ID.", + ) + api_secret: str = Field( + description="Exoscale API secret used to sign requests.", + ) + zone: str = Field( + description="Exoscale zone for launches, e.g. ch-gva-2, de-fra-1.", + ) + default_image: str = Field( + default="Linux Ubuntu 24.04 LTS 64-bit", + description="Template used when the caller doesn't pass one.", + ) + instance_type: str = Field( + default="standard.medium", + description="Exoscale instance type when the caller doesn't pass one.", + ) + disk_gb: int = Field( + default=50, + description="Root disk size in GB when the caller doesn't pass one.", + ) + api_timeout: float = Field( + default=30.0, + description="Timeout in seconds for Exoscale API calls.", + ) + bootstrap_ssh_timeout_seconds: float = Field( + default=120.0, + description="ssh-keyscan retry budget for a freshly-launched Exoscale instance.", + ) + ssh_username: str = Field( + default="ubuntu", + description="In-VM user callers SSH as. Exoscale Ubuntu templates default to ubuntu.", + ) diff --git a/src/providers/exoscale/tests/__init__.py b/src/providers/exoscale/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/providers/exoscale/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/providers/exoscale/tests/test_api.py b/src/providers/exoscale/tests/test_api.py new file mode 100644 index 0000000..b3656d0 --- /dev/null +++ b/src/providers/exoscale/tests/test_api.py @@ -0,0 +1,296 @@ +import base64 +import json + +import httpx +import pytest +import respx + +from providers.exoscale.api import ExoscaleAPI +from providers.exoscale.exceptions import ExoscaleTransportError, ExoscaleVMNotFoundError + +ZONE = "ch-gva-2" +BASE_URL = f"https://api-{ZONE}.exoscale.com/v2" + + +def _api() -> ExoscaleAPI: + return ExoscaleAPI( + api_key="EXO123", + api_secret="secret-value", + zone=ZONE, + default_image="Linux Ubuntu 24.04 LTS 64-bit", + instance_type="standard.medium", + disk_gb=50, + ) + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_ensure_ssh_key_creates_when_absent(respx_mock): + respx_mock.get("/ssh-key/drukbox-sb").mock( + return_value=httpx.Response(404, json={"message": "not found"}), + ) + create = respx_mock.post("/ssh-key").mock( + return_value=httpx.Response(201, json={"id": "key-1", "name": "drukbox-sb"}), + ) + + await _api().ensure_ssh_key( + name="drukbox-sb", + public_key="ssh-ed25519 AAAA", + labels={"managed-by": "drukbox"}, + ) + + body = json.loads(create.calls.last.request.read()) + assert body == { + "name": "drukbox-sb", + "public-key": "ssh-ed25519 AAAA", + } + assert create.calls.last.request.headers["authorization"].startswith( + "EXO2-HMAC-SHA256 credential=" + ) + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL, assert_all_called=False) +async def test_ensure_ssh_key_reuses_existing(respx_mock): + respx_mock.get("/ssh-key/drukbox-sb").mock( + return_value=httpx.Response(200, json={"id": "key-1", "name": "drukbox-sb"}), + ) + create = respx_mock.post("/ssh-key") + + await _api().ensure_ssh_key(name="drukbox-sb", public_key="ssh-ed25519 AAAA", labels={}) + + assert not create.called + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_create_instance_posts_body_and_returns_id(respx_mock): + route = respx_mock.post("/instance").mock( + return_value=httpx.Response(202, json={"reference": {"id": "i-12345"}}), + ) + + instance_id = await _api().create_instance( + name="sb-test", + image="Linux Ubuntu 24.04 LTS 64-bit", + ssh_key_name="drukbox-sb-test", + user_data="#!/bin/sh\necho hi", + labels={"managed-by": "drukbox"}, + ) + + assert instance_id == "i-12345" + body = json.loads(route.calls.last.request.read()) + assert body["name"] == "sb-test" + assert body["disk-size"] == 50 + assert body["template"] == {"name": "Linux Ubuntu 24.04 LTS 64-bit"} + assert body["instance-type"] == {"name": "standard.medium"} + assert body["zone"] == ZONE + assert body["ssh-key"] == {"name": "drukbox-sb-test"} + assert body["labels"] == {"managed-by": "drukbox"} + assert base64.standard_b64decode(body["user-data"]).decode("utf-8") == "#!/bin/sh\necho hi" + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_create_instance_prefers_explicit_instance_type(respx_mock): + route = respx_mock.post("/instance").mock( + return_value=httpx.Response(202, json={"reference": {"id": "i-1"}}), + ) + + await _api().create_instance( + name="sb", + image="Linux Ubuntu 24.04 LTS 64-bit", + ssh_key_name="k", + user_data="", + labels={}, + instance_type="standard.large", + ) + + body = json.loads(route.calls.last.request.read()) + assert body["instance-type"] == {"name": "standard.large"} + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_create_instance_prefers_explicit_disk_gb(respx_mock): + route = respx_mock.post("/instance").mock( + return_value=httpx.Response(202, json={"reference": {"id": "i-1"}}), + ) + + await _api().create_instance( + name="sb", + image="Linux Ubuntu 24.04 LTS 64-bit", + ssh_key_name="k", + user_data="", + labels={}, + disk_gb=80, + ) + + body = json.loads(route.calls.last.request.read()) + assert body["disk-size"] == 80 + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_create_instance_omits_user_data_when_empty(respx_mock): + route = respx_mock.post("/instance").mock( + return_value=httpx.Response(202, json={"reference": {"id": "i-1"}}), + ) + + await _api().create_instance( + name="sb", + image="Linux Ubuntu 24.04 LTS 64-bit", + ssh_key_name="k", + user_data="", + labels={}, + ) + + body = json.loads(route.calls.last.request.read()) + assert "user-data" not in body + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_wait_for_running_with_ip_polls_until_running(respx_mock): + respx_mock.get("/instance/i-5").mock( + side_effect=[ + httpx.Response(200, json={"state": "initializing"}), + httpx.Response(200, json={"state": "running", "public-ip": "203.0.113.9"}), + ], + ) + + assert await _api().wait_for_running_with_ip("i-5") == "203.0.113.9" + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_find_instance_id_by_name_returns_none_when_absent(respx_mock): + respx_mock.get("/instance").mock(return_value=httpx.Response(200, json={"instances": []})) + assert await _api().find_instance_id_by_name("sb-missing") is None + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_find_instance_id_by_name_returns_id_when_present(respx_mock): + respx_mock.get("/instance").mock( + return_value=httpx.Response( + 200, + json={ + "instances": [ + {"id": "i-abc", "name": "sb-found"}, + {"id": "i-xyz", "name": "other"}, + ], + }, + ), + ) + + assert await _api().find_instance_id_by_name("sb-found") == "i-abc" + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_delete_instance_swallows_404(respx_mock): + respx_mock.delete("/instance/i-7").mock( + return_value=httpx.Response(404, json={"message": "gone"}), + ) + await _api().delete_instance("i-7") + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_delete_ssh_key_swallows_404_delete_race(respx_mock): + respx_mock.delete("/ssh-key/drukbox-sb").mock( + return_value=httpx.Response(404, json={"message": "gone"}), + ) + + await _api().delete_ssh_key("drukbox-sb") + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_request_maps_4xx_to_transport_error(respx_mock): + respx_mock.get("/instance").mock( + return_value=httpx.Response(401, json={"message": "bad credentials"}), + ) + with pytest.raises(ExoscaleTransportError, match="bad credentials"): + await _api().find_instance_id_by_name("sb") + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_request_maps_404_to_not_found(respx_mock): + respx_mock.get("/instance/i-3").mock( + return_value=httpx.Response(404, json={"message": "missing"}), + ) + with pytest.raises(ExoscaleVMNotFoundError): + await _api().wait_for_running_with_ip("i-3") + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_list_instances_count_reads_total(respx_mock): + respx_mock.get("/instance").mock( + return_value=httpx.Response(200, json={"instances": [{}, {}, {}]}), + ) + assert await _api().list_instances_count() == 3 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("public_ip", [{}, None, {"public-ip": None}]) +@respx.mock(base_url=BASE_URL) +async def test_wait_for_running_with_ip_raises_when_running_without_ipv4( + respx_mock, monkeypatch, public_ip +): + monkeypatch.setattr("providers.exoscale.api._RUN_TO_IP_TIMEOUT_SECONDS", 0) + body = {"state": "running"} + if isinstance(public_ip, dict): + body.update(public_ip) + respx_mock.get("/instance/i-5").mock(return_value=httpx.Response(200, json=body)) + + with pytest.raises(ExoscaleTransportError, match="public IP"): + await _api().wait_for_running_with_ip("i-5") + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_authorization_header_matches_reference_fixture(respx_mock, monkeypatch): + monkeypatch.setattr("time.time", lambda: 1_699_999_400.0) + expected = ( + "EXO2-HMAC-SHA256 credential=EXO123,expires=1700000000," + "signature=1GojVvs6vYrqytp/HhhjEmCKgOtSmUp2ObCJNfTizAI=" + ) + route = respx_mock.post("/instance").mock( + return_value=httpx.Response(202, json={"reference": {"id": "i-12345"}}), + ) + + await _api().create_instance( + name="sb-test", + image="Linux Ubuntu 24.04 LTS 64-bit", + ssh_key_name="drukbox-sb-test", + user_data="#!/bin/sh\necho hi", + labels={"managed-by": "drukbox"}, + ) + + header = route.calls.last.request.headers["authorization"] + assert header.startswith("EXO2-HMAC-SHA256 ") + assert header == expected + + +@pytest.mark.asyncio +@respx.mock(base_url=BASE_URL) +async def test_authorization_header_matches_reference_fixture_with_query_args( + respx_mock, monkeypatch +): + monkeypatch.setattr("time.time", lambda: 1_699_999_400.0) + expected = ( + "EXO2-HMAC-SHA256 credential=EXO123,signed-query-args=name;zone," + "expires=1700000000,signature=LdrDqX3RL8WKrHOSxqZ3ueR5PcwwEzjtwlOXWUThvUc=" + ) + route = respx_mock.get("/instance").mock( + return_value=httpx.Response(200, json={"instances": [], "total": 0}), + ) + + await _api()._request("GET", "/instance", params={"zone": ZONE, "name": "sb-test"}) + + header = route.calls.last.request.headers["authorization"] + assert "signed-query-args=name;zone" in header + assert header == expected diff --git a/src/providers/exoscale/tests/test_diagnose.py b/src/providers/exoscale/tests/test_diagnose.py new file mode 100644 index 0000000..f54ce2b --- /dev/null +++ b/src/providers/exoscale/tests/test_diagnose.py @@ -0,0 +1,29 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from providers.exoscale.exceptions import ExoscaleTransportError +from providers.exoscale.provider import ExoscaleProvider +from providers.exoscale.settings import ExoscaleSettings + + +def _provider(count_side: object = 3) -> ExoscaleProvider: + api = MagicMock() + if isinstance(count_side, Exception): + api.list_instances_count = AsyncMock(side_effect=count_side) + else: + api.list_instances_count = AsyncMock(return_value=count_side) + settings = ExoscaleSettings(api_key="exo-key", api_secret="exo-secret", zone="ch-gva-2") + return ExoscaleProvider(api, settings) + + +@pytest.mark.asyncio +async def test_diagnose_returns_zone_and_instance_count(): + detail = await _provider(count_side=3).diagnose() + assert detail == "zone=ch-gva-2 instances=3" + + +@pytest.mark.asyncio +async def test_diagnose_raises_on_api_failure(): + with pytest.raises(ExoscaleTransportError): + await _provider(count_side=ExoscaleTransportError("401")).diagnose() diff --git a/src/providers/exoscale/tests/test_provider.py b/src/providers/exoscale/tests/test_provider.py new file mode 100644 index 0000000..a5a5b6f --- /dev/null +++ b/src/providers/exoscale/tests/test_provider.py @@ -0,0 +1,176 @@ +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from providers.exceptions import ProviderNotFoundError, ProviderTransportError +from providers.exoscale.exceptions import ExoscaleTransportError +from providers.exoscale.provider import ExoscaleProvider +from providers.exoscale.settings import ExoscaleSettings + + +def _settings(**overrides: Any) -> ExoscaleSettings: + base: dict[str, Any] = { + "api_key": "exo-key", + "api_secret": "exo-secret", + "zone": "ch-gva-2", + } + base.update(overrides) + return ExoscaleSettings(**base) + + +def _api_mock() -> MagicMock: + api = MagicMock() + api.ensure_ssh_key = AsyncMock() + api.delete_ssh_key = AsyncMock() + api.create_instance = AsyncMock(return_value="i-12345") + api.wait_for_running_with_ip = AsyncMock(return_value="198.51.100.7") + api.find_instance_id_by_name = AsyncMock(return_value=None) + api.delete_instance = AsyncMock() + return api + + +@pytest.mark.asyncio +async def test_create_vm_mints_key_and_returns_public_ip_and_private_key(): + api = _api_mock() + provider = ExoscaleProvider(api, _settings()) + + result = await provider.create_vm( + name="sb-test", image="Linux Ubuntu 24.04 LTS 64-bit", env={"FOO": "bar"} + ) + + api.ensure_ssh_key.assert_awaited_once() + key_kwargs = api.ensure_ssh_key.await_args.kwargs + assert key_kwargs["name"] == "drukbox-sb-test" + assert key_kwargs["labels"] == {"managed-by": "drukbox", "drukbox-host-name": "sb-test"} + + instance_kwargs = api.create_instance.await_args.kwargs + assert instance_kwargs["name"] == "sb-test" + assert instance_kwargs["image"] == "Linux Ubuntu 24.04 LTS 64-bit" + assert instance_kwargs["ssh_key_name"] == "drukbox-sb-test" + assert instance_kwargs["labels"] == {"managed-by": "drukbox", "drukbox-host-name": "sb-test"} + assert instance_kwargs["zone"] == "ch-gva-2" + assert "export FOO=bar" in instance_kwargs["user_data"] + + assert result.ssh_host == "198.51.100.7" + assert result.ssh_port == 22 + assert result.ssh_username == "ubuntu" + assert result.private_key is not None + assert "-----BEGIN OPENSSH PRIVATE KEY-----" in result.private_key + + +@pytest.mark.asyncio +async def test_create_vm_passes_instance_type(): + api = _api_mock() + provider = ExoscaleProvider(api, _settings()) + + await provider.create_vm( + name="sb-test", + image="Linux Ubuntu 24.04 LTS 64-bit", + env={}, + setup_script="echo hi", + instance_type="standard.large", + ) + + assert api.create_instance.await_args.kwargs["instance_type"] == "standard.large" + + +@pytest.mark.asyncio +async def test_create_vm_passes_disk_gb(): + api = _api_mock() + provider = ExoscaleProvider(api, _settings()) + + await provider.create_vm( + name="sb-test", + image="Linux Ubuntu 24.04 LTS 64-bit", + env={}, + setup_script="echo hi", + disk_gb=80, + ) + + assert api.create_instance.await_args.kwargs["disk_gb"] == 80 + + +@pytest.mark.asyncio +async def test_create_vm_uses_default_disk_gb_from_settings(): + api = _api_mock() + provider = ExoscaleProvider(api, _settings(disk_gb=60)) + + await provider.create_vm( + name="sb-test", + image="Linux Ubuntu 24.04 LTS 64-bit", + env={}, + setup_script="echo hi", + ) + + assert api.create_instance.await_args.kwargs["disk_gb"] == 60 + + +@pytest.mark.asyncio +async def test_create_vm_deletes_key_when_create_instance_fails(): + api = _api_mock() + api.create_instance.side_effect = ExoscaleTransportError("boom") + provider = ExoscaleProvider(api, _settings()) + + with pytest.raises(ProviderTransportError): + await provider.create_vm( + name="sb-test", + image="Linux Ubuntu 24.04 LTS 64-bit", + env={}, + setup_script="echo hi", + ) + api.delete_ssh_key.assert_awaited_once_with("drukbox-sb-test") + + +@pytest.mark.asyncio +async def test_create_vm_uses_custom_ssh_username(): + api = _api_mock() + provider = ExoscaleProvider(api, _settings(ssh_username="sandbox")) + + result = await provider.create_vm( + name="sb-test", + image="Linux Ubuntu 24.04 LTS 64-bit", + env={}, + setup_script="echo hi", + ) + assert result.ssh_username == "sandbox" + + +@pytest.mark.asyncio +async def test_delete_vm_deletes_instance_and_key(): + api = _api_mock() + api.find_instance_id_by_name.return_value = "i-12345" + provider = ExoscaleProvider(api, _settings()) + + await provider.delete_vm("sb-test") + + api.delete_instance.assert_awaited_once_with("i-12345") + api.delete_ssh_key.assert_awaited_once_with("drukbox-sb-test") + + +@pytest.mark.asyncio +async def test_delete_vm_raises_not_found_when_instance_missing(): + api = _api_mock() + api.find_instance_id_by_name.return_value = None + provider = ExoscaleProvider(api, _settings()) + + with pytest.raises(ProviderNotFoundError): + await provider.delete_vm("sb-missing") + api.delete_instance.assert_not_called() + + +@pytest.mark.asyncio +async def test_delete_vm_deletes_key_even_when_instance_already_gone(): + api = _api_mock() + api.find_instance_id_by_name.return_value = None + provider = ExoscaleProvider(api, _settings()) + + with pytest.raises(ProviderNotFoundError): + await provider.delete_vm("sb-test") + api.delete_ssh_key.assert_awaited_once_with("drukbox-sb-test") + api.delete_instance.assert_not_called() + + +def test_default_image_reads_from_settings(): + provider = ExoscaleProvider(_api_mock(), _settings(default_image="Debian 12")) + assert provider.default_image == "Debian 12"