greennode.vks-mcp-server 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.
- greennode/__init__.py +0 -0
- greennode/vks_mcp_server/__init__.py +1 -0
- greennode/vks_mcp_server/auth.py +56 -0
- greennode/vks_mcp_server/client.py +200 -0
- greennode/vks_mcp_server/cluster_handler.py +480 -0
- greennode/vks_mcp_server/config.py +114 -0
- greennode/vks_mcp_server/context.py +10 -0
- greennode/vks_mcp_server/k8s_apis.py +423 -0
- greennode/vks_mcp_server/k8s_client_cache.py +45 -0
- greennode/vks_mcp_server/k8s_handler.py +537 -0
- greennode/vks_mcp_server/models.py +482 -0
- greennode/vks_mcp_server/nodegroup_handler.py +234 -0
- greennode/vks_mcp_server/server.py +100 -0
- greennode/vks_mcp_server/validators.py +21 -0
- greennode_vks_mcp_server-0.1.0.dist-info/METADATA +597 -0
- greennode_vks_mcp_server-0.1.0.dist-info/RECORD +21 -0
- greennode_vks_mcp_server-0.1.0.dist-info/WHEEL +5 -0
- greennode_vks_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- greennode_vks_mcp_server-0.1.0.dist-info/licenses/LICENSE +17 -0
- greennode_vks_mcp_server-0.1.0.dist-info/licenses/NOTICE +2 -0
- greennode_vks_mcp_server-0.1.0.dist-info/top_level.txt +1 -0
greennode/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""GreenNode MCP Server — MCP tools for VNG Kubernetes Service."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""IAM token management for GreenNode MCP Server."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from greennode.vks_mcp_server.config import VksConfig
|
|
10
|
+
|
|
11
|
+
IAM_TOKEN_URL = "https://iamapis.vngcloud.vn/accounts-api/v1/auth/token"
|
|
12
|
+
|
|
13
|
+
DEFAULT_TIMEOUT = 30 # seconds
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TokenManager:
|
|
17
|
+
"""Manages IAM access tokens with auto-refresh via client credentials."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: VksConfig) -> None:
|
|
20
|
+
self._config = config
|
|
21
|
+
self._access_token = None
|
|
22
|
+
self._expires_at = 0
|
|
23
|
+
|
|
24
|
+
async def get_token(self) -> str:
|
|
25
|
+
"""Return a valid access token, refreshing if needed."""
|
|
26
|
+
if self._access_token and time.time() < self._expires_at - 60:
|
|
27
|
+
return self._access_token
|
|
28
|
+
|
|
29
|
+
await self._fetch_token()
|
|
30
|
+
return self._access_token
|
|
31
|
+
|
|
32
|
+
async def _fetch_token(self) -> None:
|
|
33
|
+
"""Fetch a new token from the IAM API."""
|
|
34
|
+
credentials = f"{self._config.client_id}:{self._config.client_secret}"
|
|
35
|
+
encoded = base64.b64encode(credentials.encode()).decode()
|
|
36
|
+
|
|
37
|
+
headers = {"Authorization": f"Basic {encoded}"}
|
|
38
|
+
body = {"grantType": "client_credentials"}
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
|
|
42
|
+
response = await client.post(IAM_TOKEN_URL, json=body, headers=headers)
|
|
43
|
+
except httpx.ConnectError as exc:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"Cannot connect to IAM API: {exc}"
|
|
46
|
+
) from exc
|
|
47
|
+
|
|
48
|
+
if response.status_code != 200:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
f"Authentication failed: HTTP {response.status_code} from IAM API."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
data = response.json()
|
|
54
|
+
self._access_token = data["accessToken"]
|
|
55
|
+
expires_in = data.get("expiresIn", 1800)
|
|
56
|
+
self._expires_at = time.time() + expires_in
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""HTTP client for VKS API with retry on 5xx/timeout and auto-refresh on 401."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from greennode.vks_mcp_server.auth import TokenManager
|
|
11
|
+
from greennode.vks_mcp_server.config import VksConfig
|
|
12
|
+
|
|
13
|
+
LOG = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Retry configuration
|
|
16
|
+
MAX_RETRIES = 3
|
|
17
|
+
RETRY_BASE_DELAY = 1 # seconds
|
|
18
|
+
RETRYABLE_STATUS_CODES = {500, 502, 503, 504}
|
|
19
|
+
DEFAULT_TIMEOUT = 30 # seconds
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VksClient:
|
|
23
|
+
"""Thin async HTTP client for the VKS API."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, config: VksConfig, token_manager: TokenManager) -> None:
|
|
26
|
+
self._config = config
|
|
27
|
+
self._token_manager = token_manager
|
|
28
|
+
|
|
29
|
+
async def _request(
|
|
30
|
+
self,
|
|
31
|
+
method: str,
|
|
32
|
+
path: str,
|
|
33
|
+
region: str | None = None,
|
|
34
|
+
params: dict[str, Any] | None = None,
|
|
35
|
+
json: Any = None,
|
|
36
|
+
raw_response: bool = False,
|
|
37
|
+
_retried_auth: bool = False,
|
|
38
|
+
) -> Any:
|
|
39
|
+
"""Send an HTTP request to the VKS API.
|
|
40
|
+
|
|
41
|
+
Retries up to ``MAX_RETRIES`` times on 5xx errors and network
|
|
42
|
+
timeouts with exponential backoff (1s, 2s, 4s). Automatically
|
|
43
|
+
retries once on 401 by refreshing the access token.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
method: HTTP method (GET, POST, PUT, DELETE).
|
|
47
|
+
path: API path (e.g. ``/v1/clusters``).
|
|
48
|
+
region: Region override; ``None`` uses the default region.
|
|
49
|
+
params: Optional query parameters.
|
|
50
|
+
json: Optional JSON body.
|
|
51
|
+
raw_response: If ``True``, return the raw response text
|
|
52
|
+
instead of parsed JSON.
|
|
53
|
+
_retried_auth: Internal flag to prevent infinite 401 retry loops.
|
|
54
|
+
"""
|
|
55
|
+
endpoints = self._config.get_endpoints(region)
|
|
56
|
+
url = f"{endpoints.vks}{path}"
|
|
57
|
+
|
|
58
|
+
last_error: Exception | None = None
|
|
59
|
+
|
|
60
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
61
|
+
token = await self._token_manager.get_token()
|
|
62
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
|
|
66
|
+
resp = await client.request(
|
|
67
|
+
method,
|
|
68
|
+
url,
|
|
69
|
+
headers=headers,
|
|
70
|
+
params=params,
|
|
71
|
+
json=json,
|
|
72
|
+
)
|
|
73
|
+
except (httpx.ConnectTimeout, httpx.ReadTimeout, httpx.ConnectError) as exc:
|
|
74
|
+
last_error = exc
|
|
75
|
+
if attempt < MAX_RETRIES:
|
|
76
|
+
delay = RETRY_BASE_DELAY * (2 ** attempt)
|
|
77
|
+
LOG.debug(
|
|
78
|
+
"Request timeout/error (attempt %d/%d), retrying in %ds: %s",
|
|
79
|
+
attempt + 1, MAX_RETRIES + 1, delay, exc,
|
|
80
|
+
)
|
|
81
|
+
await asyncio.sleep(delay)
|
|
82
|
+
continue
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"Request failed after {MAX_RETRIES + 1} attempts: {exc}"
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
# 401 — refresh token and retry once
|
|
88
|
+
if resp.status_code == 401:
|
|
89
|
+
if _retried_auth:
|
|
90
|
+
self._raise_error(resp)
|
|
91
|
+
self._token_manager._expires_at = 0
|
|
92
|
+
return await self._request(
|
|
93
|
+
method,
|
|
94
|
+
path,
|
|
95
|
+
region=region,
|
|
96
|
+
params=params,
|
|
97
|
+
json=json,
|
|
98
|
+
raw_response=raw_response,
|
|
99
|
+
_retried_auth=True,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Retryable server errors (5xx)
|
|
103
|
+
if resp.status_code in RETRYABLE_STATUS_CODES:
|
|
104
|
+
if attempt < MAX_RETRIES:
|
|
105
|
+
delay = RETRY_BASE_DELAY * (2 ** attempt)
|
|
106
|
+
LOG.debug(
|
|
107
|
+
"Server error %d (attempt %d/%d), retrying in %ds",
|
|
108
|
+
resp.status_code, attempt + 1, MAX_RETRIES + 1, delay,
|
|
109
|
+
)
|
|
110
|
+
await asyncio.sleep(delay)
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if not resp.is_success:
|
|
114
|
+
self._raise_error(resp)
|
|
115
|
+
|
|
116
|
+
if raw_response:
|
|
117
|
+
return resp.text
|
|
118
|
+
|
|
119
|
+
return resp.json()
|
|
120
|
+
|
|
121
|
+
# Should not reach here, but just in case
|
|
122
|
+
raise RuntimeError(f"Request failed after {MAX_RETRIES + 1} attempts")
|
|
123
|
+
|
|
124
|
+
def _raise_error(self, resp: httpx.Response) -> None:
|
|
125
|
+
"""Raise a ``RuntimeError`` with a descriptive error message."""
|
|
126
|
+
try:
|
|
127
|
+
body = resp.json()
|
|
128
|
+
|
|
129
|
+
msg = (
|
|
130
|
+
body.get("message")
|
|
131
|
+
or body.get("error")
|
|
132
|
+
or body.get("errors", [{}])[0].get("message", "")
|
|
133
|
+
or str(body)
|
|
134
|
+
)
|
|
135
|
+
except Exception:
|
|
136
|
+
msg = resp.text or "unknown error"
|
|
137
|
+
|
|
138
|
+
status = resp.status_code
|
|
139
|
+
|
|
140
|
+
if status == 400:
|
|
141
|
+
raise RuntimeError(f"Bad request: {msg}")
|
|
142
|
+
if status == 401:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
"Token expired or invalid. Please check your authentication configuration."
|
|
145
|
+
)
|
|
146
|
+
if status == 404:
|
|
147
|
+
raise RuntimeError(f"Resource not found: {msg}")
|
|
148
|
+
if status == 409:
|
|
149
|
+
raise RuntimeError("Resource is being processed. Please wait and try again.")
|
|
150
|
+
|
|
151
|
+
raise RuntimeError(f"API error ({status}): {msg}")
|
|
152
|
+
|
|
153
|
+
async def get(
|
|
154
|
+
self,
|
|
155
|
+
path: str,
|
|
156
|
+
region: str | None = None,
|
|
157
|
+
params: dict[str, Any] | None = None,
|
|
158
|
+
) -> Any:
|
|
159
|
+
"""Send a GET request."""
|
|
160
|
+
return await self._request("GET", path, region=region, params=params)
|
|
161
|
+
|
|
162
|
+
async def post(
|
|
163
|
+
self,
|
|
164
|
+
path: str,
|
|
165
|
+
region: str | None = None,
|
|
166
|
+
params: dict[str, Any] | None = None,
|
|
167
|
+
json: Any = None,
|
|
168
|
+
) -> Any:
|
|
169
|
+
"""Send a POST request."""
|
|
170
|
+
return await self._request("POST", path, region=region, params=params, json=json)
|
|
171
|
+
|
|
172
|
+
async def put(
|
|
173
|
+
self,
|
|
174
|
+
path: str,
|
|
175
|
+
region: str | None = None,
|
|
176
|
+
params: dict[str, Any] | None = None,
|
|
177
|
+
json: Any = None,
|
|
178
|
+
) -> Any:
|
|
179
|
+
"""Send a PUT request."""
|
|
180
|
+
return await self._request("PUT", path, region=region, params=params, json=json)
|
|
181
|
+
|
|
182
|
+
async def delete(
|
|
183
|
+
self,
|
|
184
|
+
path: str,
|
|
185
|
+
region: str | None = None,
|
|
186
|
+
params: dict[str, Any] | None = None,
|
|
187
|
+
) -> Any:
|
|
188
|
+
"""Send a DELETE request."""
|
|
189
|
+
return await self._request("DELETE", path, region=region, params=params)
|
|
190
|
+
|
|
191
|
+
async def get_raw(
|
|
192
|
+
self,
|
|
193
|
+
path: str,
|
|
194
|
+
region: str | None = None,
|
|
195
|
+
params: dict[str, Any] | None = None,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""Send a GET request and return the raw response text."""
|
|
198
|
+
return await self._request(
|
|
199
|
+
"GET", path, region=region, params=params, raw_response=True
|
|
200
|
+
)
|