gcube-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.
- gcube_cli/__init__.py +6 -0
- gcube_cli/__main__.py +4 -0
- gcube_cli/_group.py +8 -0
- gcube_cli/api/__init__.py +16 -0
- gcube_cli/api/client.py +225 -0
- gcube_cli/api/credential.py +68 -0
- gcube_cli/api/logs.py +52 -0
- gcube_cli/api/point.py +173 -0
- gcube_cli/api/resource.py +108 -0
- gcube_cli/api/workload.py +409 -0
- gcube_cli/cli.py +87 -0
- gcube_cli/commands/__init__.py +0 -0
- gcube_cli/commands/configure.py +168 -0
- gcube_cli/commands/credential.py +173 -0
- gcube_cli/commands/gpu.py +129 -0
- gcube_cli/commands/point.py +190 -0
- gcube_cli/commands/resource.py +98 -0
- gcube_cli/commands/workload.py +1020 -0
- gcube_cli/config/__init__.py +3 -0
- gcube_cli/config/config.py +86 -0
- gcube_cli/output/__init__.py +39 -0
- gcube_cli/output/json_out.py +10 -0
- gcube_cli/output/table.py +40 -0
- gcube_cli/output/yaml_out.py +11 -0
- gcube_cli-0.1.0.dist-info/METADATA +505 -0
- gcube_cli-0.1.0.dist-info/RECORD +29 -0
- gcube_cli-0.1.0.dist-info/WHEEL +5 -0
- gcube_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gcube_cli-0.1.0.dist-info/top_level.txt +1 -0
gcube_cli/__init__.py
ADDED
gcube_cli/__main__.py
ADDED
gcube_cli/_group.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""gcube_cli API package — public re-exports."""
|
|
2
|
+
|
|
3
|
+
from gcube_cli.api.client import APIError, AuthError, Client, make_client
|
|
4
|
+
from gcube_cli.api.credential import Credential, CredentialAPI
|
|
5
|
+
from gcube_cli.api.workload import Workload, WorkloadAPI
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"APIError",
|
|
9
|
+
"AuthError",
|
|
10
|
+
"Client",
|
|
11
|
+
"make_client",
|
|
12
|
+
"Credential",
|
|
13
|
+
"CredentialAPI",
|
|
14
|
+
"Workload",
|
|
15
|
+
"WorkloadAPI",
|
|
16
|
+
]
|
gcube_cli/api/client.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Base HTTP client for gcube CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Exceptions
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthError(Exception):
|
|
18
|
+
"""Raised on HTTP 401 — token expired or invalid."""
|
|
19
|
+
|
|
20
|
+
MESSAGE = (
|
|
21
|
+
"Token expired or invalid. "
|
|
22
|
+
"Please get a new token from https://gcube.ai"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
super().__init__(self.MESSAGE)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class APIError(Exception):
|
|
30
|
+
"""Raised when the gcube API returns status != 200 in the response body."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, status_code: int, code: str, message: str) -> None:
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.code = code
|
|
35
|
+
self.message = message
|
|
36
|
+
super().__init__(f"API Error [{code}]: {message}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Command-layer decorator
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def handle_api_errors(func: Any) -> Any:
|
|
45
|
+
"""Decorator that catches AuthError / APIError and exits with the correct code.
|
|
46
|
+
|
|
47
|
+
Apply to every Click command that calls the API.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
@functools.wraps(func)
|
|
51
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
52
|
+
try:
|
|
53
|
+
return func(*args, **kwargs)
|
|
54
|
+
except AuthError as e:
|
|
55
|
+
click.echo(str(e), err=True)
|
|
56
|
+
sys.exit(3)
|
|
57
|
+
except APIError as e:
|
|
58
|
+
click.echo(f"API Error [{e.code}]: {e.message}", err=True)
|
|
59
|
+
sys.exit(2)
|
|
60
|
+
|
|
61
|
+
return wrapper
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Client
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _make_debug_hooks(base_url: str) -> dict[str, list[Any]]:
|
|
70
|
+
"""Return httpx event hooks that print request/response to stderr."""
|
|
71
|
+
import json as _json
|
|
72
|
+
|
|
73
|
+
def _pretty(raw: str) -> str:
|
|
74
|
+
try:
|
|
75
|
+
return _json.dumps(_json.loads(raw), ensure_ascii=False, indent=2)
|
|
76
|
+
except (ValueError, TypeError):
|
|
77
|
+
return raw
|
|
78
|
+
|
|
79
|
+
def _log_request(request: httpx.Request) -> None:
|
|
80
|
+
click.echo(f"\n[DEBUG] >>> {request.method} {request.url}", err=True)
|
|
81
|
+
if request.content:
|
|
82
|
+
click.echo("[DEBUG] >>> Body:", err=True)
|
|
83
|
+
click.echo(_pretty(request.content.decode("utf-8", errors="replace")), err=True)
|
|
84
|
+
|
|
85
|
+
def _log_response(response: httpx.Response) -> None:
|
|
86
|
+
response.read()
|
|
87
|
+
click.echo(f"[DEBUG] <<< {response.status_code}", err=True)
|
|
88
|
+
click.echo("[DEBUG] <<< Body:", err=True)
|
|
89
|
+
click.echo(_pretty(response.text), err=True)
|
|
90
|
+
|
|
91
|
+
return {"request": [_log_request], "response": [_log_response]}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Client:
|
|
95
|
+
"""Synchronous HTTP client with Bearer auth and uniform error handling."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, base_url: str, token: str, debug: bool = False) -> None:
|
|
98
|
+
self.base_url = base_url
|
|
99
|
+
self.debug = debug
|
|
100
|
+
init_kwargs: dict[str, Any] = {
|
|
101
|
+
"base_url": base_url,
|
|
102
|
+
"headers": {
|
|
103
|
+
"Authorization": f"Bearer {token}",
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
},
|
|
106
|
+
"timeout": 30.0,
|
|
107
|
+
}
|
|
108
|
+
if debug:
|
|
109
|
+
init_kwargs["event_hooks"] = _make_debug_hooks(base_url)
|
|
110
|
+
self._client = httpx.Client(**init_kwargs)
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Internal request dispatcher
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
117
|
+
"""Execute an HTTP request and return the parsed JSON body.
|
|
118
|
+
|
|
119
|
+
Error handling order:
|
|
120
|
+
1. httpx.RequestError (network/timeout) → sys.exit(4)
|
|
121
|
+
2. HTTP 401 → raise AuthError
|
|
122
|
+
3. raise_for_status() for other 4xx/5xx → wrap as APIError
|
|
123
|
+
4. body["status"] != 200 → raise APIError
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
response = self._client.request(method, path, **kwargs)
|
|
127
|
+
except httpx.RequestError:
|
|
128
|
+
click.echo(
|
|
129
|
+
f"Cannot connect to {self.base_url}. Check network.",
|
|
130
|
+
err=True,
|
|
131
|
+
)
|
|
132
|
+
sys.exit(4)
|
|
133
|
+
|
|
134
|
+
# 401 — try to parse body first; only raise AuthError for bare token failures
|
|
135
|
+
if response.status_code == 401:
|
|
136
|
+
try:
|
|
137
|
+
err_data = cast(dict[str, Any], response.json())
|
|
138
|
+
if "error" in err_data and "message" in err_data:
|
|
139
|
+
raise APIError(
|
|
140
|
+
err_data.get("status", 401),
|
|
141
|
+
err_data.get("error", "UNAUTHORIZED"),
|
|
142
|
+
err_data.get("message", "Unauthorized"),
|
|
143
|
+
)
|
|
144
|
+
except (ValueError, KeyError):
|
|
145
|
+
pass
|
|
146
|
+
raise AuthError()
|
|
147
|
+
|
|
148
|
+
# Other unexpected HTTP-level errors (e.g. 500 with no JSON body)
|
|
149
|
+
try:
|
|
150
|
+
response.raise_for_status()
|
|
151
|
+
except httpx.HTTPStatusError as exc:
|
|
152
|
+
# Try to extract gcube envelope; fall back to generic message
|
|
153
|
+
try:
|
|
154
|
+
err_data = cast(dict[str, Any], exc.response.json())
|
|
155
|
+
raise APIError(
|
|
156
|
+
err_data.get("status", exc.response.status_code),
|
|
157
|
+
err_data.get("error", str(exc.response.status_code)),
|
|
158
|
+
err_data.get("message", exc.response.text),
|
|
159
|
+
) from exc
|
|
160
|
+
except (ValueError, KeyError):
|
|
161
|
+
raise APIError(
|
|
162
|
+
exc.response.status_code,
|
|
163
|
+
str(exc.response.status_code),
|
|
164
|
+
exc.response.text,
|
|
165
|
+
) from exc
|
|
166
|
+
|
|
167
|
+
# Parse gcube response envelope
|
|
168
|
+
data: dict[str, Any] = cast(dict[str, Any], response.json())
|
|
169
|
+
if data.get("status") != 200:
|
|
170
|
+
raise APIError(
|
|
171
|
+
data.get("status", 0),
|
|
172
|
+
data.get("error", ""),
|
|
173
|
+
data.get("message", "Unknown error"),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return data
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Public HTTP methods
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
183
|
+
return self._request("GET", path, **kwargs)
|
|
184
|
+
|
|
185
|
+
def post(
|
|
186
|
+
self, path: str, json: dict[str, Any] | None = None, **kwargs: Any
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
return self._request("POST", path, json=json, **kwargs)
|
|
189
|
+
|
|
190
|
+
def put(
|
|
191
|
+
self, path: str, json: dict[str, Any] | None = None, **kwargs: Any
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
return self._request("PUT", path, json=json, **kwargs)
|
|
194
|
+
|
|
195
|
+
def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
196
|
+
return self._request("DELETE", path, **kwargs)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# Factory
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def make_client(
|
|
205
|
+
config: Any, # gcube_cli.config.config.GcubeConfig — Any to avoid circular import
|
|
206
|
+
debug: bool = False,
|
|
207
|
+
platform_url: str | None = None,
|
|
208
|
+
) -> Client:
|
|
209
|
+
"""Create a Client from a loaded GcubeConfig object.
|
|
210
|
+
|
|
211
|
+
Exits with code 1 if no token is configured.
|
|
212
|
+
"""
|
|
213
|
+
token: str | None = None
|
|
214
|
+
if config.auth:
|
|
215
|
+
token = config.auth.access_token or None
|
|
216
|
+
|
|
217
|
+
if not token:
|
|
218
|
+
click.echo(
|
|
219
|
+
"No token configured. Run: gcube configure set --token <token>",
|
|
220
|
+
err=True,
|
|
221
|
+
)
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
|
|
224
|
+
base_url = platform_url or config.platform_url or "https://api.gcube.ai"
|
|
225
|
+
return Client(base_url=base_url, token=token, debug=debug)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Credential management API client for gcube CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from gcube_cli.api.client import Client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Credential:
|
|
13
|
+
"""Container image registry credential."""
|
|
14
|
+
|
|
15
|
+
owner: str
|
|
16
|
+
category: str # 항상 "repo"
|
|
17
|
+
cloud: str # docker | github | harbor | aws | huggingface | quay
|
|
18
|
+
acc_key: str # JSON: accKey (username)
|
|
19
|
+
sec_key: str # JSON: secKey (token/password)
|
|
20
|
+
region: str | None = None # AWS ECR region
|
|
21
|
+
registry_id: str | None = None # JSON: registryId (AWS ECR only)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_dict(cls, data: dict[str, Any]) -> Credential:
|
|
25
|
+
return cls(
|
|
26
|
+
owner=data.get("owner", ""),
|
|
27
|
+
category=data.get("category", ""),
|
|
28
|
+
cloud=data.get("cloud", ""),
|
|
29
|
+
acc_key=data.get("accKey", data.get("acc_key", "")),
|
|
30
|
+
sec_key=data.get("secKey", data.get("sec_key", "")),
|
|
31
|
+
region=data.get("region") or None,
|
|
32
|
+
registry_id=data.get("registryId", data.get("registry_id")) or None,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
"""Serialize to camelCase JSON dict for the API request."""
|
|
37
|
+
return {
|
|
38
|
+
"owner": self.owner,
|
|
39
|
+
"category": self.category,
|
|
40
|
+
"cloud": self.cloud,
|
|
41
|
+
"accKey": self.acc_key,
|
|
42
|
+
"secKey": self.sec_key,
|
|
43
|
+
"region": self.region or "",
|
|
44
|
+
"registryId": self.registry_id or "",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CredentialAPI:
|
|
49
|
+
"""High-level Credential API client."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, client: Client) -> None:
|
|
52
|
+
self._client = client
|
|
53
|
+
|
|
54
|
+
def list(self, owner: str) -> list[Credential]:
|
|
55
|
+
"""GET /api/credential/list?owner={email}"""
|
|
56
|
+
data = self._client.get("/api/credential/list", params={"owner": owner})
|
|
57
|
+
items: list[dict[str, Any]] = data.get("credentials", data.get("data", data.get("items", [])))
|
|
58
|
+
return [Credential.from_dict(item) for item in items]
|
|
59
|
+
|
|
60
|
+
def create(self, cred: Credential) -> Credential:
|
|
61
|
+
"""POST /api/credential/register"""
|
|
62
|
+
data = self._client.post("/api/credential/register", json=cred.to_dict())
|
|
63
|
+
item: dict[str, Any] = data.get("data", data)
|
|
64
|
+
return Credential.from_dict(item) if isinstance(item, dict) and item else cred
|
|
65
|
+
|
|
66
|
+
def delete(self, cloud: str) -> None:
|
|
67
|
+
"""DELETE /api/credential/delete/repo/{cloud}"""
|
|
68
|
+
self._client.delete(f"/api/credential/delete/repo/{cloud}")
|
gcube_cli/api/logs.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""WebSocket log streaming for gcube workloads (PBI-016)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import websockets
|
|
7
|
+
import websockets.exceptions
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LogsConnectionError(Exception):
|
|
11
|
+
"""Raised when the WebSocket connection to the log stream fails."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LogsAuthError(Exception):
|
|
15
|
+
"""Raised when the server rejects the connection due to auth failure."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LogStreamer:
|
|
19
|
+
def __init__(self, ws_url: str, token: str) -> None:
|
|
20
|
+
self.ws_url = ws_url.rstrip("/")
|
|
21
|
+
self.token = token
|
|
22
|
+
|
|
23
|
+
async def stream(self, ser: str, pod_name: str, ct_index: int = 0) -> None:
|
|
24
|
+
"""Open a WebSocket and stream log messages to stdout.
|
|
25
|
+
|
|
26
|
+
The server always requires /container/{ct_index} in the URL path.
|
|
27
|
+
ct_index defaults to 0 for single-container pods.
|
|
28
|
+
"""
|
|
29
|
+
url = (
|
|
30
|
+
f"{self.ws_url}/workloads/{ser}/log/{pod_name}"
|
|
31
|
+
f"/container/{ct_index}?token={self.token}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
async with websockets.connect(url, close_timeout=0) as ws:
|
|
36
|
+
async for message in ws:
|
|
37
|
+
click.echo(message, nl=False)
|
|
38
|
+
except websockets.exceptions.ConnectionClosed:
|
|
39
|
+
pass # server closed the stream (clean close or proxy timeout)
|
|
40
|
+
except websockets.exceptions.InvalidStatus as exc:
|
|
41
|
+
status_code = getattr(getattr(exc, "response", None), "status_code", 0)
|
|
42
|
+
if status_code in (401, 403):
|
|
43
|
+
raise LogsAuthError(
|
|
44
|
+
"Token expired or invalid. Please get a new token from https://console.gcube.ai"
|
|
45
|
+
) from exc
|
|
46
|
+
raise LogsConnectionError(
|
|
47
|
+
f"Cannot connect to log stream (HTTP {status_code}). Check ws-url configuration."
|
|
48
|
+
) from exc
|
|
49
|
+
except (OSError, websockets.exceptions.WebSocketException) as exc:
|
|
50
|
+
raise LogsConnectionError(
|
|
51
|
+
"Cannot connect to log stream. Check ws-url configuration."
|
|
52
|
+
) from exc
|
gcube_cli/api/point.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Point balance and usage API client for gcube CLI (PBI-013)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import calendar
|
|
6
|
+
import datetime
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from gcube_cli.api.client import Client
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PointStatus:
|
|
15
|
+
available_point: int
|
|
16
|
+
charge_point: int
|
|
17
|
+
spend_point: int
|
|
18
|
+
net_point: int
|
|
19
|
+
is_blocked: bool
|
|
20
|
+
is_alarm: bool
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_dict(cls, data: dict[str, Any]) -> PointStatus:
|
|
24
|
+
points: dict[str, Any] = data.get("points", data)
|
|
25
|
+
return cls(
|
|
26
|
+
available_point=int(points.get("availablePoint", 0)),
|
|
27
|
+
charge_point=int(points.get("chargePoint", 0)),
|
|
28
|
+
spend_point=int(points.get("spendPoint", 0)),
|
|
29
|
+
net_point=int(points.get("netPoint", 0)),
|
|
30
|
+
is_blocked=bool(points.get("isBlocked", False)),
|
|
31
|
+
is_alarm=bool(points.get("isAlarm", False)),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class PointSpendSummary:
|
|
37
|
+
total_spend: int
|
|
38
|
+
prev_month_spend: int
|
|
39
|
+
this_month_spend: int
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_dict(cls, data: dict[str, Any]) -> PointSpendSummary:
|
|
43
|
+
points: dict[str, Any] = data.get("points", data)
|
|
44
|
+
total = points.get("totalSpend", {})
|
|
45
|
+
prev = points.get("prevMonthSpend", {})
|
|
46
|
+
this = points.get("thisMonthSpend", {})
|
|
47
|
+
return cls(
|
|
48
|
+
total_spend=int(total.get("point", 0) if isinstance(total, dict) else total),
|
|
49
|
+
prev_month_spend=int(prev.get("point", 0) if isinstance(prev, dict) else prev),
|
|
50
|
+
this_month_spend=int(this.get("point", 0) if isinstance(this, dict) else this),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DailySpend:
|
|
56
|
+
date: str
|
|
57
|
+
point: float
|
|
58
|
+
krw_amount: int
|
|
59
|
+
krw_unit_amount: int
|
|
60
|
+
krw_usage_amount: int
|
|
61
|
+
gpu_usage: float
|
|
62
|
+
net_rx: float
|
|
63
|
+
net_tx: float
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, data: dict[str, Any]) -> DailySpend:
|
|
67
|
+
return cls(
|
|
68
|
+
date=str(data.get("date", "")),
|
|
69
|
+
point=float(data.get("point", 0)),
|
|
70
|
+
krw_amount=int(data.get("krwAmount", 0)),
|
|
71
|
+
krw_unit_amount=int(data.get("krwUnitAmount", 0)),
|
|
72
|
+
krw_usage_amount=int(data.get("krwUsageAmount", 0)),
|
|
73
|
+
gpu_usage=float(data.get("gpuUsage", 0)),
|
|
74
|
+
net_rx=float(data.get("netRx", 0)),
|
|
75
|
+
net_tx=float(data.get("netTx", 0)),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _month_range(month: str) -> tuple[str, str]:
|
|
80
|
+
"""'YYYY-MM' → ('YYYY-MM-01', 'YYYY-MM-DD') 말일 계산."""
|
|
81
|
+
year, mon = int(month[:4]), int(month[5:7])
|
|
82
|
+
last_day = calendar.monthrange(year, mon)[1]
|
|
83
|
+
return f"{year:04d}-{mon:02d}-01", f"{year:04d}-{mon:02d}-{last_day:02d}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _current_month_range() -> tuple[str, str]:
|
|
87
|
+
today = datetime.date.today()
|
|
88
|
+
return f"{today.year:04d}-{today.month:02d}-01", today.strftime("%Y-%m-%d")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# status API (/api/point/status) 에서 표시할 필드만 허용
|
|
92
|
+
_STATUS_ALLOWLIST = frozenset({
|
|
93
|
+
"availablePoint", "chargePoint", "spendPoint", "netPoint",
|
|
94
|
+
"isBlocked", "isAlarm",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
# daily spends 항목에서 제외할 필드명
|
|
98
|
+
_EXCLUDED_SPENDING_EXACT = frozenset({"gpuPoint", "namespace", "krw", "useTime", "owner"})
|
|
99
|
+
# daily spends 항목에서 제외할 패턴 (substring match, 대소문자 구분)
|
|
100
|
+
# - "Gpu": krwGpuAmount 등 GCUBE 내부 단가
|
|
101
|
+
# - "vram","cpu","mem","disk": resource 커맨드 영역
|
|
102
|
+
_EXCLUDED_SPENDING_PATTERN = ("income", "settle", "refund", "Gpu", "vram", "cpu", "mem", "disk")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _filter_status_json(raw: dict[str, Any]) -> dict[str, Any]:
|
|
106
|
+
"""status JSON: 허용 목록 필드만 출력."""
|
|
107
|
+
points = raw.get("points", {})
|
|
108
|
+
return {"points": {k: v for k, v in points.items() if k in _STATUS_ALLOWLIST}}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _filter_spend_summary_json(raw: dict[str, Any]) -> dict[str, Any]:
|
|
112
|
+
"""spend/status JSON: 각 항목의 point 값만 추출하여 flat 구조로 반환."""
|
|
113
|
+
points = raw.get("points", {})
|
|
114
|
+
|
|
115
|
+
def _pt(key: str) -> int:
|
|
116
|
+
val = points.get(key, {})
|
|
117
|
+
return int(val.get("point", 0) if isinstance(val, dict) else val)
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
"totalSpend": _pt("totalSpend"),
|
|
121
|
+
"prevMonthSpend": _pt("prevMonthSpend"),
|
|
122
|
+
"thisMonthSpend": _pt("thisMonthSpend"),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _filter_spending_json(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
127
|
+
"""daily spends JSON: gpuPoint/namespace/krw + income/settle/refund/*Gpu* 제외."""
|
|
128
|
+
result = []
|
|
129
|
+
for item in items:
|
|
130
|
+
filtered = {
|
|
131
|
+
k: v for k, v in item.items()
|
|
132
|
+
if k not in _EXCLUDED_SPENDING_EXACT
|
|
133
|
+
and not any(ex in k for ex in _EXCLUDED_SPENDING_PATTERN)
|
|
134
|
+
}
|
|
135
|
+
result.append(filtered)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class PointAPI:
|
|
140
|
+
def __init__(self, client: Client) -> None:
|
|
141
|
+
self._client = client
|
|
142
|
+
|
|
143
|
+
def status(self, owner: str) -> tuple[PointStatus, dict[str, Any]]:
|
|
144
|
+
"""GET /api/point/status?owner={email}. Returns (model, raw_data)."""
|
|
145
|
+
raw = self._client.get("/api/point/status", params={"owner": owner})
|
|
146
|
+
data: dict[str, Any] = raw.get("data", raw)
|
|
147
|
+
return PointStatus.from_dict(data), data
|
|
148
|
+
|
|
149
|
+
def spend_summary(self, owner: str) -> tuple[PointSpendSummary, dict[str, Any]]:
|
|
150
|
+
"""GET /api/point/spend/status?owner={email}. Returns (model, raw_data)."""
|
|
151
|
+
raw = self._client.get("/api/point/spend/status", params={"owner": owner})
|
|
152
|
+
data: dict[str, Any] = raw.get("data", raw)
|
|
153
|
+
return PointSpendSummary.from_dict(data), data
|
|
154
|
+
|
|
155
|
+
def daily_spends(
|
|
156
|
+
self,
|
|
157
|
+
owner: str,
|
|
158
|
+
workload: int | None = None,
|
|
159
|
+
start_time: str | None = None,
|
|
160
|
+
end_time: str | None = None,
|
|
161
|
+
) -> tuple[list[DailySpend], list[dict[str, Any]]]:
|
|
162
|
+
"""GET /api/point/daily/spends. Returns (models, raw_items)."""
|
|
163
|
+
params: dict[str, Any] = {"owner": owner}
|
|
164
|
+
if workload is not None:
|
|
165
|
+
params["workload"] = workload
|
|
166
|
+
if start_time:
|
|
167
|
+
params["startTime"] = start_time
|
|
168
|
+
if end_time:
|
|
169
|
+
params["endTime"] = end_time
|
|
170
|
+
raw = self._client.get("/api/point/daily/spends", params=params)
|
|
171
|
+
data: dict[str, Any] = raw.get("data", raw)
|
|
172
|
+
items: list[dict[str, Any]] = data.get("pointSpends", [])
|
|
173
|
+
return [DailySpend.from_dict(i) for i in items], items
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Resource monitoring API client for gcube CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from gcube_cli.api.client import APIError, Client
|
|
10
|
+
|
|
11
|
+
_GAI_BASE = "/api/workloads/gai/"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_time_interval(ti: Any) -> str | None:
|
|
15
|
+
"""Convert timeInterval from the API into a human-readable string.
|
|
16
|
+
|
|
17
|
+
Handles two formats the API may return:
|
|
18
|
+
- float/int: Unix timestamp in milliseconds → "YYYY-MM-DD HH:MM"
|
|
19
|
+
- list [year, month, day, hour, minute]: → "YYYY-MM-DD HH:MM"
|
|
20
|
+
- str: pass through as-is
|
|
21
|
+
"""
|
|
22
|
+
if ti is None:
|
|
23
|
+
return None
|
|
24
|
+
if isinstance(ti, (int, float)):
|
|
25
|
+
return datetime.fromtimestamp(ti / 1000).strftime("%Y-%m-%d %H:%M")
|
|
26
|
+
if isinstance(ti, list) and len(ti) >= 5:
|
|
27
|
+
y, m, d, h, mi = int(ti[0]), int(ti[1]), int(ti[2]), int(ti[3]), int(ti[4])
|
|
28
|
+
return f"{y:04d}-{m:02d}-{d:02d} {h:02d}:{mi:02d}"
|
|
29
|
+
return str(ti)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ResourceUsage:
|
|
34
|
+
"""Resource usage snapshot for a pod."""
|
|
35
|
+
|
|
36
|
+
gpu_name: str # JSON: gpuName (시간평균 응답에만 있음)
|
|
37
|
+
gpu_usage: float # JSON: gpuUsage, 단위 %
|
|
38
|
+
vram_usage: float # JSON: vramUsage, 단위 %
|
|
39
|
+
vram_cap: int # JSON: vramCap, 단위 MB
|
|
40
|
+
cpu_usage: float # JSON: cpuUsage, 단위 %
|
|
41
|
+
mem_usage: float # JSON: memUsage, 단위 %
|
|
42
|
+
disk_usage: float # JSON: diskUsage, 단위 %
|
|
43
|
+
disk_occu: float # JSON: diskOccu, 단위 MB
|
|
44
|
+
net_rx: float # JSON: netRx, 단위 kbps
|
|
45
|
+
net_tx: float # JSON: netTx, 단위 kbps
|
|
46
|
+
time_interval: str | None = None # JSON: timeInterval
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, data: dict[str, Any]) -> ResourceUsage:
|
|
50
|
+
return cls(
|
|
51
|
+
gpu_name=data.get("gpuName", ""),
|
|
52
|
+
gpu_usage=round(float(data.get("gpuUsage", 0.0)), 2),
|
|
53
|
+
vram_usage=round(float(data.get("vramUsage", 0.0)), 2),
|
|
54
|
+
vram_cap=int(data.get("vramCap", 0)),
|
|
55
|
+
cpu_usage=round(float(data.get("cpuUsage", 0.0)), 2),
|
|
56
|
+
mem_usage=round(float(data.get("memUsage", 0.0)), 2),
|
|
57
|
+
disk_usage=round(float(data.get("diskUsage", 0.0)), 2),
|
|
58
|
+
disk_occu=round(float(data.get("diskOccu", 0.0)), 2),
|
|
59
|
+
net_rx=round(float(data.get("netRx", 0.0)), 2),
|
|
60
|
+
net_tx=round(float(data.get("netTx", 0.0)), 2),
|
|
61
|
+
time_interval=_parse_time_interval(data.get("timeInterval")),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ResourceAPI:
|
|
66
|
+
"""Resource usage API client.
|
|
67
|
+
|
|
68
|
+
2-step lookup: workload name → pod list → per-pod resource usage.
|
|
69
|
+
|
|
70
|
+
Endpoint: GET /api/usage/avg → response key: ``usages`` (list)
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, client: Client) -> None:
|
|
74
|
+
self._client = client
|
|
75
|
+
|
|
76
|
+
def get_workload_pods(self, workload_name: str) -> list[str]:
|
|
77
|
+
"""Resolve workload name to a list of running pod names."""
|
|
78
|
+
data = self._client.get(f"{_GAI_BASE}{workload_name}")
|
|
79
|
+
item: dict[str, Any] = data.get("workload", data.get("data", data))
|
|
80
|
+
pods: list[dict[str, Any]] = item.get("pods") or []
|
|
81
|
+
return [p["name"] for p in pods if p.get("name")]
|
|
82
|
+
|
|
83
|
+
def workload(self, workload_name: str) -> dict[str, list[ResourceUsage]]:
|
|
84
|
+
"""Get time-series resource usage grouped by pod name.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Mapping of pod_name → list[ResourceUsage].
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
APIError: If workload has no running pods or an API call fails.
|
|
91
|
+
"""
|
|
92
|
+
pods = self.get_workload_pods(workload_name)
|
|
93
|
+
if not pods:
|
|
94
|
+
raise APIError(
|
|
95
|
+
status_code=404,
|
|
96
|
+
code="NO_PODS",
|
|
97
|
+
message=f"Workload '{workload_name}' has no running pods. Is it stopped?",
|
|
98
|
+
)
|
|
99
|
+
return {pod: self._get_avg(pod) for pod in pods}
|
|
100
|
+
|
|
101
|
+
def _get_avg(self, pod_name: str) -> list[ResourceUsage]:
|
|
102
|
+
"""GET /api/usage/avg?pod={pod}&isPod=true"""
|
|
103
|
+
data = self._client.get(
|
|
104
|
+
"/api/usage/avg",
|
|
105
|
+
params={"pod": pod_name, "isPod": "true"},
|
|
106
|
+
)
|
|
107
|
+
items: list[dict[str, Any]] = data.get("usages", [])
|
|
108
|
+
return [ResourceUsage.from_dict(item) for item in items]
|