From 6464504222a70ff45eea5d4401220350cbfa9e57 Mon Sep 17 00:00:00 2001 From: Jack Decker <24392469+jackowfish@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:36:45 -0400 Subject: [PATCH] resolve base url from project/cluster ids or in-cluster detection --- README.md | 22 +++++++-- porter_sandbox/_config.py | 50 +++++++++++++++++-- tests/test_config.py | 100 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 160 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index aa42bc8..ef601e9 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,24 @@ with Porter() as porter: sb.terminate() ``` -Set `PORTER_SANDBOX_API_KEY` in your environment. -By default, the SDK connects to Porter's in-cluster sandbox API at -`http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080`. Override it -with `PORTER_SANDBOX_BASE_URL` or by passing `base_url`. +Inside a sandbox-enabled Porter cluster, the SDK connects to the in-cluster +sandbox API at `http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080` +automatically, with no configuration needed. + +From outside the cluster, set the project and cluster where sandboxes are +enabled, plus a Porter API token (created from **Settings > API tokens** in the +Porter Dashboard): + +```bash +export PORTER_PROJECT_ID= +export PORTER_CLUSTER_ID= +export PORTER_SANDBOX_API_KEY= +``` + +The SDK then calls the sandbox API through the Porter API at +`dashboard.porter.run`. To target a specific URL instead, set +`PORTER_SANDBOX_BASE_URL` or pass `base_url` - both take precedence over +everything above. ### Async diff --git a/porter_sandbox/_config.py b/porter_sandbox/_config.py index ba7fb6c..23cca41 100644 --- a/porter_sandbox/_config.py +++ b/porter_sandbox/_config.py @@ -3,7 +3,10 @@ import os from dataclasses import dataclass -DEFAULT_BASE_URL = "http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080" +IN_CLUSTER_BASE_URL = "http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080" +EXTERNAL_BASE_URL_FORMAT = ( + "https://dashboard.porter.run/api/v2/alpha/projects/{project_id}/clusters/{cluster_id}" +) DEFAULT_TIMEOUT_SECONDS = 30.0 @@ -21,8 +24,49 @@ def resolve( base_url: str | None = None, timeout: float | None = None, ) -> Config: + resolved_api_key = api_key or os.environ.get("PORTER_SANDBOX_API_KEY") or None return cls( - api_key=api_key or os.environ.get("PORTER_SANDBOX_API_KEY") or None, - base_url=(base_url or os.environ.get("PORTER_SANDBOX_BASE_URL") or DEFAULT_BASE_URL).rstrip("/"), + api_key=resolved_api_key, + base_url=_resolve_base_url(base_url, resolved_api_key).rstrip("/"), timeout=timeout if timeout is not None else DEFAULT_TIMEOUT_SECONDS, ) + + +def _resolve_base_url(base_url: str | None, api_key: str | None) -> str: + explicit = base_url or os.environ.get("PORTER_SANDBOX_BASE_URL") + if explicit: + return explicit + + project_id = os.environ.get("PORTER_PROJECT_ID") + cluster_id = os.environ.get("PORTER_CLUSTER_ID") + if project_id and cluster_id: + if not api_key: + raise ValueError( + "PORTER_PROJECT_ID and PORTER_CLUSTER_ID are set, so the SDK will call the " + "Porter API from outside the cluster, which requires an API token. Set " + "PORTER_SANDBOX_API_KEY or pass api_key. You can create an API token from " + "Settings > API tokens in the Porter Dashboard (requires admin permissions)." + ) + return EXTERNAL_BASE_URL_FORMAT.format(project_id=project_id, cluster_id=cluster_id) + if project_id or cluster_id: + set_var, missing_var = ( + ("PORTER_PROJECT_ID", "PORTER_CLUSTER_ID") + if project_id + else ("PORTER_CLUSTER_ID", "PORTER_PROJECT_ID") + ) + raise ValueError( + f"{set_var} is set but {missing_var} is not. Set both to call the Porter API " + "from outside the cluster." + ) + + # Kubernetes sets KUBERNETES_SERVICE_HOST in every pod, so its presence means the + # in-cluster sandbox API service address is at least reachable in principle. + if os.environ.get("KUBERNETES_SERVICE_HOST"): + return IN_CLUSTER_BASE_URL + + raise ValueError( + "Could not determine the sandbox API base URL. Either run inside a sandbox-enabled " + "Porter cluster, or set PORTER_PROJECT_ID and PORTER_CLUSTER_ID (plus " + "PORTER_SANDBOX_API_KEY) to call the Porter API from outside the cluster. You can " + "also set PORTER_SANDBOX_BASE_URL or pass base_url to target a specific URL." + ) diff --git a/tests/test_config.py b/tests/test_config.py index 10086cc..5b8cbf1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,19 +1,36 @@ from __future__ import annotations +import pytest from pytest import MonkeyPatch from porter_sandbox._config import Config -DEFAULT_BASE_URL = "http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080" +IN_CLUSTER_BASE_URL = "http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080" + +RESOLUTION_ENV_VARS = ( + "PORTER_SANDBOX_BASE_URL", + "PORTER_SANDBOX_API_KEY", + "PORTER_PROJECT_ID", + "PORTER_CLUSTER_ID", + "KUBERNETES_SERVICE_HOST", +) + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch: MonkeyPatch) -> None: + for var in RESOLUTION_ENV_VARS: + monkeypatch.delenv(var, raising=False) def test_config_falls_back_to_in_cluster_sandbox_api_url(monkeypatch: MonkeyPatch) -> None: - monkeypatch.delenv("PORTER_SANDBOX_BASE_URL", raising=False) + monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1") - assert Config.resolve().base_url == DEFAULT_BASE_URL + assert Config.resolve().base_url == IN_CLUSTER_BASE_URL -def test_config_uses_environment_base_url_when_no_base_url_is_passed(monkeypatch: MonkeyPatch) -> None: +def test_config_uses_environment_base_url_when_no_base_url_is_passed( + monkeypatch: MonkeyPatch, +) -> None: monkeypatch.setenv("PORTER_SANDBOX_BASE_URL", "https://sandbox.example/") assert Config.resolve().base_url == "https://sandbox.example" @@ -22,4 +39,77 @@ def test_config_uses_environment_base_url_when_no_base_url_is_passed(monkeypatch def test_config_prefers_explicit_base_url_over_environment(monkeypatch: MonkeyPatch) -> None: monkeypatch.setenv("PORTER_SANDBOX_BASE_URL", "https://sandbox.example") - assert Config.resolve(base_url="https://sandbox.override/").base_url == "https://sandbox.override" + assert ( + Config.resolve(base_url="https://sandbox.override/").base_url == "https://sandbox.override" + ) + + +def test_config_builds_external_url_from_project_and_cluster_ids(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("PORTER_PROJECT_ID", "123") + monkeypatch.setenv("PORTER_CLUSTER_ID", "456") + monkeypatch.setenv("PORTER_SANDBOX_API_KEY", "token") + + assert ( + Config.resolve().base_url + == "https://dashboard.porter.run/api/v2/alpha/projects/123/clusters/456" + ) + + +def test_config_prefers_project_and_cluster_ids_over_in_cluster_detection( + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.setenv("PORTER_PROJECT_ID", "123") + monkeypatch.setenv("PORTER_CLUSTER_ID", "456") + monkeypatch.setenv("PORTER_SANDBOX_API_KEY", "token") + monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1") + + assert ( + Config.resolve().base_url + == "https://dashboard.porter.run/api/v2/alpha/projects/123/clusters/456" + ) + + +def test_config_prefers_environment_base_url_over_project_and_cluster_ids( + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.setenv("PORTER_PROJECT_ID", "123") + monkeypatch.setenv("PORTER_CLUSTER_ID", "456") + monkeypatch.setenv("PORTER_SANDBOX_BASE_URL", "https://sandbox.example") + + assert Config.resolve().base_url == "https://sandbox.example" + + +def test_config_requires_api_key_for_external_url(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("PORTER_PROJECT_ID", "123") + monkeypatch.setenv("PORTER_CLUSTER_ID", "456") + + with pytest.raises(ValueError, match="PORTER_SANDBOX_API_KEY"): + Config.resolve() + + +def test_config_accepts_api_key_argument_for_external_url(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("PORTER_PROJECT_ID", "123") + monkeypatch.setenv("PORTER_CLUSTER_ID", "456") + + config = Config.resolve(api_key="token") + + assert config.base_url == "https://dashboard.porter.run/api/v2/alpha/projects/123/clusters/456" + + +def test_config_rejects_project_id_without_cluster_id(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("PORTER_PROJECT_ID", "123") + + with pytest.raises(ValueError, match="PORTER_CLUSTER_ID is not"): + Config.resolve() + + +def test_config_rejects_cluster_id_without_project_id(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("PORTER_CLUSTER_ID", "456") + + with pytest.raises(ValueError, match="PORTER_PROJECT_ID is not"): + Config.resolve() + + +def test_config_raises_when_base_url_cannot_be_determined() -> None: + with pytest.raises(ValueError, match="Could not determine the sandbox API base URL"): + Config.resolve()