lcloud-cli 0.1.0__py3-none-any.whl
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.
- lambda_cloud/__init__.py +3 -0
- lambda_cloud/__main__.py +6 -0
- lambda_cloud/api/__init__.py +17 -0
- lambda_cloud/api/client.py +150 -0
- lambda_cloud/api/service.py +242 -0
- lambda_cloud/cli/__init__.py +1 -0
- lambda_cloud/cli/app.py +111 -0
- lambda_cloud/cli/commands/__init__.py +1 -0
- lambda_cloud/cli/commands/audit.py +37 -0
- lambda_cloud/cli/commands/auth.py +69 -0
- lambda_cloud/cli/commands/completion.py +37 -0
- lambda_cloud/cli/commands/config_cmd.py +45 -0
- lambda_cloud/cli/commands/filesystems.py +53 -0
- lambda_cloud/cli/commands/firewall.py +157 -0
- lambda_cloud/cli/commands/images.py +23 -0
- lambda_cloud/cli/commands/instance_types.py +19 -0
- lambda_cloud/cli/commands/instances.py +215 -0
- lambda_cloud/cli/commands/regions.py +19 -0
- lambda_cloud/cli/commands/ssh_keys.py +99 -0
- lambda_cloud/cli/state.py +66 -0
- lambda_cloud/cli/ui/__init__.py +13 -0
- lambda_cloud/cli/ui/console.py +94 -0
- lambda_cloud/cli/ui/history.py +62 -0
- lambda_cloud/cli/ui/tables.py +258 -0
- lambda_cloud/core/__init__.py +1 -0
- lambda_cloud/core/config.py +125 -0
- lambda_cloud/core/errors.py +58 -0
- lambda_cloud/mngr/__init__.py +5 -0
- lambda_cloud/mngr/models.py +198 -0
- lcloud_cli-0.1.0.dist-info/METADATA +217 -0
- lcloud_cli-0.1.0.dist-info/RECORD +34 -0
- lcloud_cli-0.1.0.dist-info/WHEEL +4 -0
- lcloud_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lcloud_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
lambda_cloud/__init__.py
ADDED
lambda_cloud/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""API access layer: HTTP client and high-level service functions."""
|
|
2
|
+
|
|
3
|
+
from . import service
|
|
4
|
+
from .client import (
|
|
5
|
+
DEFAULT_BASE_URL,
|
|
6
|
+
DEFAULT_MAX_RETRIES,
|
|
7
|
+
DEFAULT_MIN_INTERVAL_SECONDS,
|
|
8
|
+
LambdaCloudClient,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DEFAULT_BASE_URL",
|
|
13
|
+
"DEFAULT_MAX_RETRIES",
|
|
14
|
+
"DEFAULT_MIN_INTERVAL_SECONDS",
|
|
15
|
+
"LambdaCloudClient",
|
|
16
|
+
"service",
|
|
17
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""HTTP client for the Lambda Cloud API.
|
|
2
|
+
|
|
3
|
+
Handles authentication, the documented rate limits (1 request/second in
|
|
4
|
+
general), retries on ``429 Too Many Requests``, and conversion of error
|
|
5
|
+
responses into :class:`~lambda_cloud.errors.APIError` exceptions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .. import __version__
|
|
17
|
+
from ..core.errors import APIError
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://cloud.lambda.ai/api/v1"
|
|
20
|
+
BASE_URL_ENV_VAR = "LAMBDA_CLOUD_API_URL"
|
|
21
|
+
MIN_INTERVAL_ENV_VAR = "LAMBDA_CLOUD_MIN_INTERVAL"
|
|
22
|
+
|
|
23
|
+
#: Default minimum delay between two API calls, per the documented rate limit.
|
|
24
|
+
DEFAULT_MIN_INTERVAL_SECONDS = 1.05
|
|
25
|
+
|
|
26
|
+
#: Maximum number of retries after a 429 response.
|
|
27
|
+
DEFAULT_MAX_RETRIES = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LambdaCloudClient:
|
|
31
|
+
"""Thin wrapper around :class:`httpx.Client` for the Lambda Cloud API."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str,
|
|
36
|
+
*,
|
|
37
|
+
base_url: str | None = None,
|
|
38
|
+
timeout: float = 30.0,
|
|
39
|
+
min_interval: float | None = None,
|
|
40
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
41
|
+
transport: httpx.BaseTransport | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
resolved_base_url = base_url or os.environ.get(BASE_URL_ENV_VAR) or DEFAULT_BASE_URL
|
|
44
|
+
if min_interval is None:
|
|
45
|
+
min_interval = float(os.environ.get(MIN_INTERVAL_ENV_VAR, DEFAULT_MIN_INTERVAL_SECONDS))
|
|
46
|
+
self._min_interval = min_interval
|
|
47
|
+
self._max_retries = max_retries
|
|
48
|
+
self._last_request_at = 0.0
|
|
49
|
+
self._client = httpx.Client(
|
|
50
|
+
base_url=resolved_base_url.rstrip("/"),
|
|
51
|
+
headers={
|
|
52
|
+
"Authorization": f"Bearer {api_key}",
|
|
53
|
+
"Accept": "application/json",
|
|
54
|
+
"User-Agent": f"lambda-cloud-cli/{__version__}",
|
|
55
|
+
},
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
transport=transport,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> LambdaCloudClient:
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
64
|
+
self.close()
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._client.close()
|
|
68
|
+
|
|
69
|
+
def _request(
|
|
70
|
+
self,
|
|
71
|
+
method: str,
|
|
72
|
+
path: str,
|
|
73
|
+
*,
|
|
74
|
+
params: dict[str, Any] | None = None,
|
|
75
|
+
json: Any | None = None,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
"""Perform one API call (with retries) and return the raw payload."""
|
|
78
|
+
for attempt in range(self._max_retries + 1):
|
|
79
|
+
self._throttle()
|
|
80
|
+
response = self._client.request(method, path, params=params, json=json)
|
|
81
|
+
|
|
82
|
+
if response.status_code == 429 and attempt < self._max_retries:
|
|
83
|
+
time.sleep(self._retry_delay(response))
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if not response.is_success:
|
|
87
|
+
raise APIError.from_response(response)
|
|
88
|
+
|
|
89
|
+
if response.status_code == 204 or not response.content:
|
|
90
|
+
return {}
|
|
91
|
+
payload = response.json()
|
|
92
|
+
return payload if isinstance(payload, dict) else {"data": payload}
|
|
93
|
+
|
|
94
|
+
# Unreachable in practice: the last attempt either returns or raises.
|
|
95
|
+
raise APIError(429, "global/rate-limited", "Too many requests after retries.")
|
|
96
|
+
|
|
97
|
+
def request(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
path: str,
|
|
101
|
+
*,
|
|
102
|
+
params: dict[str, Any] | None = None,
|
|
103
|
+
json: Any | None = None,
|
|
104
|
+
) -> Any:
|
|
105
|
+
"""Perform an API call and return the unwrapped ``data`` payload.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
APIError: if the API responds with a non-2xx status.
|
|
109
|
+
"""
|
|
110
|
+
return self._request(method, path, params=params, json=json).get("data")
|
|
111
|
+
|
|
112
|
+
def get_page(
|
|
113
|
+
self, path: str, *, params: dict[str, Any] | None = None
|
|
114
|
+
) -> tuple[Any, str | None]:
|
|
115
|
+
"""GET an endpoint that paginates with a top-level ``page_token``."""
|
|
116
|
+
payload = self._request("GET", path, params=params)
|
|
117
|
+
return payload.get("data"), payload.get("page_token")
|
|
118
|
+
|
|
119
|
+
def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
|
|
120
|
+
return self.request("GET", path, params=params)
|
|
121
|
+
|
|
122
|
+
def post(
|
|
123
|
+
self, path: str, *, json: Any | None = None, params: dict[str, Any] | None = None
|
|
124
|
+
) -> Any:
|
|
125
|
+
return self.request("POST", path, json=json, params=params)
|
|
126
|
+
|
|
127
|
+
def patch(self, path: str, *, json: Any | None = None) -> Any:
|
|
128
|
+
return self.request("PATCH", path, json=json)
|
|
129
|
+
|
|
130
|
+
def put(self, path: str, *, json: Any | None = None) -> Any:
|
|
131
|
+
return self.request("PUT", path, json=json)
|
|
132
|
+
|
|
133
|
+
def delete(self, path: str) -> Any:
|
|
134
|
+
return self.request("DELETE", path)
|
|
135
|
+
|
|
136
|
+
def _throttle(self) -> None:
|
|
137
|
+
"""Enforce the minimum interval between two API requests."""
|
|
138
|
+
if self._min_interval <= 0:
|
|
139
|
+
return
|
|
140
|
+
elapsed = time.monotonic() - self._last_request_at
|
|
141
|
+
if elapsed < self._min_interval:
|
|
142
|
+
time.sleep(self._min_interval - elapsed)
|
|
143
|
+
self._last_request_at = time.monotonic()
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _retry_delay(response: httpx.Response) -> float:
|
|
147
|
+
try:
|
|
148
|
+
return min(float(response.headers.get("Retry-After", "1")), 30.0)
|
|
149
|
+
except ValueError:
|
|
150
|
+
return 1.0
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""High-level API operations shared by commands and tests.
|
|
2
|
+
|
|
3
|
+
Every function takes a :class:`LambdaCloudClient` and returns validated
|
|
4
|
+
pydantic models, keeping commands free of HTTP details.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from ..core.errors import LambdaCloudError
|
|
12
|
+
from ..mngr.models import (
|
|
13
|
+
AuditEvent,
|
|
14
|
+
Filesystem,
|
|
15
|
+
FirewallRule,
|
|
16
|
+
FirewallRuleset,
|
|
17
|
+
GeneratedSSHKey,
|
|
18
|
+
GlobalFirewallRuleset,
|
|
19
|
+
Image,
|
|
20
|
+
Instance,
|
|
21
|
+
InstanceTypeOffer,
|
|
22
|
+
NetworkProtocol,
|
|
23
|
+
Region,
|
|
24
|
+
SSHKey,
|
|
25
|
+
TagEntry,
|
|
26
|
+
)
|
|
27
|
+
from .client import LambdaCloudClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_tags(tags: list[TagEntry]) -> list[dict[str, str]]:
|
|
31
|
+
"""Serialize tags for the launch payload."""
|
|
32
|
+
return [tag.model_dump() for tag in tags]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_firewall_rules(rules: list[FirewallRule]) -> None:
|
|
36
|
+
"""Enforce the API's port_range constraints before submitting rules."""
|
|
37
|
+
for rule in rules:
|
|
38
|
+
if rule.protocol is NetworkProtocol.ICMP and rule.port_range is not None:
|
|
39
|
+
raise LambdaCloudError("Firewall rule with protocol 'icmp' must not define port_range.")
|
|
40
|
+
if rule.protocol is not NetworkProtocol.ICMP and rule.port_range is None:
|
|
41
|
+
raise LambdaCloudError(
|
|
42
|
+
f"Firewall rule with protocol '{rule.protocol.value}' requires "
|
|
43
|
+
"port_range [min, max]."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def serialize_firewall_rules(rules: list[FirewallRule]) -> list[dict]:
|
|
48
|
+
"""Serialize rules, dropping an empty port_range for icmp."""
|
|
49
|
+
payload = []
|
|
50
|
+
for rule in rules:
|
|
51
|
+
item = rule.model_dump(mode="json")
|
|
52
|
+
if item.get("port_range") is None:
|
|
53
|
+
item.pop("port_range", None)
|
|
54
|
+
payload.append(item)
|
|
55
|
+
return payload
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def filter_images(
|
|
59
|
+
images: list[Image],
|
|
60
|
+
region: str | None = None,
|
|
61
|
+
family: str | None = None,
|
|
62
|
+
) -> list[Image]:
|
|
63
|
+
"""Apply client-side filters to an image list."""
|
|
64
|
+
if region:
|
|
65
|
+
images = [img for img in images if img.region and img.region.name == region]
|
|
66
|
+
if family:
|
|
67
|
+
images = [img for img in images if img.family == family]
|
|
68
|
+
return images
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sort_images_by_updated(images: list[Image]) -> list[Image]:
|
|
72
|
+
"""Sort images by last update, oldest first."""
|
|
73
|
+
return sorted(images, key=lambda img: img.updated_time or img.created_time or datetime.min)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_instance_types(data: dict[str, dict]) -> list[InstanceTypeOffer]:
|
|
77
|
+
"""Parse the dict response of ``GET /instance-types``."""
|
|
78
|
+
offers = [InstanceTypeOffer.model_validate(value) for value in data.values()]
|
|
79
|
+
return sorted(offers, key=lambda offer: offer.instance_type.price_cents_per_hour)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def list_instance_types(client: LambdaCloudClient) -> list[InstanceTypeOffer]:
|
|
83
|
+
"""Fetch and sort all instance type offers by hourly price."""
|
|
84
|
+
return parse_instance_types(client.get("/instance-types"))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------- thin wrappers
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def list_instances(client: LambdaCloudClient, cluster_id: str | None = None) -> list[Instance]:
|
|
91
|
+
params = {"cluster_id": cluster_id} if cluster_id else None
|
|
92
|
+
data = client.get("/instances", params=params)
|
|
93
|
+
return [Instance.model_validate(item) for item in data]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_instance(client: LambdaCloudClient, instance_id: str) -> Instance:
|
|
97
|
+
return Instance.model_validate(client.get(f"/instances/{instance_id}"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def launch_instances(client: LambdaCloudClient, payload: dict) -> list[str]:
|
|
101
|
+
data = client.post("/instance-operations/launch", json=payload)
|
|
102
|
+
return data["instance_ids"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def restart_instances(client: LambdaCloudClient, instance_ids: list[str]) -> list[Instance]:
|
|
106
|
+
data = client.post("/instance-operations/restart", json={"instance_ids": instance_ids})
|
|
107
|
+
return [Instance.model_validate(item) for item in data["restarted_instances"]]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def terminate_instances(client: LambdaCloudClient, instance_ids: list[str]) -> list[Instance]:
|
|
111
|
+
data = client.post("/instance-operations/terminate", json={"instance_ids": instance_ids})
|
|
112
|
+
return [Instance.model_validate(item) for item in data["terminated_instances"]]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def rename_instance(client: LambdaCloudClient, instance_id: str, name: str) -> None:
|
|
116
|
+
client.post(f"/instances/{instance_id}", json={"name": name})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def list_ssh_keys(client: LambdaCloudClient) -> list[SSHKey]:
|
|
120
|
+
data = client.get("/ssh-keys")
|
|
121
|
+
return [SSHKey.model_validate(item) for item in data]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def add_ssh_key(
|
|
125
|
+
client: LambdaCloudClient, name: str, public_key: str | None = None
|
|
126
|
+
) -> SSHKey | GeneratedSSHKey:
|
|
127
|
+
payload: dict[str, str] = {"name": name}
|
|
128
|
+
if public_key:
|
|
129
|
+
payload["public_key"] = public_key
|
|
130
|
+
data = client.post("/ssh-keys", json=payload)
|
|
131
|
+
if "private_key" in data:
|
|
132
|
+
return GeneratedSSHKey.model_validate(data)
|
|
133
|
+
return SSHKey.model_validate(data)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def delete_ssh_key(client: LambdaCloudClient, key_id: str) -> None:
|
|
137
|
+
client.delete(f"/ssh-keys/{key_id}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def list_filesystems(client: LambdaCloudClient) -> list[Filesystem]:
|
|
141
|
+
data = client.get("/file-systems")
|
|
142
|
+
return [Filesystem.model_validate(item) for item in data]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def create_filesystem(client: LambdaCloudClient, name: str, region: str) -> Filesystem:
|
|
146
|
+
data = client.post("/filesystems", json={"name": name, "region": region})
|
|
147
|
+
return Filesystem.model_validate(data)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def delete_filesystem(client: LambdaCloudClient, filesystem_id: str) -> None:
|
|
151
|
+
client.delete(f"/filesystems/{filesystem_id}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def list_images(client: LambdaCloudClient) -> list[Image]:
|
|
155
|
+
data = client.get("/images")
|
|
156
|
+
return [Image.model_validate(item) for item in data]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def list_regions(client: LambdaCloudClient) -> list[Region]:
|
|
160
|
+
data = client.get("/regions")
|
|
161
|
+
return [Region.model_validate(item) for item in data]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def list_firewall_rulesets(client: LambdaCloudClient) -> list[FirewallRuleset]:
|
|
165
|
+
data = client.get("/firewall-rulesets")
|
|
166
|
+
return [FirewallRuleset.model_validate(item) for item in data]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_firewall_ruleset(client: LambdaCloudClient, ruleset_id: str) -> FirewallRuleset:
|
|
170
|
+
return FirewallRuleset.model_validate(client.get(f"/firewall-rulesets/{ruleset_id}"))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def create_firewall_ruleset(
|
|
174
|
+
client: LambdaCloudClient, name: str, region: str, rules: list[FirewallRule]
|
|
175
|
+
) -> FirewallRuleset:
|
|
176
|
+
validate_firewall_rules(rules)
|
|
177
|
+
data = client.post(
|
|
178
|
+
"/firewall-rulesets",
|
|
179
|
+
json={"name": name, "region": region, "rules": serialize_firewall_rules(rules)},
|
|
180
|
+
)
|
|
181
|
+
return FirewallRuleset.model_validate(data)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def update_firewall_ruleset(
|
|
185
|
+
client: LambdaCloudClient,
|
|
186
|
+
ruleset_id: str,
|
|
187
|
+
name: str | None = None,
|
|
188
|
+
rules: list[FirewallRule] | None = None,
|
|
189
|
+
) -> FirewallRuleset:
|
|
190
|
+
payload: dict = {}
|
|
191
|
+
if name is not None:
|
|
192
|
+
payload["name"] = name
|
|
193
|
+
if rules is not None:
|
|
194
|
+
validate_firewall_rules(rules)
|
|
195
|
+
payload["rules"] = serialize_firewall_rules(rules)
|
|
196
|
+
data = client.patch(f"/firewall-rulesets/{ruleset_id}", json=payload)
|
|
197
|
+
return FirewallRuleset.model_validate(data)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def delete_firewall_ruleset(client: LambdaCloudClient, ruleset_id: str) -> None:
|
|
201
|
+
client.delete(f"/firewall-rulesets/{ruleset_id}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_global_firewall_ruleset(client: LambdaCloudClient) -> GlobalFirewallRuleset:
|
|
205
|
+
return GlobalFirewallRuleset.model_validate(client.get("/firewall-rulesets/global"))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def update_global_firewall_ruleset(
|
|
209
|
+
client: LambdaCloudClient, rules: list[FirewallRule]
|
|
210
|
+
) -> GlobalFirewallRuleset:
|
|
211
|
+
validate_firewall_rules(rules)
|
|
212
|
+
data = client.patch(
|
|
213
|
+
"/firewall-rulesets/global", json={"rules": serialize_firewall_rules(rules)}
|
|
214
|
+
)
|
|
215
|
+
return GlobalFirewallRuleset.model_validate(data)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def list_audit_events(
|
|
219
|
+
client: LambdaCloudClient,
|
|
220
|
+
start: str | None = None,
|
|
221
|
+
end: str | None = None,
|
|
222
|
+
resource_type: str | None = None,
|
|
223
|
+
all_pages: bool = False,
|
|
224
|
+
) -> list[AuditEvent]:
|
|
225
|
+
params = {
|
|
226
|
+
key: value
|
|
227
|
+
for key, value in {
|
|
228
|
+
"start": start,
|
|
229
|
+
"end": end,
|
|
230
|
+
"resource_type": resource_type,
|
|
231
|
+
}.items()
|
|
232
|
+
if value is not None
|
|
233
|
+
}
|
|
234
|
+
events: list[AuditEvent] = []
|
|
235
|
+
data, page_token = client.get_page("/audit-events", params=params)
|
|
236
|
+
events.extend(AuditEvent.model_validate(item) for item in data)
|
|
237
|
+
while all_pages and page_token:
|
|
238
|
+
data, page_token = client.get_page(
|
|
239
|
+
"/audit-events", params={**params, "page_token": page_token}
|
|
240
|
+
)
|
|
241
|
+
events.extend(AuditEvent.model_validate(item) for item in data)
|
|
242
|
+
return events
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface layer."""
|
lambda_cloud/cli/app.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Root Typer application assembling every command group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from .. import __version__
|
|
9
|
+
from ..core.errors import LambdaCloudError
|
|
10
|
+
from .commands import (
|
|
11
|
+
audit,
|
|
12
|
+
auth,
|
|
13
|
+
completion,
|
|
14
|
+
config_cmd,
|
|
15
|
+
filesystems,
|
|
16
|
+
firewall,
|
|
17
|
+
images,
|
|
18
|
+
instance_types,
|
|
19
|
+
instances,
|
|
20
|
+
regions,
|
|
21
|
+
ssh_keys,
|
|
22
|
+
)
|
|
23
|
+
from .state import State
|
|
24
|
+
from .ui.console import OutputFormat, console, err_console, exit_with_error
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _version_callback(value: bool) -> None:
|
|
28
|
+
if value:
|
|
29
|
+
console.print(f"lambda-cloud {__version__}")
|
|
30
|
+
raise typer.Exit()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
name="lambda-cloud",
|
|
35
|
+
help=(
|
|
36
|
+
"Unofficial community CLI for the Lambda Cloud API "
|
|
37
|
+
"(https://cloud.lambda.ai)."
|
|
38
|
+
"\n\nManage on-demand GPU instances, SSH keys, filesystems, images, "
|
|
39
|
+
"regions and firewall rulesets."
|
|
40
|
+
),
|
|
41
|
+
no_args_is_help=True,
|
|
42
|
+
add_completion=False,
|
|
43
|
+
rich_markup_mode="rich",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.callback()
|
|
48
|
+
def callback(
|
|
49
|
+
ctx: typer.Context,
|
|
50
|
+
api_key: str | None = typer.Option(
|
|
51
|
+
None,
|
|
52
|
+
"--api-key",
|
|
53
|
+
help="API key. Overrides LAMBDA_API_KEY and the stored config.",
|
|
54
|
+
show_envvar=False,
|
|
55
|
+
),
|
|
56
|
+
output_format: OutputFormat = typer.Option(
|
|
57
|
+
OutputFormat.TABLE,
|
|
58
|
+
"--output",
|
|
59
|
+
"-o",
|
|
60
|
+
help="Output format.",
|
|
61
|
+
),
|
|
62
|
+
verbose: bool = typer.Option(False, "--verbose", help="Verbose output."),
|
|
63
|
+
show_completion: str | None = typer.Option(
|
|
64
|
+
None,
|
|
65
|
+
"--show-completion",
|
|
66
|
+
help="Print shell completion instructions (bash, zsh, fish, powershell).",
|
|
67
|
+
is_eager=True,
|
|
68
|
+
),
|
|
69
|
+
version: bool = typer.Option(
|
|
70
|
+
False,
|
|
71
|
+
"--version",
|
|
72
|
+
callback=_version_callback,
|
|
73
|
+
is_eager=True,
|
|
74
|
+
help="Show version and exit.",
|
|
75
|
+
),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Store global options on the context; commands pick them up lazily."""
|
|
78
|
+
completion.handle_completion(show_completion)
|
|
79
|
+
state = State(api_key=api_key, output=output_format, verbose=verbose)
|
|
80
|
+
ctx.obj = state
|
|
81
|
+
ctx.call_on_close(state.close)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
app.add_typer(instances.app, name="instances")
|
|
85
|
+
app.add_typer(instance_types.app, name="types")
|
|
86
|
+
app.add_typer(ssh_keys.app, name="ssh-keys")
|
|
87
|
+
app.add_typer(filesystems.app, name="filesystems")
|
|
88
|
+
app.add_typer(images.app, name="images")
|
|
89
|
+
app.add_typer(regions.app, name="regions")
|
|
90
|
+
app.add_typer(firewall.app, name="firewall")
|
|
91
|
+
app.add_typer(audit.app, name="audit")
|
|
92
|
+
app.add_typer(config_cmd.app, name="config")
|
|
93
|
+
|
|
94
|
+
app.command("login", help="Store and validate your Lambda Cloud API key.")(auth.login)
|
|
95
|
+
app.command("logout", help="Remove the stored API key.")(auth.logout)
|
|
96
|
+
app.command("whoami", help="Show which credentials are in use and validate them.")(auth.whoami)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> None:
|
|
100
|
+
"""Console entry point: convert errors into clean exits."""
|
|
101
|
+
try:
|
|
102
|
+
app()
|
|
103
|
+
except LambdaCloudError as exc:
|
|
104
|
+
exit_with_error(exc)
|
|
105
|
+
except httpx.HTTPError as exc:
|
|
106
|
+
err_console.print(f"Network error: {exc}", style="bold red")
|
|
107
|
+
raise SystemExit(1) from None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Typer command groups for every Lambda Cloud resource."""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Query account audit events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ...api import service
|
|
8
|
+
from ..state import CommandBase
|
|
9
|
+
from ..ui.tables import audit_events_table
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(no_args_is_help=True, help="Query account audit events.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_audit_events(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
start: str | None = typer.Option(
|
|
18
|
+
None, "--start", help="ISO 8601 timestamp, inclusive (e.g. 2025-09-01T00:00:00Z)."
|
|
19
|
+
),
|
|
20
|
+
end: str | None = typer.Option(None, "--end", help="ISO 8601 timestamp, inclusive."),
|
|
21
|
+
resource_type: str | None = typer.Option(
|
|
22
|
+
None, "--resource-type", help="Filter by resource type, e.g. cloud.api_key."
|
|
23
|
+
),
|
|
24
|
+
all_pages: bool = typer.Option(
|
|
25
|
+
False, "--all", help="Follow pagination until all events are fetched."
|
|
26
|
+
),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""List audit events (newest time range by default)."""
|
|
29
|
+
cmd = CommandBase(ctx)
|
|
30
|
+
events = service.list_audit_events(
|
|
31
|
+
cmd.client,
|
|
32
|
+
start=start,
|
|
33
|
+
end=end,
|
|
34
|
+
resource_type=resource_type,
|
|
35
|
+
all_pages=all_pages,
|
|
36
|
+
)
|
|
37
|
+
cmd.emit(events, audit_events_table(events))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Manage the local Lambda Cloud API key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ...api import LambdaCloudClient
|
|
10
|
+
from ...core.config import (
|
|
11
|
+
API_KEY_ENV_VAR,
|
|
12
|
+
delete_stored_config,
|
|
13
|
+
describe_api_key_source,
|
|
14
|
+
mask_api_key,
|
|
15
|
+
save_api_key,
|
|
16
|
+
)
|
|
17
|
+
from ...core.errors import ConfigError, LambdaCloudError
|
|
18
|
+
from ..state import CommandBase
|
|
19
|
+
from ..ui.console import info, success, warn
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def login(
|
|
23
|
+
ctx: typer.Context,
|
|
24
|
+
api_key: str | None = typer.Option(
|
|
25
|
+
None,
|
|
26
|
+
"--api-key",
|
|
27
|
+
help="Skip the prompt by passing the key directly.",
|
|
28
|
+
),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Prompt for a key, validate it against the API, and save it."""
|
|
31
|
+
CommandBase(ctx, needs_client=False)
|
|
32
|
+
if api_key is None:
|
|
33
|
+
api_key = typer.prompt("Lambda Cloud API key (input hidden)", hide_input=True)
|
|
34
|
+
api_key = api_key.strip()
|
|
35
|
+
if not api_key:
|
|
36
|
+
raise LambdaCloudError("Empty API key.")
|
|
37
|
+
|
|
38
|
+
with LambdaCloudClient(api_key) as client:
|
|
39
|
+
client.get("/instances")
|
|
40
|
+
|
|
41
|
+
path = save_api_key(api_key)
|
|
42
|
+
success(f"Key is valid. Configuration written to {path}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def logout(ctx: typer.Context) -> None:
|
|
46
|
+
"""Delete the stored config file, if any."""
|
|
47
|
+
CommandBase(ctx, needs_client=False)
|
|
48
|
+
if delete_stored_config():
|
|
49
|
+
success("Stored configuration removed.")
|
|
50
|
+
else:
|
|
51
|
+
info("No stored configuration found.")
|
|
52
|
+
if os.environ.get(API_KEY_ENV_VAR):
|
|
53
|
+
warn(f"{API_KEY_ENV_VAR} is still set in your environment; unset it to fully log out.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def whoami(ctx: typer.Context) -> None:
|
|
57
|
+
"""Show the credentials currently in use and verify they are valid."""
|
|
58
|
+
CommandBase(ctx, needs_client=False)
|
|
59
|
+
source = describe_api_key_source(ctx.obj.api_key)
|
|
60
|
+
if source is None:
|
|
61
|
+
raise ConfigError(
|
|
62
|
+
f"No API key configured. Run `lambda-cloud login` or set {API_KEY_ENV_VAR}."
|
|
63
|
+
)
|
|
64
|
+
label, key = source
|
|
65
|
+
info(f"Source: {label}")
|
|
66
|
+
info(f"Key: {mask_api_key(key)}")
|
|
67
|
+
with LambdaCloudClient(key) as client:
|
|
68
|
+
client.get("/instances")
|
|
69
|
+
success("Key is valid.")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Shell completion support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ...core.errors import LambdaCloudError
|
|
8
|
+
from ..ui.console import console
|
|
9
|
+
|
|
10
|
+
SUPPORTED_SHELLS = ("bash", "zsh", "fish", "powershell")
|
|
11
|
+
|
|
12
|
+
_SNIPPETS = {
|
|
13
|
+
"bash": 'eval "$(_LAMBDA_CLOUD_COMPLETE=bash_source lambda-cloud)"',
|
|
14
|
+
"zsh": 'eval "$(_LAMBDA_CLOUD_COMPLETE=zsh_source lambda-cloud)"',
|
|
15
|
+
"fish": "_LAMBDA_CLOUD_COMPLETE=fish_source lambda-cloud | source",
|
|
16
|
+
"powershell": (
|
|
17
|
+
"$env:_LAMBDA_CLOUD_COMPLETE='powershell_source'; "
|
|
18
|
+
"lambda-cloud | Out-String | Invoke-Expression; "
|
|
19
|
+
"Remove-Item Env:_LAMBDA_CLOUD_COMPLETE"
|
|
20
|
+
),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def handle_completion(shell: str | None) -> None:
|
|
25
|
+
"""Print activation instructions for shell completion, then exit."""
|
|
26
|
+
if shell is None:
|
|
27
|
+
return
|
|
28
|
+
normalized = shell.lower()
|
|
29
|
+
if normalized not in _SNIPPETS:
|
|
30
|
+
raise LambdaCloudError(
|
|
31
|
+
f"Unsupported shell {shell!r}. Choose from: {', '.join(SUPPORTED_SHELLS)}."
|
|
32
|
+
)
|
|
33
|
+
console.print(
|
|
34
|
+
f"Run the following to enable completion for [bold]{normalized}[/bold]:\n\n"
|
|
35
|
+
f" {_SNIPPETS[normalized]}\n"
|
|
36
|
+
)
|
|
37
|
+
raise typer.Exit()
|