developer-platform-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- developer_platform_cli-0.1.0/.gitignore +3 -0
- developer_platform_cli-0.1.0/PKG-INFO +40 -0
- developer_platform_cli-0.1.0/README.md +26 -0
- developer_platform_cli-0.1.0/pyproject.toml +32 -0
- developer_platform_cli-0.1.0/src/platform_cli/__init__.py +3 -0
- developer_platform_cli-0.1.0/src/platform_cli/__main__.py +128 -0
- developer_platform_cli-0.1.0/src/platform_cli/client.py +127 -0
- developer_platform_cli-0.1.0/src/platform_cli/config.py +81 -0
- developer_platform_cli-0.1.0/src/platform_cli/errors.py +45 -0
- developer_platform_cli-0.1.0/src/platform_cli/models.py +46 -0
- developer_platform_cli-0.1.0/tests/test_client.py +54 -0
- developer_platform_cli-0.1.0/tests/test_commands.py +16 -0
- developer_platform_cli-0.1.0/tests/test_config.py +32 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: developer-platform-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for the Developer Platform API
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: httpx<1,>=0.28
|
|
7
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
8
|
+
Requires-Dist: rich<14,>=13.9
|
|
9
|
+
Requires-Dist: typer<1,>=0.15
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest<9,>=8.3; extra == 'dev'
|
|
12
|
+
Requires-Dist: respx<1,>=0.22; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Developer Platform CLI
|
|
16
|
+
|
|
17
|
+
`devplat` is the command-line client for the Developer Platform API. Its first
|
|
18
|
+
supported workflow is CI-style deployment using a project-scoped deployment
|
|
19
|
+
credential supplied only through environment variables.
|
|
20
|
+
|
|
21
|
+
Install an editable development copy:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python3 -m pip install -e 'platform/cli[dev]'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Set the values returned when the deployment credential was created. Keep the secret
|
|
28
|
+
in the CI system's protected environment and disable shell tracing around this step:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
export PLATFORM_API_URL=https://platform.example
|
|
32
|
+
export PLATFORM_TOKEN_ENDPOINT=https://identity.example/realms/platform/protocol/openid-connect/token
|
|
33
|
+
export PLATFORM_CLIENT_ID='...'
|
|
34
|
+
export PLATFORM_CLIENT_SECRET='...'
|
|
35
|
+
devplat deploy apply --project hello --file examples/project.json --wait
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`PLATFORM_ACCESS_TOKEN` may be used instead of the deployment-credential variables
|
|
39
|
+
for a short-lived, externally obtained token. The CLI never persists either form of
|
|
40
|
+
authentication material.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Developer Platform CLI
|
|
2
|
+
|
|
3
|
+
`devplat` is the command-line client for the Developer Platform API. Its first
|
|
4
|
+
supported workflow is CI-style deployment using a project-scoped deployment
|
|
5
|
+
credential supplied only through environment variables.
|
|
6
|
+
|
|
7
|
+
Install an editable development copy:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python3 -m pip install -e 'platform/cli[dev]'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Set the values returned when the deployment credential was created. Keep the secret
|
|
14
|
+
in the CI system's protected environment and disable shell tracing around this step:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
export PLATFORM_API_URL=https://platform.example
|
|
18
|
+
export PLATFORM_TOKEN_ENDPOINT=https://identity.example/realms/platform/protocol/openid-connect/token
|
|
19
|
+
export PLATFORM_CLIENT_ID='...'
|
|
20
|
+
export PLATFORM_CLIENT_SECRET='...'
|
|
21
|
+
devplat deploy apply --project hello --file examples/project.json --wait
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`PLATFORM_ACCESS_TOKEN` may be used instead of the deployment-credential variables
|
|
25
|
+
for a short-lived, externally obtained token. The CLI never persists either form of
|
|
26
|
+
authentication material.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "developer-platform-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Command-line client for the Developer Platform API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"httpx>=0.28,<1",
|
|
13
|
+
"pydantic>=2.10,<3",
|
|
14
|
+
"rich>=13.9,<14",
|
|
15
|
+
"typer>=0.15,<1",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.3,<9",
|
|
21
|
+
"respx>=0.22,<1",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
devplat = "platform_cli.__main__:main"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/platform_cli"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
addopts = "-q"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The devplat executable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from .client import OperationFailed, OperationTimeout, PlatformClient, wait_for_operation
|
|
14
|
+
from .config import ConfigurationError, authentication_source, runtime_config
|
|
15
|
+
from .errors import ApiError
|
|
16
|
+
from .models import OperationStatus, application_body
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False, help="Developer Platform command-line client.")
|
|
20
|
+
deploy_app = typer.Typer(no_args_is_help=True, help="Deploy versioned application specifications.")
|
|
21
|
+
auth_app = typer.Typer(no_args_is_help=True, help="Inspect the active non-persistent authentication source.")
|
|
22
|
+
app.add_typer(deploy_app, name="deploy")
|
|
23
|
+
app.add_typer(auth_app, name="auth")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read_json(path: str) -> dict[str, object]:
|
|
27
|
+
try:
|
|
28
|
+
contents = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8")
|
|
29
|
+
return application_body(json.loads(contents))
|
|
30
|
+
except (OSError, json.JSONDecodeError, ValueError) as error:
|
|
31
|
+
raise typer.BadParameter(f"unable to read a JSON application specification: {error}", param_hint="--file") from error
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _emit(value: object, output: str) -> None:
|
|
35
|
+
if output == "json":
|
|
36
|
+
typer.echo(json.dumps(value, separators=(",", ":"), default=str))
|
|
37
|
+
return
|
|
38
|
+
if isinstance(value, dict) and "operation_id" in value:
|
|
39
|
+
typer.echo(f"Operation {value['operation_id']} is {value.get('state', 'unknown')} (revision {value.get('revision', 'unknown')}).")
|
|
40
|
+
return
|
|
41
|
+
typer.echo(str(value))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _failure(error: Exception, output: str) -> typer.Exit:
|
|
45
|
+
if isinstance(error, ApiError):
|
|
46
|
+
payload = {"status": error.status_code, "detail": error.detail, "code": error.code, "request_id": error.request_id}
|
|
47
|
+
if output == "json":
|
|
48
|
+
typer.echo(json.dumps(payload, separators=(",", ":")), err=True)
|
|
49
|
+
else:
|
|
50
|
+
typer.echo(error.detail, err=True)
|
|
51
|
+
return typer.Exit(error.exit_code)
|
|
52
|
+
if isinstance(error, ConfigurationError):
|
|
53
|
+
typer.echo(str(error), err=True)
|
|
54
|
+
return typer.Exit(2)
|
|
55
|
+
if isinstance(error, OperationFailed):
|
|
56
|
+
typer.echo(f"Operation {error.status.operation_id} failed: {error}", err=True)
|
|
57
|
+
return typer.Exit(9)
|
|
58
|
+
if isinstance(error, OperationTimeout):
|
|
59
|
+
typer.echo(f"{error} Resume with devplat operations watch {error.operation_id} when available.", err=True)
|
|
60
|
+
return typer.Exit(10)
|
|
61
|
+
typer.echo(str(error), err=True)
|
|
62
|
+
return typer.Exit(8)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@auth_app.command("status")
|
|
66
|
+
def auth_status(
|
|
67
|
+
output: Annotated[str, typer.Option("--output", case_sensitive=False)] = "table",
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Show the active authentication source without disclosing credentials."""
|
|
70
|
+
try:
|
|
71
|
+
source = authentication_source(runtime_config())
|
|
72
|
+
_emit({"source": source, "persistent": False}, output)
|
|
73
|
+
except Exception as error:
|
|
74
|
+
raise _failure(error, output) from None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@deploy_app.command("apply")
|
|
78
|
+
def deploy_apply(
|
|
79
|
+
project: Annotated[str, typer.Option("--project", min=1, help="Target project name.")],
|
|
80
|
+
file: Annotated[str, typer.Option("--file", help="JSON specification file, or - for stdin.")],
|
|
81
|
+
wait: Annotated[bool, typer.Option("--wait", help="Wait for durable operation success and readiness.")] = False,
|
|
82
|
+
timeout: Annotated[float, typer.Option("--timeout", min=1.0, help="Maximum wait time in seconds.")] = 300.0,
|
|
83
|
+
poll_interval: Annotated[float, typer.Option("--poll-interval", min=0.1, help="Operation poll interval in seconds.")] = 2.0,
|
|
84
|
+
if_match: Annotated[str | None, typer.Option("--if-match", help="Expected current revision.")] = None,
|
|
85
|
+
output: Annotated[str, typer.Option("--output", case_sensitive=False)] = "table",
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Apply one JSON application specification using environment authentication."""
|
|
88
|
+
if output not in {"table", "json", "plain"}:
|
|
89
|
+
raise typer.BadParameter("must be table, json, or plain", param_hint="--output")
|
|
90
|
+
try:
|
|
91
|
+
body = _read_json(file)
|
|
92
|
+
config = runtime_config()
|
|
93
|
+
client = PlatformClient(config)
|
|
94
|
+
try:
|
|
95
|
+
token = client.access_token()
|
|
96
|
+
accepted = client.deploy(project, body, token, if_match)
|
|
97
|
+
if not wait:
|
|
98
|
+
_emit(accepted.model_dump(), output)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
def update(status: OperationStatus) -> None:
|
|
102
|
+
if output != "json":
|
|
103
|
+
typer.echo(
|
|
104
|
+
f"Operation {status.operation_id}: {status.state}; readiness {status.readiness.state}.",
|
|
105
|
+
err=True,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
completed = wait_for_operation(
|
|
109
|
+
client, accepted, token, timeout, poll_interval, time.monotonic, time.sleep, update
|
|
110
|
+
)
|
|
111
|
+
_emit(completed.model_dump(), output)
|
|
112
|
+
finally:
|
|
113
|
+
client.close()
|
|
114
|
+
except typer.BadParameter:
|
|
115
|
+
raise
|
|
116
|
+
except KeyboardInterrupt:
|
|
117
|
+
typer.echo("Waiting interrupted; the durable platform operation continues.", err=True)
|
|
118
|
+
raise typer.Exit(10) from None
|
|
119
|
+
except Exception as error:
|
|
120
|
+
raise _failure(error, output) from None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main() -> None:
|
|
124
|
+
app()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
main()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""HTTP and OIDC client boundary for deployment credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from .config import DeploymentCredentialAuth, RuntimeConfig, authentication_source
|
|
12
|
+
from .errors import ApiError, safe_error_body
|
|
13
|
+
from .models import OperationAccepted, OperationStatus, TokenResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PlatformClient:
|
|
17
|
+
def __init__(self, config: RuntimeConfig, http_client: httpx.Client | None = None):
|
|
18
|
+
self.config = config
|
|
19
|
+
self._http = http_client or httpx.Client(timeout=httpx.Timeout(30.0), verify=config.ca_bundle or True)
|
|
20
|
+
self._owns_http = http_client is None
|
|
21
|
+
|
|
22
|
+
def close(self) -> None:
|
|
23
|
+
if self._owns_http:
|
|
24
|
+
self._http.close()
|
|
25
|
+
|
|
26
|
+
def access_token(self) -> str:
|
|
27
|
+
source = authentication_source(self.config)
|
|
28
|
+
if source == "access-token-environment":
|
|
29
|
+
assert self.config.access_token_auth is not None
|
|
30
|
+
return self.config.access_token_auth.access_token
|
|
31
|
+
assert self.config.deployment_credential_auth is not None
|
|
32
|
+
return self._client_credentials_token(self.config.deployment_credential_auth)
|
|
33
|
+
|
|
34
|
+
def deploy(self, project: str, body: dict[str, Any], access_token: str, if_match: str | None = None) -> OperationAccepted:
|
|
35
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
36
|
+
if if_match is not None:
|
|
37
|
+
headers["If-Match"] = if_match
|
|
38
|
+
response = self._request("PUT", f"/projects/{project}", headers=headers, json=body)
|
|
39
|
+
try:
|
|
40
|
+
return OperationAccepted.model_validate(response.json())
|
|
41
|
+
except (ValidationError, ValueError) as error:
|
|
42
|
+
raise RuntimeError("The platform returned an invalid deployment operation.") from error
|
|
43
|
+
|
|
44
|
+
def operation(self, status_url: str, access_token: str) -> OperationStatus:
|
|
45
|
+
if not status_url.startswith("/") or status_url.startswith("//"):
|
|
46
|
+
raise RuntimeError("The platform returned an unsafe operation status URL.")
|
|
47
|
+
response = self._request("GET", status_url, headers={"Authorization": f"Bearer {access_token}"})
|
|
48
|
+
try:
|
|
49
|
+
return OperationStatus.model_validate(response.json())
|
|
50
|
+
except (ValidationError, ValueError) as error:
|
|
51
|
+
raise RuntimeError("The platform returned an invalid operation status.") from error
|
|
52
|
+
|
|
53
|
+
def _client_credentials_token(self, credential: DeploymentCredentialAuth) -> str:
|
|
54
|
+
try:
|
|
55
|
+
response = self._http.post(
|
|
56
|
+
credential.token_endpoint,
|
|
57
|
+
data={
|
|
58
|
+
"grant_type": "client_credentials",
|
|
59
|
+
"client_id": credential.client_id,
|
|
60
|
+
"client_secret": credential.client_secret,
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
except httpx.HTTPError as error:
|
|
64
|
+
raise RuntimeError("Unable to reach the OIDC token endpoint.") from error
|
|
65
|
+
self._raise_for_error(response)
|
|
66
|
+
try:
|
|
67
|
+
token = TokenResponse.model_validate(response.json())
|
|
68
|
+
except (ValidationError, ValueError) as error:
|
|
69
|
+
raise RuntimeError("The OIDC provider returned an invalid token response.") from error
|
|
70
|
+
if token.token_type.casefold() != "bearer":
|
|
71
|
+
raise RuntimeError("The OIDC provider returned an unsupported token type.")
|
|
72
|
+
return token.access_token
|
|
73
|
+
|
|
74
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
75
|
+
try:
|
|
76
|
+
response = self._http.request(method, self.config.api_url + path, **kwargs)
|
|
77
|
+
except httpx.HTTPError as error:
|
|
78
|
+
raise RuntimeError("Unable to reach the Platform API.") from error
|
|
79
|
+
self._raise_for_error(response)
|
|
80
|
+
return response
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _raise_for_error(response: httpx.Response) -> None:
|
|
84
|
+
if response.is_success:
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
detail, code = safe_error_body(response.json())
|
|
88
|
+
except ValueError:
|
|
89
|
+
detail, code = "The platform returned an error without a safe detail.", None
|
|
90
|
+
request_id = response.headers.get("x-request-id") or response.headers.get("x-correlation-id")
|
|
91
|
+
raise ApiError(response.status_code, detail, code, request_id)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def wait_for_operation(
|
|
95
|
+
client: PlatformClient,
|
|
96
|
+
accepted: OperationAccepted,
|
|
97
|
+
access_token: str,
|
|
98
|
+
timeout_seconds: float,
|
|
99
|
+
interval_seconds: float,
|
|
100
|
+
clock: Callable[[], float],
|
|
101
|
+
sleep: Callable[[float], None],
|
|
102
|
+
on_update: Callable[[OperationStatus], None],
|
|
103
|
+
) -> OperationStatus:
|
|
104
|
+
"""Wait for apply success and live readiness without cancelling a durable operation."""
|
|
105
|
+
deadline = clock() + timeout_seconds
|
|
106
|
+
while True:
|
|
107
|
+
status = client.operation(accepted.status_url, access_token)
|
|
108
|
+
on_update(status)
|
|
109
|
+
if status.state in {"failed", "cancelled"}:
|
|
110
|
+
raise OperationFailed(status)
|
|
111
|
+
if status.state == "succeeded" and status.readiness.state == "ready":
|
|
112
|
+
return status
|
|
113
|
+
if clock() >= deadline:
|
|
114
|
+
raise OperationTimeout(accepted.operation_id)
|
|
115
|
+
sleep(interval_seconds)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class OperationFailed(RuntimeError):
|
|
119
|
+
def __init__(self, status: OperationStatus):
|
|
120
|
+
super().__init__(status.error_code or "The platform operation failed.")
|
|
121
|
+
self.status = status
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class OperationTimeout(TimeoutError):
|
|
125
|
+
def __init__(self, operation_id: str):
|
|
126
|
+
super().__init__(f"Timed out while waiting for operation {operation_id}.")
|
|
127
|
+
self.operation_id = operation_id
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Non-persistent authentication configuration for the first CLI increment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigurationError(ValueError):
|
|
10
|
+
"""Raised when CLI authentication environment is incomplete or invalid."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AccessTokenAuth:
|
|
15
|
+
access_token: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DeploymentCredentialAuth:
|
|
20
|
+
token_endpoint: str
|
|
21
|
+
client_id: str
|
|
22
|
+
client_secret: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class RuntimeConfig:
|
|
27
|
+
api_url: str
|
|
28
|
+
ca_bundle: str | None
|
|
29
|
+
access_token_auth: AccessTokenAuth | None
|
|
30
|
+
deployment_credential_auth: DeploymentCredentialAuth | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def runtime_config(environment: dict[str, str] | None = None) -> RuntimeConfig:
|
|
34
|
+
"""Read the non-persistent command configuration from an environment mapping."""
|
|
35
|
+
env = os.environ if environment is None else environment
|
|
36
|
+
api_url = env.get("PLATFORM_API_URL", "").rstrip("/")
|
|
37
|
+
if not api_url:
|
|
38
|
+
raise ConfigurationError("PLATFORM_API_URL is required")
|
|
39
|
+
|
|
40
|
+
token = env.get("PLATFORM_ACCESS_TOKEN")
|
|
41
|
+
if token:
|
|
42
|
+
return RuntimeConfig(
|
|
43
|
+
api_url=api_url,
|
|
44
|
+
ca_bundle=env.get("PLATFORM_CA_BUNDLE") or None,
|
|
45
|
+
access_token_auth=AccessTokenAuth(token),
|
|
46
|
+
deployment_credential_auth=None,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
credential_values = {
|
|
50
|
+
"PLATFORM_TOKEN_ENDPOINT": env.get("PLATFORM_TOKEN_ENDPOINT"),
|
|
51
|
+
"PLATFORM_CLIENT_ID": env.get("PLATFORM_CLIENT_ID"),
|
|
52
|
+
"PLATFORM_CLIENT_SECRET": env.get("PLATFORM_CLIENT_SECRET"),
|
|
53
|
+
}
|
|
54
|
+
present = {name for name, value in credential_values.items() if value}
|
|
55
|
+
if present and len(present) != len(credential_values):
|
|
56
|
+
missing = sorted(set(credential_values) - present)
|
|
57
|
+
raise ConfigurationError("deployment credential environment is incomplete; missing " + ", ".join(missing))
|
|
58
|
+
|
|
59
|
+
credential = None
|
|
60
|
+
if len(present) == len(credential_values):
|
|
61
|
+
credential = DeploymentCredentialAuth(
|
|
62
|
+
token_endpoint=credential_values["PLATFORM_TOKEN_ENDPOINT"] or "",
|
|
63
|
+
client_id=credential_values["PLATFORM_CLIENT_ID"] or "",
|
|
64
|
+
client_secret=credential_values["PLATFORM_CLIENT_SECRET"] or "",
|
|
65
|
+
)
|
|
66
|
+
return RuntimeConfig(
|
|
67
|
+
api_url=api_url,
|
|
68
|
+
ca_bundle=env.get("PLATFORM_CA_BUNDLE") or None,
|
|
69
|
+
access_token_auth=None,
|
|
70
|
+
deployment_credential_auth=credential,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def authentication_source(config: RuntimeConfig) -> str:
|
|
75
|
+
if config.access_token_auth is not None:
|
|
76
|
+
return "access-token-environment"
|
|
77
|
+
if config.deployment_credential_auth is not None:
|
|
78
|
+
return "deployment-credential-environment"
|
|
79
|
+
raise ConfigurationError(
|
|
80
|
+
"set PLATFORM_ACCESS_TOKEN or PLATFORM_TOKEN_ENDPOINT, PLATFORM_CLIENT_ID, and PLATFORM_CLIENT_SECRET"
|
|
81
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Safe API error mapping and stable command exit codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ApiError(Exception):
|
|
11
|
+
status_code: int
|
|
12
|
+
detail: str
|
|
13
|
+
code: str | None = None
|
|
14
|
+
request_id: str | None = None
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def exit_code(self) -> int:
|
|
18
|
+
return exit_code_for_status(self.status_code)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def exit_code_for_status(status_code: int) -> int:
|
|
22
|
+
if status_code in {401}:
|
|
23
|
+
return 3
|
|
24
|
+
if status_code == 403:
|
|
25
|
+
return 4
|
|
26
|
+
if status_code == 404:
|
|
27
|
+
return 5
|
|
28
|
+
if status_code == 409:
|
|
29
|
+
return 6
|
|
30
|
+
if status_code in {400, 422}:
|
|
31
|
+
return 7
|
|
32
|
+
if status_code == 429 or status_code >= 500:
|
|
33
|
+
return 8
|
|
34
|
+
return 8
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def safe_error_body(value: Any) -> tuple[str, str | None]:
|
|
38
|
+
if not isinstance(value, dict):
|
|
39
|
+
return "The platform returned an error without a safe detail.", None
|
|
40
|
+
detail = value.get("detail")
|
|
41
|
+
code = value.get("code")
|
|
42
|
+
return (
|
|
43
|
+
detail if isinstance(detail, str) else "The platform returned an error without a safe detail.",
|
|
44
|
+
code if isinstance(code, str) else None,
|
|
45
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Strict boundary models for the first deployment workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TokenResponse(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="ignore")
|
|
12
|
+
|
|
13
|
+
access_token: str = Field(min_length=1)
|
|
14
|
+
token_type: str = Field(min_length=1)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OperationAccepted(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="forbid")
|
|
19
|
+
|
|
20
|
+
operation_id: str = Field(min_length=1)
|
|
21
|
+
state: str = Field(min_length=1)
|
|
22
|
+
revision: int = Field(ge=0)
|
|
23
|
+
status_url: str = Field(min_length=1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Readiness(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="allow")
|
|
28
|
+
|
|
29
|
+
state: str = Field(min_length=1)
|
|
30
|
+
reason: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OperationStatus(BaseModel):
|
|
34
|
+
model_config = ConfigDict(extra="allow")
|
|
35
|
+
|
|
36
|
+
operation_id: str = Field(min_length=1)
|
|
37
|
+
state: str = Field(min_length=1)
|
|
38
|
+
revision: int = Field(ge=0)
|
|
39
|
+
error_code: str | None = None
|
|
40
|
+
readiness: Readiness
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def application_body(value: Any) -> dict[str, Any]:
|
|
44
|
+
if not isinstance(value, dict):
|
|
45
|
+
raise ValueError("application specification must be a JSON object")
|
|
46
|
+
return value
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from platform_cli.client import PlatformClient, wait_for_operation
|
|
4
|
+
from platform_cli.config import runtime_config
|
|
5
|
+
from platform_cli.models import OperationAccepted
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_deployment_credential_exchange_and_apply_use_bearer_token():
|
|
9
|
+
requests: list[httpx.Request] = []
|
|
10
|
+
|
|
11
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
12
|
+
requests.append(request)
|
|
13
|
+
if request.url.host == "identity.example":
|
|
14
|
+
return httpx.Response(200, json={"access_token": "short-lived", "token_type": "Bearer"})
|
|
15
|
+
return httpx.Response(202, json={
|
|
16
|
+
"operation_id": "operation-1", "state": "queued", "revision": 1, "status_url": "/v1/operations/operation-1",
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
config = runtime_config({
|
|
20
|
+
"PLATFORM_API_URL": "https://platform.example",
|
|
21
|
+
"PLATFORM_TOKEN_ENDPOINT": "https://identity.example/token",
|
|
22
|
+
"PLATFORM_CLIENT_ID": "client-id",
|
|
23
|
+
"PLATFORM_CLIENT_SECRET": "client-secret",
|
|
24
|
+
})
|
|
25
|
+
client = PlatformClient(config, httpx.Client(transport=httpx.MockTransport(handler)))
|
|
26
|
+
|
|
27
|
+
token = client.access_token()
|
|
28
|
+
accepted = client.deploy("smoke", {"name": "smoke", "image": "example/image:1"}, token)
|
|
29
|
+
|
|
30
|
+
assert accepted.operation_id == "operation-1"
|
|
31
|
+
assert requests[0].url == "https://identity.example/token"
|
|
32
|
+
assert requests[1].headers["authorization"] == "Bearer short-lived"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_wait_requires_apply_success_and_readiness():
|
|
36
|
+
responses = iter([
|
|
37
|
+
{"operation_id": "operation-1", "revision": 1, "state": "running", "error_code": None, "readiness": {"state": "progressing", "reason": "pods_not_ready"}},
|
|
38
|
+
{"operation_id": "operation-1", "revision": 1, "state": "succeeded", "error_code": None, "readiness": {"state": "ready", "reason": None}},
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
class FakeClient:
|
|
42
|
+
def operation(self, _url: str, _token: str):
|
|
43
|
+
from platform_cli.models import OperationStatus
|
|
44
|
+
return OperationStatus.model_validate(next(responses))
|
|
45
|
+
|
|
46
|
+
ticks = iter([0.0, 0.0, 1.0])
|
|
47
|
+
updates = []
|
|
48
|
+
completed = wait_for_operation(
|
|
49
|
+
FakeClient(), OperationAccepted(operation_id="operation-1", state="queued", revision=1, status_url="/v1/operations/operation-1"),
|
|
50
|
+
"token", 10.0, 1.0, lambda: next(ticks), lambda _seconds: None, updates.append,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
assert completed.readiness.state == "ready"
|
|
54
|
+
assert len(updates) == 2
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from typer.testing import CliRunner
|
|
4
|
+
|
|
5
|
+
from platform_cli.__main__ import app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_auth_status_reports_environment_source_without_token(monkeypatch):
|
|
9
|
+
monkeypatch.setenv("PLATFORM_API_URL", "https://platform.example")
|
|
10
|
+
monkeypatch.setenv("PLATFORM_ACCESS_TOKEN", "should-not-appear")
|
|
11
|
+
|
|
12
|
+
result = CliRunner().invoke(app, ["auth", "status", "--output", "json"])
|
|
13
|
+
|
|
14
|
+
assert result.exit_code == 0
|
|
15
|
+
assert json.loads(result.stdout) == {"source": "access-token-environment", "persistent": False}
|
|
16
|
+
assert "should-not-appear" not in result.output
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from platform_cli.config import ConfigurationError, authentication_source, runtime_config
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_deployment_credential_environment_is_selected():
|
|
5
|
+
config = runtime_config({
|
|
6
|
+
"PLATFORM_API_URL": "https://platform.example/",
|
|
7
|
+
"PLATFORM_TOKEN_ENDPOINT": "https://identity.example/token",
|
|
8
|
+
"PLATFORM_CLIENT_ID": "client-id",
|
|
9
|
+
"PLATFORM_CLIENT_SECRET": "client-secret",
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
assert config.api_url == "https://platform.example"
|
|
13
|
+
assert authentication_source(config) == "deployment-credential-environment"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_partial_deployment_credential_environment_fails_closed():
|
|
17
|
+
try:
|
|
18
|
+
runtime_config({"PLATFORM_API_URL": "https://platform.example", "PLATFORM_CLIENT_ID": "client-id"})
|
|
19
|
+
except ConfigurationError as error:
|
|
20
|
+
assert "PLATFORM_CLIENT_SECRET" in str(error)
|
|
21
|
+
else:
|
|
22
|
+
raise AssertionError("expected incomplete credential environment to fail")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_access_token_takes_precedence_over_an_incomplete_credential_environment():
|
|
26
|
+
config = runtime_config({
|
|
27
|
+
"PLATFORM_API_URL": "https://platform.example",
|
|
28
|
+
"PLATFORM_ACCESS_TOKEN": "access-token",
|
|
29
|
+
"PLATFORM_CLIENT_ID": "client-id",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
assert authentication_source(config) == "access-token-environment"
|