diff --git a/README.md b/README.md index aa42bc8..2460e33 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,23 @@ 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 a Porter API token (created from +**Settings > API tokens** in the Porter Dashboard) and the cluster where +sandboxes are enabled: + +```bash +export PORTER_SANDBOX_API_KEY= +export PORTER_CLUSTER_ID= +``` + +The SDK reads the project from the token and 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..8b89889 100644 --- a/porter_sandbox/_config.py +++ b/porter_sandbox/_config.py @@ -1,9 +1,15 @@ from __future__ import annotations +import base64 +import binascii +import json 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 +27,75 @@ 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 + + cluster_id = os.environ.get("PORTER_CLUSTER_ID") + if cluster_id: + if not api_key: + raise ValueError( + "PORTER_CLUSTER_ID is 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_from_api_key(api_key), cluster_id=cluster_id + ) + + # 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 + + if api_key: + raise ValueError( + "An API key is set but PORTER_CLUSTER_ID is not. Set PORTER_CLUSTER_ID to the " + "cluster where sandboxes are enabled so the SDK can call the Porter API from " + "outside the cluster." + ) + + raise ValueError( + "Could not determine the sandbox API base URL. Either run inside a sandbox-enabled " + "Porter cluster, or set PORTER_CLUSTER_ID and 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." + ) + + +def _project_id_from_api_key(api_key: str) -> int: + """Read the project_id claim from a Porter API token without verifying the signature. + + Signature verification is the server's job; the SDK only needs the claim to build + the URL, and a tampered claim just produces a URL the server will reject. + """ + error = ValueError( + "PORTER_SANDBOX_API_KEY does not look like a Porter API token (expected a JWT with " + "a project_id claim). Create one from Settings > API tokens in the Porter Dashboard." + ) + + segments = api_key.split(".") + if len(segments) != 3: + raise error + payload_segment = segments[1] + try: + payload = json.loads( + base64.urlsafe_b64decode(payload_segment + "=" * (-len(payload_segment) % 4)) + ) + except (binascii.Error, ValueError, UnicodeDecodeError): + raise error from None + + project_id = payload.get("project_id") if isinstance(payload, dict) else None + if not isinstance(project_id, int) or isinstance(project_id, bool) or project_id <= 0: + raise error + return project_id diff --git a/tests/test_config.py b/tests/test_config.py index 10086cc..094cac1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,19 +1,46 @@ from __future__ import annotations +import base64 +import json + +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_CLUSTER_ID", + "KUBERNETES_SERVICE_HOST", +) + + +def make_api_key(payload: object) -> str: + encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") + return f"header.{encoded}.signature" + + +API_KEY = make_api_key({"project_id": 123, "sub": "api", "token_id": "abc"}) + + +@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 +49,112 @@ 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_parses_project_id_from_porter_minted_token(monkeypatch: MonkeyPatch) -> None: + # Exact claim shape minted by the monolith's JWTForAPI: iat is a string, + # project_id is a number, sub/sub_kind/token_id are always present. + porter_token = make_api_key( + { + "iat": "1783537707", + "project_id": 1, + "sub": "api", + "sub_kind": "api", + "token_id": "7dc2a111-f851-4935-a414-b72945be0883", + } + ) + monkeypatch.setenv("PORTER_CLUSTER_ID", "651") + monkeypatch.setenv("PORTER_SANDBOX_API_KEY", porter_token) + + assert ( + Config.resolve().base_url + == "https://dashboard.porter.run/api/v2/alpha/projects/1/clusters/651" + ) + + +def test_config_builds_external_url_from_api_key_and_cluster_id(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("PORTER_CLUSTER_ID", "456") + monkeypatch.setenv("PORTER_SANDBOX_API_KEY", API_KEY) + + assert ( + Config.resolve().base_url + == "https://dashboard.porter.run/api/v2/alpha/projects/123/clusters/456" + ) + + +def test_config_prefers_cluster_id_over_in_cluster_detection(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("PORTER_CLUSTER_ID", "456") + monkeypatch.setenv("PORTER_SANDBOX_API_KEY", API_KEY) + 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_cluster_id(monkeypatch: MonkeyPatch) -> None: + 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_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_CLUSTER_ID", "456") + + assert ( + Config.resolve(api_key=API_KEY).base_url + == "https://dashboard.porter.run/api/v2/alpha/projects/123/clusters/456" + ) + + +@pytest.mark.parametrize( + "api_key", + [ + "not-a-jwt", + "one.two", + make_api_key({"sub": "api"}), + make_api_key({"project_id": "123"}), + make_api_key({"project_id": 0}), + make_api_key(["not", "a", "dict"]), + "header.!!!not-base64!!!.signature", + ], +) +def test_config_rejects_api_key_without_project_id_claim( + monkeypatch: MonkeyPatch, api_key: str +) -> None: + monkeypatch.setenv("PORTER_CLUSTER_ID", "456") + monkeypatch.setenv("PORTER_SANDBOX_API_KEY", api_key) + + with pytest.raises(ValueError, match="project_id claim"): + Config.resolve() + + +def test_config_requires_cluster_id_when_api_key_is_set(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("PORTER_SANDBOX_API_KEY", API_KEY) + + with pytest.raises(ValueError, match="PORTER_CLUSTER_ID is not"): + Config.resolve() + + +def test_config_ignores_missing_cluster_id_in_cluster(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("PORTER_SANDBOX_API_KEY", API_KEY) + monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1") + + assert Config.resolve().base_url == IN_CLUSTER_BASE_URL + + +def test_config_errors_when_no_resolution_path_is_available() -> None: + with pytest.raises(ValueError, match="Could not determine the sandbox API base URL"): + Config.resolve()