Skip to content
Closed
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
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<project-id>
export PORTER_CLUSTER_ID=<cluster-id>
export PORTER_SANDBOX_API_KEY=<porter-api-token>
```

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

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


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