Skip to content

Commit 6464504

Browse files
committed
resolve base url from project/cluster ids or in-cluster detection
1 parent 97906b2 commit 6464504

3 files changed

Lines changed: 160 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,24 @@ with Porter() as porter:
2424
sb.terminate()
2525
```
2626

27-
Set `PORTER_SANDBOX_API_KEY` in your environment.
28-
By default, the SDK connects to Porter's in-cluster sandbox API at
29-
`http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080`. Override it
30-
with `PORTER_SANDBOX_BASE_URL` or by passing `base_url`.
27+
Inside a sandbox-enabled Porter cluster, the SDK connects to the in-cluster
28+
sandbox API at `http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080`
29+
automatically, with no configuration needed.
30+
31+
From outside the cluster, set the project and cluster where sandboxes are
32+
enabled, plus a Porter API token (created from **Settings > API tokens** in the
33+
Porter Dashboard):
34+
35+
```bash
36+
export PORTER_PROJECT_ID=<project-id>
37+
export PORTER_CLUSTER_ID=<cluster-id>
38+
export PORTER_SANDBOX_API_KEY=<porter-api-token>
39+
```
40+
41+
The SDK then calls the sandbox API through the Porter API at
42+
`dashboard.porter.run`. To target a specific URL instead, set
43+
`PORTER_SANDBOX_BASE_URL` or pass `base_url` - both take precedence over
44+
everything above.
3145

3246
### Async
3347

‎porter_sandbox/_config.py‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import os
44
from dataclasses import dataclass
55

6-
DEFAULT_BASE_URL = "http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080"
6+
IN_CLUSTER_BASE_URL = "http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080"
7+
EXTERNAL_BASE_URL_FORMAT = (
8+
"https://dashboard.porter.run/api/v2/alpha/projects/{project_id}/clusters/{cluster_id}"
9+
)
710
DEFAULT_TIMEOUT_SECONDS = 30.0
811

912

@@ -21,8 +24,49 @@ def resolve(
2124
base_url: str | None = None,
2225
timeout: float | None = None,
2326
) -> Config:
27+
resolved_api_key = api_key or os.environ.get("PORTER_SANDBOX_API_KEY") or None
2428
return cls(
25-
api_key=api_key or os.environ.get("PORTER_SANDBOX_API_KEY") or None,
26-
base_url=(base_url or os.environ.get("PORTER_SANDBOX_BASE_URL") or DEFAULT_BASE_URL).rstrip("/"),
29+
api_key=resolved_api_key,
30+
base_url=_resolve_base_url(base_url, resolved_api_key).rstrip("/"),
2731
timeout=timeout if timeout is not None else DEFAULT_TIMEOUT_SECONDS,
2832
)
33+
34+
35+
def _resolve_base_url(base_url: str | None, api_key: str | None) -> str:
36+
explicit = base_url or os.environ.get("PORTER_SANDBOX_BASE_URL")
37+
if explicit:
38+
return explicit
39+
40+
project_id = os.environ.get("PORTER_PROJECT_ID")
41+
cluster_id = os.environ.get("PORTER_CLUSTER_ID")
42+
if project_id and cluster_id:
43+
if not api_key:
44+
raise ValueError(
45+
"PORTER_PROJECT_ID and PORTER_CLUSTER_ID are set, so the SDK will call the "
46+
"Porter API from outside the cluster, which requires an API token. Set "
47+
"PORTER_SANDBOX_API_KEY or pass api_key. You can create an API token from "
48+
"Settings > API tokens in the Porter Dashboard (requires admin permissions)."
49+
)
50+
return EXTERNAL_BASE_URL_FORMAT.format(project_id=project_id, cluster_id=cluster_id)
51+
if project_id or cluster_id:
52+
set_var, missing_var = (
53+
("PORTER_PROJECT_ID", "PORTER_CLUSTER_ID")
54+
if project_id
55+
else ("PORTER_CLUSTER_ID", "PORTER_PROJECT_ID")
56+
)
57+
raise ValueError(
58+
f"{set_var} is set but {missing_var} is not. Set both to call the Porter API "
59+
"from outside the cluster."
60+
)
61+
62+
# Kubernetes sets KUBERNETES_SERVICE_HOST in every pod, so its presence means the
63+
# in-cluster sandbox API service address is at least reachable in principle.
64+
if os.environ.get("KUBERNETES_SERVICE_HOST"):
65+
return IN_CLUSTER_BASE_URL
66+
67+
raise ValueError(
68+
"Could not determine the sandbox API base URL. Either run inside a sandbox-enabled "
69+
"Porter cluster, or set PORTER_PROJECT_ID and PORTER_CLUSTER_ID (plus "
70+
"PORTER_SANDBOX_API_KEY) to call the Porter API from outside the cluster. You can "
71+
"also set PORTER_SANDBOX_BASE_URL or pass base_url to target a specific URL."
72+
)

‎tests/test_config.py‎

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,36 @@
11
from __future__ import annotations
22

3+
import pytest
34
from pytest import MonkeyPatch
45

56
from porter_sandbox._config import Config
67

7-
DEFAULT_BASE_URL = "http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080"
8+
IN_CLUSTER_BASE_URL = "http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080"
9+
10+
RESOLUTION_ENV_VARS = (
11+
"PORTER_SANDBOX_BASE_URL",
12+
"PORTER_SANDBOX_API_KEY",
13+
"PORTER_PROJECT_ID",
14+
"PORTER_CLUSTER_ID",
15+
"KUBERNETES_SERVICE_HOST",
16+
)
17+
18+
19+
@pytest.fixture(autouse=True)
20+
def clean_env(monkeypatch: MonkeyPatch) -> None:
21+
for var in RESOLUTION_ENV_VARS:
22+
monkeypatch.delenv(var, raising=False)
823

924

1025
def test_config_falls_back_to_in_cluster_sandbox_api_url(monkeypatch: MonkeyPatch) -> None:
11-
monkeypatch.delenv("PORTER_SANDBOX_BASE_URL", raising=False)
26+
monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1")
1227

13-
assert Config.resolve().base_url == DEFAULT_BASE_URL
28+
assert Config.resolve().base_url == IN_CLUSTER_BASE_URL
1429

1530

16-
def test_config_uses_environment_base_url_when_no_base_url_is_passed(monkeypatch: MonkeyPatch) -> None:
31+
def test_config_uses_environment_base_url_when_no_base_url_is_passed(
32+
monkeypatch: MonkeyPatch,
33+
) -> None:
1734
monkeypatch.setenv("PORTER_SANDBOX_BASE_URL", "https://sandbox.example/")
1835

1936
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
2239
def test_config_prefers_explicit_base_url_over_environment(monkeypatch: MonkeyPatch) -> None:
2340
monkeypatch.setenv("PORTER_SANDBOX_BASE_URL", "https://sandbox.example")
2441

25-
assert Config.resolve(base_url="https://sandbox.override/").base_url == "https://sandbox.override"
42+
assert (
43+
Config.resolve(base_url="https://sandbox.override/").base_url == "https://sandbox.override"
44+
)
45+
46+
47+
def test_config_builds_external_url_from_project_and_cluster_ids(monkeypatch: MonkeyPatch) -> None:
48+
monkeypatch.setenv("PORTER_PROJECT_ID", "123")
49+
monkeypatch.setenv("PORTER_CLUSTER_ID", "456")
50+
monkeypatch.setenv("PORTER_SANDBOX_API_KEY", "token")
51+
52+
assert (
53+
Config.resolve().base_url
54+
== "https://dashboard.porter.run/api/v2/alpha/projects/123/clusters/456"
55+
)
56+
57+
58+
def test_config_prefers_project_and_cluster_ids_over_in_cluster_detection(
59+
monkeypatch: MonkeyPatch,
60+
) -> None:
61+
monkeypatch.setenv("PORTER_PROJECT_ID", "123")
62+
monkeypatch.setenv("PORTER_CLUSTER_ID", "456")
63+
monkeypatch.setenv("PORTER_SANDBOX_API_KEY", "token")
64+
monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1")
65+
66+
assert (
67+
Config.resolve().base_url
68+
== "https://dashboard.porter.run/api/v2/alpha/projects/123/clusters/456"
69+
)
70+
71+
72+
def test_config_prefers_environment_base_url_over_project_and_cluster_ids(
73+
monkeypatch: MonkeyPatch,
74+
) -> None:
75+
monkeypatch.setenv("PORTER_PROJECT_ID", "123")
76+
monkeypatch.setenv("PORTER_CLUSTER_ID", "456")
77+
monkeypatch.setenv("PORTER_SANDBOX_BASE_URL", "https://sandbox.example")
78+
79+
assert Config.resolve().base_url == "https://sandbox.example"
80+
81+
82+
def test_config_requires_api_key_for_external_url(monkeypatch: MonkeyPatch) -> None:
83+
monkeypatch.setenv("PORTER_PROJECT_ID", "123")
84+
monkeypatch.setenv("PORTER_CLUSTER_ID", "456")
85+
86+
with pytest.raises(ValueError, match="PORTER_SANDBOX_API_KEY"):
87+
Config.resolve()
88+
89+
90+
def test_config_accepts_api_key_argument_for_external_url(monkeypatch: MonkeyPatch) -> None:
91+
monkeypatch.setenv("PORTER_PROJECT_ID", "123")
92+
monkeypatch.setenv("PORTER_CLUSTER_ID", "456")
93+
94+
config = Config.resolve(api_key="token")
95+
96+
assert config.base_url == "https://dashboard.porter.run/api/v2/alpha/projects/123/clusters/456"
97+
98+
99+
def test_config_rejects_project_id_without_cluster_id(monkeypatch: MonkeyPatch) -> None:
100+
monkeypatch.setenv("PORTER_PROJECT_ID", "123")
101+
102+
with pytest.raises(ValueError, match="PORTER_CLUSTER_ID is not"):
103+
Config.resolve()
104+
105+
106+
def test_config_rejects_cluster_id_without_project_id(monkeypatch: MonkeyPatch) -> None:
107+
monkeypatch.setenv("PORTER_CLUSTER_ID", "456")
108+
109+
with pytest.raises(ValueError, match="PORTER_PROJECT_ID is not"):
110+
Config.resolve()
111+
112+
113+
def test_config_raises_when_base_url_cannot_be_determined() -> None:
114+
with pytest.raises(ValueError, match="Could not determine the sandbox API base URL"):
115+
Config.resolve()

0 commit comments

Comments
 (0)