diff --git a/app/main.py b/app/main.py index 436283b..b1c361f 100644 --- a/app/main.py +++ b/app/main.py @@ -739,6 +739,12 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id if raw_command: spec["command"] = re.sub(r"\$\{(\w+)\}", lambda m: env.get(m.group(1), m.group(0)), raw_command) + # Durable resource limits (mem_limit / mem_reservation / oom_score_adj), + # declared in the service YAML. Only forwarded when present. + resources = docker_conf.get("resources") + if resources: + spec["resources"] = resources + result = await _proxy_worker_deploy(worker_id, slug, spec) container_id = result.get("container_id", "remote") await database.save_deployment(slug=slug, container_id=container_id) diff --git a/app/orchestrator.py b/app/orchestrator.py index 72dc703..f87b7dd 100644 --- a/app/orchestrator.py +++ b/app/orchestrator.py @@ -102,6 +102,23 @@ def _find_container(slug: str): raise ValueError(f"Container for {slug} not found") +def _normalize_resources(resources: Any) -> dict[str, Any]: + """Coerce a resources spec (Pydantic model, dict, or None) into a plain dict. + + Only non-None values are kept, so callers can splat the result into + ``containers.run`` without clobbering Docker's own defaults. + """ + if resources is None: + return {} + if hasattr(resources, "model_dump"): + data = resources.model_dump() + elif isinstance(resources, dict): + data = dict(resources) + else: + return {} + return {k: v for k, v in data.items() if v is not None} + + def deploy_raw( slug: str, image: str, @@ -114,12 +131,14 @@ def deploy_raw( command: str | None = None, hostname: str | None = None, labels: dict[str, str] | None = None, + resources: Any = None, category: str = "bandwidth", ) -> str: """Deploy a container from a raw spec (no catalog lookup). Used by CashPilot Worker when the UI sends a full container spec. - Returns the container ID. + ``resources`` (mem_limit / mem_reservation / oom_score_adj) makes the + container's cgroup limits durable across recreates. Returns the container ID. """ client = _get_client() name = _container_name(slug) @@ -148,6 +167,14 @@ def deploy_raw( except APIError as exc: logger.warning("Failed to pull image %s: %s (trying local)", image, exc) + # Durable resource limits (only passed when explicitly set, so Docker + # defaults are preserved otherwise). memswap is deliberately left unset: + # at create time mem_limit alone avoids the cgroup-v2 swap validation issue. + res = _normalize_resources(resources) + resource_kwargs = { + key: res[key] for key in ("mem_limit", "mem_reservation", "oom_score_adj") if res.get(key) is not None + } + logger.info("Creating container %s from %s", name, image) container = client.containers.run( image=image, @@ -163,6 +190,7 @@ def deploy_raw( hostname=hostname or f"cashpilot-{slug}", detach=True, restart_policy={"Name": "unless-stopped"}, + **resource_kwargs, ) logger.info("Container %s started: %s", name, container.short_id) diff --git a/app/worker_api.py b/app/worker_api.py index 08bae69..5777057 100644 --- a/app/worker_api.py +++ b/app/worker_api.py @@ -18,6 +18,7 @@ import logging import os import platform +import re import socket from contextlib import asynccontextmanager from datetime import UTC, datetime @@ -258,6 +259,18 @@ async def worker_status_page(): # --------------------------------------------------------------------------- +class ResourceSpec(BaseModel): + """Optional Docker resource limits applied when the container is created. + + mem_limit / mem_reservation follow Docker's size syntax ("768m", "2g"); + oom_score_adj biases the kernel OOM killer (-1000 = sacrificed last). + """ + + mem_limit: str | None = None + mem_reservation: str | None = None + oom_score_adj: int | None = None + + class DeploySpec(BaseModel): image: str env: dict[str, str] = {} @@ -269,10 +282,13 @@ class DeploySpec(BaseModel): command: str | None = None hostname: str | None = None labels: dict[str, str] = {} + resources: ResourceSpec | None = None _BLOCKED_VOLUME_SOURCES = {"/", "/etc", "/var/run/docker.sock", "/root", "/proc", "/sys"} _BLOCKED_CAPS = {"ALL", "SYS_ADMIN", "SYS_PTRACE"} +# Docker memory size syntax: a positive integer with an optional b/k/m/g unit. +_MEM_LIMIT_RE = re.compile(r"^\d+[bkmgBKMG]?$") def _validate_deploy_spec(spec: DeploySpec) -> None: @@ -289,6 +305,23 @@ def _validate_deploy_spec(spec: DeploySpec) -> None: for blocked in _BLOCKED_VOLUME_SOURCES: if normalized == blocked or normalized.startswith(blocked + "/"): raise HTTPException(status_code=403, detail=f"Volume mount '{source}' is blocked") + _validate_resources(spec.resources) + + +def _validate_resources(resources: ResourceSpec | None) -> None: + if resources is None: + return + for field, value in (("mem_limit", resources.mem_limit), ("mem_reservation", resources.mem_reservation)): + if value is not None and not _MEM_LIMIT_RE.match(value): + raise HTTPException( + status_code=400, + detail=f"Invalid {field} '{value}': expected a size like '768m' or '2g'", + ) + if resources.oom_score_adj is not None and not (-1000 <= resources.oom_score_adj <= 1000): + raise HTTPException( + status_code=400, + detail=f"Invalid oom_score_adj '{resources.oom_score_adj}': must be between -1000 and 1000", + ) @app.get("/api/status") @@ -338,6 +371,7 @@ async def api_deploy_container(request: Request, slug: str, spec: DeploySpec) -> command=spec.command, hostname=spec.hostname, labels=spec.labels, + resources=spec.resources, ) return {"status": "deployed", "container_id": container_id} except Exception: diff --git a/services/_schema.yml b/services/_schema.yml index 0c27511..94bf00d 100644 --- a/services/_schema.yml +++ b/services/_schema.yml @@ -17,6 +17,10 @@ # docker: # image: Docker Hub image # platforms: [linux/amd64, linux/arm64] +# resources: (optional, durable Docker resource limits applied at container create) +# mem_limit: "768m" # hard memory ceiling (str: , e.g. "512m", "2g") +# mem_reservation: "512m" # (optional) soft memory reservation, same format as mem_limit +# oom_score_adj: -100 # kernel OOM-killer bias, int -1000..1000 (lower = sacrificed last) # env: # - key: ENV_VAR_NAME # label: "Human label" diff --git a/services/bandwidth/bitping.yml b/services/bandwidth/bitping.yml index ce11886..4d92582 100644 --- a/services/bandwidth/bitping.yml +++ b/services/bandwidth/bitping.yml @@ -17,6 +17,9 @@ referral: docker: image: bitping/bitpingd platforms: [linux/amd64, linux/arm64] + resources: + mem_limit: "128m" + oom_score_adj: 200 env: [] ports: [] volumes: diff --git a/services/bandwidth/earnapp.yml b/services/bandwidth/earnapp.yml index 1c25bb1..2762491 100644 --- a/services/bandwidth/earnapp.yml +++ b/services/bandwidth/earnapp.yml @@ -16,6 +16,9 @@ referral: docker: image: fazalfarhan01/earnapp:lite platforms: [linux/amd64] + resources: + mem_limit: "256m" + oom_score_adj: 300 env: - key: EARNAPP_UUID label: "Node UUID" diff --git a/services/bandwidth/earnfm.yml b/services/bandwidth/earnfm.yml index 2fa221f..f7897e6 100644 --- a/services/bandwidth/earnfm.yml +++ b/services/bandwidth/earnfm.yml @@ -15,6 +15,9 @@ referral: docker: image: earnfm/earnfm-client platforms: [linux/amd64, linux/arm64] + resources: + mem_limit: "128m" + oom_score_adj: 200 env: - key: EARNFM_TOKEN label: "API Key" diff --git a/services/bandwidth/honeygain.yml b/services/bandwidth/honeygain.yml index ea944e5..7aab23e 100644 --- a/services/bandwidth/honeygain.yml +++ b/services/bandwidth/honeygain.yml @@ -16,6 +16,9 @@ referral: docker: image: honeygain/honeygain platforms: [linux/amd64] + resources: + mem_limit: "256m" + oom_score_adj: 200 env: - key: HONEYGAIN_EMAIL label: "Email" diff --git a/services/bandwidth/iproyal.yml b/services/bandwidth/iproyal.yml index 185a121..9d84fad 100644 --- a/services/bandwidth/iproyal.yml +++ b/services/bandwidth/iproyal.yml @@ -17,6 +17,9 @@ referral: docker: image: iproyal/pawns-cli platforms: [linux/amd64, linux/arm64] + resources: + mem_limit: "128m" + oom_score_adj: 300 env: - key: IPROYALPAWNS_EMAIL label: "Email" diff --git a/services/bandwidth/mysterium.yml b/services/bandwidth/mysterium.yml index 33f6393..37c8277 100644 --- a/services/bandwidth/mysterium.yml +++ b/services/bandwidth/mysterium.yml @@ -17,6 +17,9 @@ referral: docker: image: mysteriumnetwork/myst platforms: [linux/amd64, linux/arm64] + resources: + mem_limit: "768m" + oom_score_adj: -100 env: [] ports: - "4449:4449" diff --git a/services/bandwidth/packetstream.yml b/services/bandwidth/packetstream.yml index e8ebb64..1ef7ce9 100644 --- a/services/bandwidth/packetstream.yml +++ b/services/bandwidth/packetstream.yml @@ -17,6 +17,9 @@ referral: docker: image: packetstream/psclient platforms: [linux/amd64] + resources: + mem_limit: "128m" + oom_score_adj: 300 env: - key: CID label: "Client ID" diff --git a/services/bandwidth/proxybase.yml b/services/bandwidth/proxybase.yml index c128ced..6cc000b 100644 --- a/services/bandwidth/proxybase.yml +++ b/services/bandwidth/proxybase.yml @@ -15,6 +15,9 @@ referral: docker: image: proxybase/proxybase platforms: [linux/amd64] + resources: + mem_limit: "128m" + oom_score_adj: 300 env: - key: USER_ID label: "User ID" diff --git a/services/bandwidth/proxylite.yml b/services/bandwidth/proxylite.yml index 6ef0d14..0fcb6e1 100644 --- a/services/bandwidth/proxylite.yml +++ b/services/bandwidth/proxylite.yml @@ -16,6 +16,9 @@ referral: docker: image: proxylite/proxyservice platforms: [linux/amd64, linux/arm64] + resources: + mem_limit: "128m" + oom_score_adj: 300 env: - key: USER_ID label: "User ID" diff --git a/services/bandwidth/proxyrack.yml b/services/bandwidth/proxyrack.yml index 8c42798..7d775e9 100644 --- a/services/bandwidth/proxyrack.yml +++ b/services/bandwidth/proxyrack.yml @@ -17,6 +17,9 @@ referral: docker: image: proxyrack/pop platforms: [linux/amd64] + resources: + mem_limit: "256m" + oom_score_adj: 300 env: - key: UUID label: "Device UUID" diff --git a/services/bandwidth/repocket.yml b/services/bandwidth/repocket.yml index 0958d54..1a92897 100644 --- a/services/bandwidth/repocket.yml +++ b/services/bandwidth/repocket.yml @@ -17,6 +17,9 @@ referral: docker: image: repocket/repocket platforms: [linux/amd64, linux/arm64] + resources: + mem_limit: "128m" + oom_score_adj: 300 env: - key: RP_EMAIL label: "Email" diff --git a/services/bandwidth/traffmonetizer.yml b/services/bandwidth/traffmonetizer.yml index dd88d42..685a30a 100644 --- a/services/bandwidth/traffmonetizer.yml +++ b/services/bandwidth/traffmonetizer.yml @@ -17,6 +17,9 @@ referral: docker: image: traffmonetizer/cli_v2 platforms: [linux/amd64, linux/arm64] + resources: + mem_limit: "128m" + oom_score_adj: 300 env: - key: TRAFFMONETIZER_TOKEN label: "Token" diff --git a/services/bandwidth/urnetwork.yml b/services/bandwidth/urnetwork.yml index e9fc5d3..dac05d9 100644 --- a/services/bandwidth/urnetwork.yml +++ b/services/bandwidth/urnetwork.yml @@ -16,6 +16,9 @@ referral: docker: image: bringyour/community-provider platforms: [linux/amd64, linux/arm64] + resources: + mem_limit: "128m" + oom_score_adj: 300 env: - key: UR_AUTH_TOKEN label: "Auth Token" diff --git a/services/depin/anyone-protocol.yml b/services/depin/anyone-protocol.yml index c4d86ac..fc81215 100644 --- a/services/depin/anyone-protocol.yml +++ b/services/depin/anyone-protocol.yml @@ -18,6 +18,9 @@ docker: image: ghcr.io/anyone-protocol/ator-protocol tag: latest platforms: [linux/amd64, linux/arm64] + resources: + mem_limit: "1536m" + oom_score_adj: -100 env: [] ports: ["9001:9001"] volumes: diff --git a/services/storage/storj.yml b/services/storage/storj.yml index 0c6f794..bd84b9a 100644 --- a/services/storage/storj.yml +++ b/services/storage/storj.yml @@ -18,6 +18,9 @@ referral: docker: image: storjlabs/storagenode platforms: [linux/amd64, linux/arm64] + resources: + mem_limit: "2g" + oom_score_adj: -100 env: - key: WALLET label: "Wallet address" diff --git a/tests/test_main_deploy_routes.py b/tests/test_main_deploy_routes.py index b802371..e0493ed 100644 --- a/tests/test_main_deploy_routes.py +++ b/tests/test_main_deploy_routes.py @@ -53,6 +53,22 @@ def client(): yield c +@pytest.fixture +def deploy_capture(): + """Capture the spec forwarded to ``_proxy_worker_deploy``. + + Returns ``(captured, side_effect)``: patch ``app.main._proxy_worker_deploy`` + with ``side_effect``, then read the forwarded spec from ``captured["spec"]``. + """ + captured: dict = {} + + async def _capture_deploy(worker_id, slug, spec): + captured["spec"] = spec + return {"container_id": "abc123"} + + return captured, _capture_deploy + + def _online_worker(wid=1, url="http://192.168.1.10:8081"): return {"id": wid, "name": "w1", "status": "online", "url": url} @@ -141,7 +157,7 @@ def test_deploy_no_auth(self, client): resp = client.post("/api/deploy/honeygain", json={}) assert resp.status_code == 401 - def test_deploy_repocket_emits_rp_env_keys(self, client): + def test_deploy_repocket_emits_rp_env_keys(self, client, deploy_capture): """#82 guard at the deploy layer: the real repocket catalog entry must produce RP_EMAIL/RP_API_KEY in the container spec sent to the worker. @@ -149,11 +165,7 @@ def test_deploy_repocket_emits_rp_env_keys(self, client): renames the YAML keys OR a refactor of api_deploy that mangles env names is caught independently of the catalog-level test. """ - captured: dict = {} - - async def _capture_deploy(worker_id, slug, spec): - captured["spec"] = spec - return {"container_id": "abc123"} + captured, capture = deploy_capture # Reload the real catalog from disk so this test is independent of any # earlier test that swapped SERVICES_DIR and left the cache polluted. @@ -164,7 +176,7 @@ async def _capture_deploy(worker_id, slug, spec): with ( _auth_owner(), patch("app.main._resolve_worker_id", new_callable=AsyncMock, return_value=1), - patch("app.main._proxy_worker_deploy", side_effect=_capture_deploy), + patch("app.main._proxy_worker_deploy", side_effect=capture), patch("app.main.database.save_deployment", new_callable=AsyncMock), patch("app.main.database.record_health_event", new_callable=AsyncMock), ): @@ -180,6 +192,89 @@ async def _capture_deploy(worker_id, slug, spec): assert spec_env["RP_API_KEY"] == "key123" assert spec_env["RP_EMAIL"] == "me@example.com" + def test_deploy_forwards_resources_from_yaml(self, client, deploy_capture): + """A resources block in the service YAML must reach the worker spec.""" + captured, capture = deploy_capture + + svc = { + "slug": "storj", + "name": "Storj", + "docker": { + "image": "storjlabs/storagenode", + "env": [], + "ports": [], + "volumes": [], + "resources": {"mem_limit": "2g", "oom_score_adj": -100}, + }, + } + with ( + _auth_owner(), + patch("app.main._resolve_worker_id", new_callable=AsyncMock, return_value=1), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main._proxy_worker_deploy", side_effect=capture), + patch("app.main.database.save_deployment", new_callable=AsyncMock), + patch("app.main.database.record_health_event", new_callable=AsyncMock), + ): + resp = client.post("/api/deploy/storj", json={}) + assert resp.status_code == 200, resp.text + assert captured["spec"]["resources"] == {"mem_limit": "2g", "oom_score_adj": -100} + + def test_deploy_omits_resources_when_absent(self, client, deploy_capture): + """A service YAML without a resources block must not add one to the spec.""" + captured, capture = deploy_capture + + svc = { + "slug": "nores", + "name": "NoRes", + "docker": {"image": "x", "env": [], "ports": [], "volumes": []}, + } + with ( + _auth_owner(), + patch("app.main._resolve_worker_id", new_callable=AsyncMock, return_value=1), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main._proxy_worker_deploy", side_effect=capture), + patch("app.main.database.save_deployment", new_callable=AsyncMock), + patch("app.main.database.record_health_event", new_callable=AsyncMock), + ): + resp = client.post("/api/deploy/nores", json={}) + assert resp.status_code == 200, resp.text + assert "resources" not in captured["spec"] + + def test_deploy_storj_real_catalog_carries_resources(self, client, deploy_capture): + """Guard: the real storj YAML resources must reach the container spec. + + Uses the real catalog (no get_service mock) so a rename of the YAML + `resources` keys is caught independently of the schema-level test. + """ + captured, capture = deploy_capture + + from app import catalog + + catalog.load_services() + + with ( + _auth_owner(), + patch("app.main._resolve_worker_id", new_callable=AsyncMock, return_value=1), + patch("app.main._proxy_worker_deploy", side_effect=capture), + patch("app.main.database.save_deployment", new_callable=AsyncMock), + patch("app.main.database.record_health_event", new_callable=AsyncMock), + ): + resp = client.post( + "/api/deploy/storj", + json={ + "env": { + "WALLET": "0xabc", + "EMAIL": "a@b.com", + "ADDRESS": "1.2.3.4:28967", + "STORAGE": "2TB", + "IDENTITY_DIR": "/mnt/id", + "STORAGE_DIR": "/mnt/data", + } + }, + ) + assert resp.status_code == 200, resp.text + assert captured["spec"]["resources"] == {"mem_limit": "2g", "oom_score_adj": -100} + # --------------------------------------------------------------------------- # Stop / Restart / Start / Remove (service management routes) diff --git a/tests/test_worker_resources.py b/tests/test_worker_resources.py new file mode 100644 index 0000000..3c9c77b --- /dev/null +++ b/tests/test_worker_resources.py @@ -0,0 +1,204 @@ +"""Tests for per-service Docker resource limits (worker deploy chain). + +Covers: + * DeploySpec / ResourceSpec validation (valid + invalid mem_limit, + mem_reservation, oom_score_adj). + * orchestrator.deploy_raw forwarding mem_limit / mem_reservation / + oom_score_adj to containers.run() only when set. + * The worker /deploy endpoint threading resources through to deploy_raw. +""" + +import os +from contextlib import asynccontextmanager +from unittest.mock import MagicMock, patch + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import pytest # noqa: E402 + +try: + from fastapi import HTTPException # noqa: E402 + from fastapi.testclient import TestClient # noqa: E402 + + from app import orchestrator, worker_api # noqa: E402 + from app.worker_api import ( # noqa: E402 + DeploySpec, + ResourceSpec, + _validate_deploy_spec, + _validate_resources, + ) +except ImportError: + pytest.skip( + "Requires full app dependencies (fastapi, docker, etc.) — runs in CI", + allow_module_level=True, + ) + + +# --------------------------------------------------------------------------- +# ResourceSpec / DeploySpec validation +# --------------------------------------------------------------------------- + + +class TestResourceValidation: + def test_valid_resources_pass(self): + spec = DeploySpec(image="x", resources=ResourceSpec(mem_limit="256m", oom_score_adj=200)) + _validate_deploy_spec(spec) # must not raise + + def test_none_resources_pass(self): + _validate_deploy_spec(DeploySpec(image="x")) + _validate_resources(None) + + @pytest.mark.parametrize("good", ["128", "256m", "2g", "512M", "1024k", "999b", "1536m", "2G"]) + def test_valid_mem_forms_accepted(self, good): + _validate_resources(ResourceSpec(mem_limit=good, mem_reservation=good)) + + @pytest.mark.parametrize("bad", ["", "abc", "-5m", "256 m", "12tb", "2gb", "1.5g", "m"]) + def test_invalid_mem_limit_rejected(self, bad): + with pytest.raises(HTTPException) as ei: + _validate_resources(ResourceSpec(mem_limit=bad)) + assert ei.value.status_code == 400 + + def test_invalid_mem_reservation_rejected(self): + with pytest.raises(HTTPException) as ei: + _validate_resources(ResourceSpec(mem_reservation="lots")) + assert ei.value.status_code == 400 + + @pytest.mark.parametrize("bad", [-1001, 1001, 5000, -5000]) + def test_oom_out_of_range_rejected(self, bad): + with pytest.raises(HTTPException) as ei: + _validate_resources(ResourceSpec(oom_score_adj=bad)) + assert ei.value.status_code == 400 + + @pytest.mark.parametrize("good", [-1000, -100, 0, 200, 300, 1000]) + def test_oom_in_range_accepted(self, good): + _validate_resources(ResourceSpec(oom_score_adj=good)) + + def test_invalid_resources_rejected_via_deploy_spec(self): + spec = DeploySpec(image="x", resources=ResourceSpec(oom_score_adj=5000)) + with pytest.raises(HTTPException) as ei: + _validate_deploy_spec(spec) + assert ei.value.status_code == 400 + + +# --------------------------------------------------------------------------- +# orchestrator.deploy_raw -> containers.run() kwargs +# --------------------------------------------------------------------------- + + +class TestDeployRawResources: + def _mock_client(self): + container = MagicMock() + container.id = "cid" + container.short_id = "short" + client = MagicMock() + # No pre-existing container: get() raises NotFound so deploy proceeds. + client.containers.get.side_effect = orchestrator.NotFound("nope") + client.containers.run.return_value = container + return client + + def test_forwards_resources_from_pydantic_model(self): + client = self._mock_client() + with patch.object(orchestrator, "_get_client", return_value=client): + orchestrator.deploy_raw( + slug="storj", + image="img", + resources=ResourceSpec(mem_limit="2g", oom_score_adj=-100), + ) + kwargs = client.containers.run.call_args.kwargs + assert kwargs["mem_limit"] == "2g" + assert kwargs["oom_score_adj"] == -100 + assert "mem_reservation" not in kwargs + + def test_forwards_resources_from_dict_with_reservation(self): + client = self._mock_client() + with patch.object(orchestrator, "_get_client", return_value=client): + orchestrator.deploy_raw( + slug="svc", + image="img", + resources={"mem_limit": "768m", "mem_reservation": "512m", "oom_score_adj": None}, + ) + kwargs = client.containers.run.call_args.kwargs + assert kwargs["mem_limit"] == "768m" + assert kwargs["mem_reservation"] == "512m" + # None-valued fields are dropped, never forwarded as None. + assert "oom_score_adj" not in kwargs + + def test_omits_resource_kwargs_when_unset(self): + client = self._mock_client() + with patch.object(orchestrator, "_get_client", return_value=client): + orchestrator.deploy_raw(slug="svc", image="img") + kwargs = client.containers.run.call_args.kwargs + assert "mem_limit" not in kwargs + assert "mem_reservation" not in kwargs + assert "oom_score_adj" not in kwargs + + +# --------------------------------------------------------------------------- +# Worker /deploy endpoint (spec -> deploy_raw) +# --------------------------------------------------------------------------- + + +@asynccontextmanager +async def _noop_lifespan(a): + yield + + +class TestWorkerDeployEndpoint: + def _client(self): + # Disable the heartbeat lifespan so the TestClient stays isolated. + worker_api.app.router.lifespan_context = _noop_lifespan + return TestClient(worker_api.app, raise_server_exceptions=False) + + def _auth(self): + return {"Authorization": f"Bearer {worker_api.API_KEY}"} + + def test_endpoint_threads_resources_to_deploy_raw(self): + captured: dict = {} + + def _fake_deploy(**kwargs): + captured.update(kwargs) + return "container-id-123" + + with patch("app.worker_api.orchestrator.deploy_raw", side_effect=_fake_deploy): + resp = self._client().post( + "/api/containers/storj/deploy", + json={"image": "img", "resources": {"mem_limit": "2g", "oom_score_adj": -100}}, + headers=self._auth(), + ) + assert resp.status_code == 200, resp.text + assert resp.json()["status"] == "deployed" + res = captured["resources"] + assert res.mem_limit == "2g" + assert res.oom_score_adj == -100 + + def test_endpoint_deploys_without_resources(self): + captured: dict = {} + + def _fake_deploy(**kwargs): + captured.update(kwargs) + return "container-id-123" + + with patch("app.worker_api.orchestrator.deploy_raw", side_effect=_fake_deploy): + resp = self._client().post( + "/api/containers/honeygain/deploy", + json={"image": "img"}, + headers=self._auth(), + ) + assert resp.status_code == 200, resp.text + assert captured["resources"] is None + + def test_endpoint_rejects_invalid_oom_score_adj(self): + resp = self._client().post( + "/api/containers/storj/deploy", + json={"image": "img", "resources": {"oom_score_adj": 5000}}, + headers=self._auth(), + ) + assert resp.status_code == 400 + + def test_endpoint_rejects_invalid_mem_limit(self): + resp = self._client().post( + "/api/containers/storj/deploy", + json={"image": "img", "resources": {"mem_limit": "loads"}}, + headers=self._auth(), + ) + assert resp.status_code == 400