Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<porter-api-token>
export PORTER_CLUSTER_ID=<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

Expand Down
79 changes: 76 additions & 3 deletions porter_sandbox/_config.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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
145 changes: 140 additions & 5 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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()
Loading