clue2app-cli 0.2.7__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.
- clue2app_cli/__init__.py +10 -0
- clue2app_cli/agents/__init__.py +9 -0
- clue2app_cli/agents/ecr_healer.py +475 -0
- clue2app_cli/auth.py +343 -0
- clue2app_cli/config.py +316 -0
- clue2app_cli/core_client.py +46 -0
- clue2app_cli/creds_module.py +253 -0
- clue2app_cli/curses_wizard.py +839 -0
- clue2app_cli/entrypoint_check.py +215 -0
- clue2app_cli/main.py +6711 -0
- clue2app_cli/sdk.py +2993 -0
- clue2app_cli/tui/__init__.py +33 -0
- clue2app_cli/tui/curses_app.py +733 -0
- clue2app_cli/tui/data.py +316 -0
- clue2app_cli/tui/screens/__init__.py +1 -0
- clue2app_cli/tui/textual_app.py +1021 -0
- clue2app_cli/tui/widgets/__init__.py +1 -0
- clue2app_cli/tui_wizard.py +987 -0
- clue2app_cli/version.py +3 -0
- clue2app_cli/wizard.py +1079 -0
- clue2app_cli-0.2.7.dist-info/METADATA +202 -0
- clue2app_cli-0.2.7.dist-info/RECORD +26 -0
- clue2app_cli-0.2.7.dist-info/WHEEL +5 -0
- clue2app_cli-0.2.7.dist-info/entry_points.txt +3 -0
- clue2app_cli-0.2.7.dist-info/licenses/LICENSE +21 -0
- clue2app_cli-0.2.7.dist-info/top_level.txt +1 -0
clue2app_cli/sdk.py
ADDED
|
@@ -0,0 +1,2993 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SDK integration module for Clue2App CLI.
|
|
3
|
+
|
|
4
|
+
This module provides integration with the clue2app-python-sdk and handles
|
|
5
|
+
API communication with the Clue2App platform.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Dict, Any, List, Optional
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
import platform
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from .auth import get_access_token, parse_jwt_token
|
|
17
|
+
from .config import get_config_value
|
|
18
|
+
from .entrypoint_check import detect_entrypoint_issue
|
|
19
|
+
|
|
20
|
+
from .version import __version__
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_ksvc_name(app_name: str) -> str:
|
|
26
|
+
"""Convert a user-facing app name to the KSVC k8s name.
|
|
27
|
+
|
|
28
|
+
Coord's `/api/kn-service/{name}/...` and `/api/k8s-service/{name}/...`
|
|
29
|
+
endpoints identify the target Knative service by name — which is the
|
|
30
|
+
displayName the user typed (via Utils.identifyServiceName's fallback
|
|
31
|
+
chain: record.service.name → record.service.displayName → record.displayName).
|
|
32
|
+
|
|
33
|
+
The kpack Image resource for the same app has a k8s name shaped like
|
|
34
|
+
"{projectTsPrefix}-{name}-image" (e.g. "1770055311930-speakez-image").
|
|
35
|
+
That name is NEVER a valid ksvc name — passing it to a ksvc endpoint
|
|
36
|
+
returns 424 "not found" even while the app serves traffic.
|
|
37
|
+
|
|
38
|
+
Historic bug: several SDK methods used `get_app()` to resolve
|
|
39
|
+
displayName → k8s Image, then passed image.metadata.name to a
|
|
40
|
+
ksvc-side endpoint. This helper defends against that: strip the
|
|
41
|
+
"-image" suffix + drop the timestamp prefix if present, so both
|
|
42
|
+
accidental and intentional callers reach the right resource.
|
|
43
|
+
Reported by SpeakEZ agent 2026-08-02.
|
|
44
|
+
"""
|
|
45
|
+
if not app_name:
|
|
46
|
+
return app_name
|
|
47
|
+
name = app_name.rstrip('/')
|
|
48
|
+
# Strip trailing "-image" (kpack Image naming convention)
|
|
49
|
+
if name.endswith("-image"):
|
|
50
|
+
name = name[:-len("-image")]
|
|
51
|
+
# Strip a leading "{projectTsPrefix}-" (14-15 digits, then a dash).
|
|
52
|
+
# Match the exact shape coord's personifyName() emits so we don't
|
|
53
|
+
# accidentally chew a legitimate name like "1234-my-app".
|
|
54
|
+
import re
|
|
55
|
+
m = re.match(r'^\d{13,16}-(.+)$', name)
|
|
56
|
+
if m:
|
|
57
|
+
return m.group(1)
|
|
58
|
+
return name
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SDKClient:
|
|
62
|
+
"""SDK client for Clue2App API."""
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
self.api_base_url = get_config_value("api.base_url", "https://coordinator.control.apps.clue2.app")
|
|
66
|
+
self.api_version = get_config_value("api.version", "v1")
|
|
67
|
+
self.api_timeout = get_config_value("api.timeout", 30)
|
|
68
|
+
self.headers = {
|
|
69
|
+
"User-Agent": f"Clue2App-CLI/{__version__} ({platform.system()} {platform.release()})",
|
|
70
|
+
"Accept": "application/json",
|
|
71
|
+
"Content-Type": "application/json"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def _make_api_request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
|
75
|
+
"""Make a request to the API."""
|
|
76
|
+
# Don't add version prefix for /api/ endpoints
|
|
77
|
+
if endpoint.startswith("/api/"):
|
|
78
|
+
url = f"{self.api_base_url}{endpoint}"
|
|
79
|
+
else:
|
|
80
|
+
url = f"{self.api_base_url}/{self.api_version}/{endpoint.lstrip('/')}"
|
|
81
|
+
|
|
82
|
+
# Add default timeout
|
|
83
|
+
kwargs.setdefault('timeout', self.api_timeout)
|
|
84
|
+
|
|
85
|
+
# Add authentication header if available
|
|
86
|
+
token = get_access_token()
|
|
87
|
+
if token:
|
|
88
|
+
headers = kwargs.get('headers', {})
|
|
89
|
+
headers['Authorization'] = f"Bearer {token}"
|
|
90
|
+
kwargs['headers'] = headers
|
|
91
|
+
|
|
92
|
+
return requests.request(method, url, **kwargs)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Clue2AppSDK:
|
|
96
|
+
"""
|
|
97
|
+
Main SDK client for interacting with the Clue2App platform.
|
|
98
|
+
|
|
99
|
+
This class provides a high-level interface to the Clue2App API,
|
|
100
|
+
wrapping the clue2app-python-sdk functionality.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self):
|
|
104
|
+
self.api_client = SDKClient()
|
|
105
|
+
self.base_url = get_config_value("api.base_url", "https://coordinator.control.apps.clue2.app")
|
|
106
|
+
self.core_base_url = get_config_value("core.base_url", "https://core.control.apps.clue2.app")
|
|
107
|
+
self.api_version = get_config_value("api.version", "v1")
|
|
108
|
+
|
|
109
|
+
def audit_code(self, git_url: str, revision: str = "main",
|
|
110
|
+
auth_username: str = "", auth_password: str = "",
|
|
111
|
+
timeout: int = 60) -> Dict[str, Any]:
|
|
112
|
+
"""Run the pre-build code-audit ruleset against a git ref.
|
|
113
|
+
|
|
114
|
+
Calls core's POST /api/v1/code/audit — no auth required (core's
|
|
115
|
+
public ksvc accepts the call; in-cluster URL also works).
|
|
116
|
+
Returns the full response: {gitUrl, revision, scannedAt,
|
|
117
|
+
findings: [...], criticalCount}.
|
|
118
|
+
"""
|
|
119
|
+
url = f"{self.core_base_url}/api/v1/k8s/code/audit"
|
|
120
|
+
body = {"gitUrl": git_url, "revision": revision}
|
|
121
|
+
if auth_username:
|
|
122
|
+
body["authUsername"] = auth_username
|
|
123
|
+
if auth_password:
|
|
124
|
+
body["authPassword"] = auth_password
|
|
125
|
+
resp = requests.post(url, json=body, timeout=timeout)
|
|
126
|
+
resp.raise_for_status()
|
|
127
|
+
return resp.json()
|
|
128
|
+
|
|
129
|
+
def _make_authenticated_request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
|
130
|
+
"""Make an authenticated request to the API."""
|
|
131
|
+
token = get_access_token()
|
|
132
|
+
if not token:
|
|
133
|
+
raise Exception("Not authenticated. Please login first.")
|
|
134
|
+
|
|
135
|
+
return self.api_client._make_api_request(method, endpoint, **kwargs)
|
|
136
|
+
|
|
137
|
+
# Application Management
|
|
138
|
+
def list_apps(self, project_id: str = None, use_context: bool = True, unlinked_only: bool = False, suppress_errors: bool = True) -> List[Dict[str, Any]]:
|
|
139
|
+
"""List all applications (images) for the current user or project.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
project_id: Filter by specific project ID
|
|
143
|
+
use_context: If True and project_id is None, use context project.
|
|
144
|
+
If False, return all apps from all projects.
|
|
145
|
+
unlinked_only: If True, return only apps not linked to any project.
|
|
146
|
+
suppress_errors: If True, return empty list on error. If False, raise exception.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
# If unlinked_only, just return apps from base endpoint (those with nil appGroupId)
|
|
150
|
+
if unlinked_only:
|
|
151
|
+
response = self._make_authenticated_request("GET", "/api/k8s-image/")
|
|
152
|
+
response.raise_for_status()
|
|
153
|
+
data = response.json()
|
|
154
|
+
return data if isinstance(data, list) else []
|
|
155
|
+
|
|
156
|
+
# Get project ID from context if not provided and use_context is True
|
|
157
|
+
if project_id is None and use_context:
|
|
158
|
+
project_id = get_config_value("context.project.id")
|
|
159
|
+
|
|
160
|
+
if project_id:
|
|
161
|
+
# Filter by specific project
|
|
162
|
+
endpoint = f"/api/k8s-image/?appGroupId={project_id}"
|
|
163
|
+
response = self._make_authenticated_request("GET", endpoint)
|
|
164
|
+
response.raise_for_status()
|
|
165
|
+
data = response.json()
|
|
166
|
+
return data if isinstance(data, list) else data.get("images", [])
|
|
167
|
+
else:
|
|
168
|
+
# Get all apps from all projects
|
|
169
|
+
all_apps = []
|
|
170
|
+
seen_names = set()
|
|
171
|
+
|
|
172
|
+
# First get unlinked apps (appGroupId = 00000000...)
|
|
173
|
+
response = self._make_authenticated_request("GET", "/api/k8s-image/")
|
|
174
|
+
response.raise_for_status()
|
|
175
|
+
unlinked = response.json() if isinstance(response.json(), list) else []
|
|
176
|
+
for app in unlinked:
|
|
177
|
+
name = app.get('metadata', {}).get('name')
|
|
178
|
+
if name and name not in seen_names:
|
|
179
|
+
all_apps.append(app)
|
|
180
|
+
seen_names.add(name)
|
|
181
|
+
|
|
182
|
+
# Then get apps from each project
|
|
183
|
+
projects = self.list_projects()
|
|
184
|
+
for project in projects:
|
|
185
|
+
pid = project.get('id')
|
|
186
|
+
if pid:
|
|
187
|
+
try:
|
|
188
|
+
response = self._make_authenticated_request("GET", f"/api/k8s-image/?appGroupId={pid}")
|
|
189
|
+
response.raise_for_status()
|
|
190
|
+
project_apps = response.json() if isinstance(response.json(), list) else []
|
|
191
|
+
for app in project_apps:
|
|
192
|
+
name = app.get('metadata', {}).get('name')
|
|
193
|
+
if name and name not in seen_names:
|
|
194
|
+
all_apps.append(app)
|
|
195
|
+
seen_names.add(name)
|
|
196
|
+
except:
|
|
197
|
+
pass # Skip projects we can't access
|
|
198
|
+
|
|
199
|
+
return all_apps
|
|
200
|
+
except requests.exceptions.RequestException as e:
|
|
201
|
+
if suppress_errors:
|
|
202
|
+
console.print(f"[red]Error listing apps:[/red] {e}")
|
|
203
|
+
return []
|
|
204
|
+
raise Exception(f"Error listing apps: {e}")
|
|
205
|
+
|
|
206
|
+
def list_projects(self, suppress_errors: bool = True) -> List[Dict[str, Any]]:
|
|
207
|
+
"""List all projects for the current user.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
suppress_errors: If True, return empty list on error. If False, raise exception.
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
response = self._make_authenticated_request("GET", "/api/k8s-project/")
|
|
214
|
+
response.raise_for_status()
|
|
215
|
+
data = response.json()
|
|
216
|
+
return data if isinstance(data, list) else data.get("projects", [])
|
|
217
|
+
except requests.exceptions.RequestException as e:
|
|
218
|
+
if suppress_errors:
|
|
219
|
+
console.print(f"[red]Error listing projects:[/red] {e}")
|
|
220
|
+
return []
|
|
221
|
+
raise Exception(f"Error listing projects: {e}")
|
|
222
|
+
|
|
223
|
+
def get_app(self, app_name: str) -> Optional[Dict[str, Any]]:
|
|
224
|
+
"""Get details for a specific application by name or displayName."""
|
|
225
|
+
try:
|
|
226
|
+
# First try direct lookup by name
|
|
227
|
+
response = self._make_authenticated_request("GET", f"/api/k8s-image/{app_name}")
|
|
228
|
+
if response.status_code == 200:
|
|
229
|
+
return response.json()
|
|
230
|
+
except:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
# If direct lookup fails, search by displayName or partial match
|
|
234
|
+
try:
|
|
235
|
+
apps = self.list_apps(use_context=False)
|
|
236
|
+
for app in apps:
|
|
237
|
+
meta = app.get('metadata', {})
|
|
238
|
+
name = meta.get('name', '')
|
|
239
|
+
display_name = meta.get('displayName', '')
|
|
240
|
+
# Match by displayName or if name ends with the search term
|
|
241
|
+
# Also try with -image suffix since kpack images have that naming
|
|
242
|
+
if (display_name == app_name or
|
|
243
|
+
display_name == f"{app_name}-image" or
|
|
244
|
+
name == app_name or
|
|
245
|
+
name.endswith(f"-{app_name}") or
|
|
246
|
+
name.endswith(f"-{app_name}-image")):
|
|
247
|
+
return app
|
|
248
|
+
except Exception as e:
|
|
249
|
+
console.print(f"[red]Error searching for app {app_name}:[/red] {e}")
|
|
250
|
+
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
def delete_app(self, app_name: str) -> bool:
|
|
254
|
+
"""Delete an application by name.
|
|
255
|
+
|
|
256
|
+
Note: Use the actual k8s name, not displayName. Use get_app() first to resolve.
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
# Try to resolve the name first if it's a displayName
|
|
260
|
+
app = self.get_app(app_name)
|
|
261
|
+
if app:
|
|
262
|
+
actual_name = app.get('metadata', {}).get('name', app_name)
|
|
263
|
+
else:
|
|
264
|
+
actual_name = app_name
|
|
265
|
+
|
|
266
|
+
response = self._make_authenticated_request("DELETE", f"/api/k8s-image/{actual_name}")
|
|
267
|
+
response.raise_for_status()
|
|
268
|
+
return True
|
|
269
|
+
except requests.exceptions.RequestException as e:
|
|
270
|
+
console.print(f"[red]Error deleting app {app_name}:[/red] {e}")
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
def rename_app(self, app_name: str, new_name: str) -> Optional[Dict[str, Any]]:
|
|
274
|
+
"""Rename an application.
|
|
275
|
+
|
|
276
|
+
This creates a new application with the new name and deletes the old one,
|
|
277
|
+
since Kubernetes resource names are immutable.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
app_name: Current application name (displayName used when creating the app)
|
|
281
|
+
new_name: New name for the application
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Dict with rename result on success, None on failure
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
# Call the name-based rename endpoint
|
|
288
|
+
project_id = get_config_value("context.project.id")
|
|
289
|
+
endpoint = "/api/k8s/app/rename"
|
|
290
|
+
if project_id:
|
|
291
|
+
endpoint = f"{endpoint}?appGroupId={project_id}"
|
|
292
|
+
|
|
293
|
+
payload = {"oldName": app_name, "newName": new_name}
|
|
294
|
+
response = self._make_authenticated_request("POST", endpoint, json=payload)
|
|
295
|
+
response.raise_for_status()
|
|
296
|
+
return response.json()
|
|
297
|
+
except requests.exceptions.RequestException as e:
|
|
298
|
+
error_detail = ""
|
|
299
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
300
|
+
try:
|
|
301
|
+
error_detail = e.response.json().get('message', '')
|
|
302
|
+
except:
|
|
303
|
+
error_detail = e.response.text[:200] if e.response.text else ''
|
|
304
|
+
console.print(f"[red]Error renaming app {app_name}:[/red] {e}")
|
|
305
|
+
if error_detail:
|
|
306
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
def get_image_build_env(self, image_name: str) -> Dict[str, str]:
|
|
310
|
+
"""Read the current spec.build.env off a kpack Image.
|
|
311
|
+
|
|
312
|
+
Returns a {name: value} dict. Empty if the Image has no build.env
|
|
313
|
+
block, or on any lookup failure (callers can then PATCH with just
|
|
314
|
+
the new vars).
|
|
315
|
+
|
|
316
|
+
image_name must be the k8s resource name (not the displayName).
|
|
317
|
+
Use get_app() to resolve first.
|
|
318
|
+
"""
|
|
319
|
+
try:
|
|
320
|
+
response = self._make_authenticated_request("GET", f"/api/k8s-image/{image_name}")
|
|
321
|
+
if response.status_code != 200:
|
|
322
|
+
return {}
|
|
323
|
+
image = response.json()
|
|
324
|
+
env_list = image.get('spec', {}).get('build', {}).get('env', []) or []
|
|
325
|
+
return {e.get('name'): e.get('value', '') for e in env_list if e.get('name')}
|
|
326
|
+
except Exception:
|
|
327
|
+
return {}
|
|
328
|
+
|
|
329
|
+
def patch_image_build_env(self, image_name: str, build_env: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
|
330
|
+
"""Set-and-replace the kpack Image's spec.build.env.
|
|
331
|
+
|
|
332
|
+
kpack watches spec.build.env and triggers a rebuild automatically
|
|
333
|
+
when it changes. Server-side endpoint added in coord PR #257
|
|
334
|
+
(SpeakEZ report finding #2).
|
|
335
|
+
|
|
336
|
+
Body is the FULL desired map — callers wanting to add one var
|
|
337
|
+
should GET the current, merge, then PATCH. See CLI's
|
|
338
|
+
`c2a env set --build` for the merge dance.
|
|
339
|
+
"""
|
|
340
|
+
try:
|
|
341
|
+
response = self._make_authenticated_request(
|
|
342
|
+
"PATCH", f"/api/k8s-image/{image_name}/build-env",
|
|
343
|
+
json=build_env,
|
|
344
|
+
)
|
|
345
|
+
response.raise_for_status()
|
|
346
|
+
return response.json()
|
|
347
|
+
except requests.exceptions.RequestException as e:
|
|
348
|
+
error_detail = ""
|
|
349
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
350
|
+
try:
|
|
351
|
+
error_detail = e.response.json().get('message', '')
|
|
352
|
+
except:
|
|
353
|
+
error_detail = e.response.text[:200] if e.response.text else ''
|
|
354
|
+
console.print(f"[red]Error patching build-env on {image_name}:[/red] {e}")
|
|
355
|
+
if error_detail:
|
|
356
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def rebuild_app(self, app_name: str) -> Optional[Dict[str, Any]]:
|
|
360
|
+
"""Trigger a rebuild for an application by name.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
app_name: The application name (can be displayName or actual k8s name)
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
Dict with status and buildNumber on success, None on failure
|
|
367
|
+
"""
|
|
368
|
+
try:
|
|
369
|
+
# Try to resolve the name first if it's a displayName
|
|
370
|
+
app = self.get_app(app_name)
|
|
371
|
+
if app:
|
|
372
|
+
actual_name = app.get('metadata', {}).get('name', app_name)
|
|
373
|
+
else:
|
|
374
|
+
actual_name = app_name
|
|
375
|
+
|
|
376
|
+
response = self._make_authenticated_request("POST", f"/api/k8s-image/rebuild/{actual_name}")
|
|
377
|
+
response.raise_for_status()
|
|
378
|
+
return response.json()
|
|
379
|
+
except requests.exceptions.RequestException as e:
|
|
380
|
+
# Try to get more details from the response
|
|
381
|
+
error_detail = ""
|
|
382
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
383
|
+
try:
|
|
384
|
+
error_detail = e.response.json().get('message', '')
|
|
385
|
+
except:
|
|
386
|
+
error_detail = e.response.text[:200] if e.response.text else ''
|
|
387
|
+
console.print(f"[red]Error rebuilding app {app_name}:[/red] {e}")
|
|
388
|
+
if error_detail:
|
|
389
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
def verify_rebuild_scheduled(self, app_name: str, rebuild_called_at,
|
|
393
|
+
timeout_seconds: int = 30) -> Dict[str, Any]:
|
|
394
|
+
"""After rebuild_app, poll the Image's builds and check if a new
|
|
395
|
+
one was actually scheduled.
|
|
396
|
+
|
|
397
|
+
Background: kpack does NOT auto-reschedule when the latest Build
|
|
398
|
+
is in Succeeded=False. The rebuild API call returns 200 (sets
|
|
399
|
+
the kpack.io/rebuild annotation) but no new Build appears. The
|
|
400
|
+
Image is wedged; the operator's mental model says "I called
|
|
401
|
+
rebuild and it returned success" but reality is no kpack Build
|
|
402
|
+
was created. See heal_agent_wedged_kpack_plan for the full
|
|
403
|
+
incident retro.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
{
|
|
407
|
+
"took_effect": bool,
|
|
408
|
+
"latest_build_name": str | None,
|
|
409
|
+
"latest_build_age_seconds": float | None,
|
|
410
|
+
"build_count": int,
|
|
411
|
+
}
|
|
412
|
+
"""
|
|
413
|
+
import time
|
|
414
|
+
from datetime import datetime, timezone
|
|
415
|
+
|
|
416
|
+
# Resolve to k8s name (rebuild_app accepts displayName too).
|
|
417
|
+
app = self.get_app(app_name)
|
|
418
|
+
actual_name = (app.get('metadata', {}).get('name', app_name)
|
|
419
|
+
if app else app_name)
|
|
420
|
+
# Allow 5s clock skew between local + cluster.
|
|
421
|
+
threshold = rebuild_called_at - 5
|
|
422
|
+
|
|
423
|
+
deadline = time.time() + timeout_seconds
|
|
424
|
+
last_state = {"took_effect": False, "latest_build_name": None,
|
|
425
|
+
"latest_build_age_seconds": None, "build_count": 0}
|
|
426
|
+
while time.time() < deadline:
|
|
427
|
+
try:
|
|
428
|
+
builds = self.get_builds(image_name=actual_name) or []
|
|
429
|
+
except Exception:
|
|
430
|
+
builds = []
|
|
431
|
+
newest = None
|
|
432
|
+
newest_ts = 0
|
|
433
|
+
for b in builds:
|
|
434
|
+
meta = b.get('metadata', {}) or {}
|
|
435
|
+
ts_str = meta.get('creationTimestamp', '')
|
|
436
|
+
if not ts_str:
|
|
437
|
+
continue
|
|
438
|
+
try:
|
|
439
|
+
# Parse RFC3339; tolerate the "Z" suffix.
|
|
440
|
+
ts = datetime.strptime(ts_str.replace("Z", "+0000"),
|
|
441
|
+
"%Y-%m-%dT%H:%M:%S%z").timestamp()
|
|
442
|
+
except Exception:
|
|
443
|
+
continue
|
|
444
|
+
if ts > newest_ts:
|
|
445
|
+
newest_ts = ts
|
|
446
|
+
newest = meta.get('name', '')
|
|
447
|
+
last_state["build_count"] = len(builds)
|
|
448
|
+
last_state["latest_build_name"] = newest
|
|
449
|
+
last_state["latest_build_age_seconds"] = (
|
|
450
|
+
time.time() - newest_ts if newest else None
|
|
451
|
+
)
|
|
452
|
+
if newest and newest_ts >= threshold:
|
|
453
|
+
last_state["took_effect"] = True
|
|
454
|
+
return last_state
|
|
455
|
+
time.sleep(3)
|
|
456
|
+
return last_state
|
|
457
|
+
|
|
458
|
+
# ---------------- Daari Secrets ----------------
|
|
459
|
+
|
|
460
|
+
def daari_secrets_list(self) -> List[Dict[str, Any]]:
|
|
461
|
+
"""List Daari Secrets in the current project."""
|
|
462
|
+
try:
|
|
463
|
+
response = self._make_authenticated_request("GET", "/api/v1/daari/secrets/")
|
|
464
|
+
response.raise_for_status()
|
|
465
|
+
return response.json() or []
|
|
466
|
+
except requests.exceptions.RequestException as e:
|
|
467
|
+
console.print(f"[red]Error listing Daari Secrets:[/red] {e}")
|
|
468
|
+
return []
|
|
469
|
+
|
|
470
|
+
def daari_secret_get(self, k8s_name: str) -> Optional[Dict[str, Any]]:
|
|
471
|
+
"""Get one Daari Secret's metadata (keys + bindings; never values)."""
|
|
472
|
+
try:
|
|
473
|
+
response = self._make_authenticated_request("GET", f"/api/v1/daari/secrets/{k8s_name}")
|
|
474
|
+
if response.status_code == 404:
|
|
475
|
+
return None
|
|
476
|
+
response.raise_for_status()
|
|
477
|
+
return response.json()
|
|
478
|
+
except requests.exceptions.RequestException as e:
|
|
479
|
+
console.print(f"[red]Error fetching Daari Secret {k8s_name}:[/red] {e}")
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
def daari_secret_create(self, display_name: str, data: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
|
483
|
+
"""Create a new Daari Secret in the current project."""
|
|
484
|
+
try:
|
|
485
|
+
response = self._make_authenticated_request(
|
|
486
|
+
"POST",
|
|
487
|
+
"/api/v1/daari/secrets/",
|
|
488
|
+
json={"displayName": display_name, "data": data},
|
|
489
|
+
)
|
|
490
|
+
response.raise_for_status()
|
|
491
|
+
return response.json()
|
|
492
|
+
except requests.exceptions.RequestException as e:
|
|
493
|
+
self._print_api_error(f"creating Daari Secret '{display_name}'", e)
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
def daari_secret_rotate(self, k8s_name: str, data: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
|
497
|
+
"""Rotate values for the keys passed; merge semantics."""
|
|
498
|
+
try:
|
|
499
|
+
response = self._make_authenticated_request(
|
|
500
|
+
"PUT",
|
|
501
|
+
f"/api/v1/daari/secrets/{k8s_name}/values",
|
|
502
|
+
json={"data": data},
|
|
503
|
+
)
|
|
504
|
+
response.raise_for_status()
|
|
505
|
+
return response.json()
|
|
506
|
+
except requests.exceptions.RequestException as e:
|
|
507
|
+
self._print_api_error(f"rotating {k8s_name}", e)
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
def daari_secret_delete(self, k8s_name: str, force: bool = False) -> bool:
|
|
511
|
+
"""Delete a Daari Secret. Returns True on success."""
|
|
512
|
+
try:
|
|
513
|
+
response = self._make_authenticated_request(
|
|
514
|
+
"DELETE",
|
|
515
|
+
f"/api/v1/daari/secrets/{k8s_name}",
|
|
516
|
+
params={"force": "true" if force else "false"},
|
|
517
|
+
)
|
|
518
|
+
if response.status_code == 204:
|
|
519
|
+
return True
|
|
520
|
+
response.raise_for_status()
|
|
521
|
+
return True
|
|
522
|
+
except requests.exceptions.RequestException as e:
|
|
523
|
+
self._print_api_error(f"deleting {k8s_name}", e)
|
|
524
|
+
return False
|
|
525
|
+
|
|
526
|
+
def daari_secret_bindings(self, k8s_name: str) -> List[Dict[str, Any]]:
|
|
527
|
+
"""List apps bound to a Daari Secret."""
|
|
528
|
+
try:
|
|
529
|
+
response = self._make_authenticated_request(
|
|
530
|
+
"GET", f"/api/v1/daari/secrets/{k8s_name}/bindings")
|
|
531
|
+
response.raise_for_status()
|
|
532
|
+
return response.json() or []
|
|
533
|
+
except requests.exceptions.RequestException as e:
|
|
534
|
+
console.print(f"[red]Error listing bindings:[/red] {e}")
|
|
535
|
+
return []
|
|
536
|
+
|
|
537
|
+
def _print_api_error(self, action: str, e):
|
|
538
|
+
error_detail = ""
|
|
539
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
540
|
+
try:
|
|
541
|
+
error_detail = e.response.json().get('message', '')
|
|
542
|
+
except Exception:
|
|
543
|
+
error_detail = e.response.text[:200] if e.response.text else ''
|
|
544
|
+
console.print(f"[red]Error {action}:[/red] {e}")
|
|
545
|
+
if error_detail:
|
|
546
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
547
|
+
|
|
548
|
+
def set_autoscaling(self, app_name: str, min_scale: Optional[int] = None,
|
|
549
|
+
max_scale: Optional[int] = None, window: Optional[str] = None,
|
|
550
|
+
scale_to_zero: bool = False) -> Optional[Dict[str, Any]]:
|
|
551
|
+
"""Pin warm / allow scale-to-zero on an app via Knative
|
|
552
|
+
autoscaling annotations. Only the fields you pass are written;
|
|
553
|
+
other annotations on the ksvc are preserved by the server-side
|
|
554
|
+
merge-patch.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
app_name: target ksvc / app name (displayName or k8s name)
|
|
558
|
+
min_scale: minimum replicas (0 allows scale-to-zero, 1+ pins warm)
|
|
559
|
+
max_scale: maximum replicas (>= 1)
|
|
560
|
+
window: Knative stable window (e.g. "30m", "60s")
|
|
561
|
+
scale_to_zero: equivalent to min_scale=0; convenience flag
|
|
562
|
+
|
|
563
|
+
Knative auto-rolls a fresh revision because spec.template
|
|
564
|
+
changed. Survives c2a app rebuild + c2a app env set.
|
|
565
|
+
"""
|
|
566
|
+
try:
|
|
567
|
+
# NOTE: coord's /api/kn-service/{name}/... endpoints expect the
|
|
568
|
+
# ksvc name (== the app's displayName, per Utils.identifyServiceName).
|
|
569
|
+
# get_app() returns the kpack Image resource whose metadata.name
|
|
570
|
+
# is "{namePrefix}-image" (e.g. "1770055311930-speakez-image") —
|
|
571
|
+
# passing THAT to a ksvc endpoint 424s with "ksvc not found"
|
|
572
|
+
# even though the app is serving traffic. SpeakEZ report
|
|
573
|
+
# 2026-08-02. Pass app_name (what the user typed) directly.
|
|
574
|
+
ksvc_name = _resolve_ksvc_name(app_name)
|
|
575
|
+
body: Dict[str, Any] = {}
|
|
576
|
+
if min_scale is not None:
|
|
577
|
+
body["minScale"] = min_scale
|
|
578
|
+
if max_scale is not None:
|
|
579
|
+
body["maxScale"] = max_scale
|
|
580
|
+
if window:
|
|
581
|
+
body["window"] = window
|
|
582
|
+
if scale_to_zero:
|
|
583
|
+
body["scaleToZero"] = True
|
|
584
|
+
if not body:
|
|
585
|
+
console.print("[red]Nothing to set — pass at least one of --min/--max/--window/--scale-to-zero.[/red]")
|
|
586
|
+
return None
|
|
587
|
+
# Pass projectId so coord can resolve the ksvc's namespace
|
|
588
|
+
# (via getCurrentProject). Without this the endpoint's
|
|
589
|
+
# getCurrentNamespace() falls back to DEFAULT_NAMESPACE and
|
|
590
|
+
# the ksvc lookup returns "not found" for any app not in
|
|
591
|
+
# the platform's own namespace. Same shape as logs / bindings
|
|
592
|
+
# calls already use.
|
|
593
|
+
params: Dict[str, str] = {}
|
|
594
|
+
project_id = get_config_value("context.project.id")
|
|
595
|
+
if project_id:
|
|
596
|
+
params["projectId"] = project_id
|
|
597
|
+
response = self._make_authenticated_request(
|
|
598
|
+
"POST",
|
|
599
|
+
f"/api/kn-service/{ksvc_name}/autoscaling",
|
|
600
|
+
json=body,
|
|
601
|
+
params=params if params else None,
|
|
602
|
+
)
|
|
603
|
+
response.raise_for_status()
|
|
604
|
+
return response.json()
|
|
605
|
+
except requests.exceptions.RequestException as e:
|
|
606
|
+
self._print_api_error(f"setting autoscaling on {app_name}", e)
|
|
607
|
+
return None
|
|
608
|
+
|
|
609
|
+
def add_service_binding(self, app_name: str, binding_secret_name: str,
|
|
610
|
+
restart: bool = True) -> Optional[Dict[str, Any]]:
|
|
611
|
+
"""Append a binding to an app's <app>-service-binding Secret.
|
|
612
|
+
|
|
613
|
+
Resolves the binding Secret server-side: coordinator reads its
|
|
614
|
+
`serviceCatalog.targetDisplayName` label (or `targetName` fallback)
|
|
615
|
+
for the binding key, decodes its data values, and merges into the
|
|
616
|
+
existing C2A_SYSTEM_ENV.binding map. variables / secrets / other
|
|
617
|
+
bindings are preserved.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
app_name: target ksvc / app name (NOT displayName)
|
|
621
|
+
binding_secret_name: the bound Secret name in the same namespace
|
|
622
|
+
restart: when True, also restart the ksvc so the new binding
|
|
623
|
+
takes effect immediately
|
|
624
|
+
|
|
625
|
+
See `service_binding_architecture.md` in memory for the C2A_SYSTEM_ENV
|
|
626
|
+
contract this funnels through.
|
|
627
|
+
"""
|
|
628
|
+
try:
|
|
629
|
+
# See set_autoscaling — pass app_name (== ksvc name) directly,
|
|
630
|
+
# not the get_app()-resolved kpack Image name.
|
|
631
|
+
ksvc_name = _resolve_ksvc_name(app_name)
|
|
632
|
+
response = self._make_authenticated_request(
|
|
633
|
+
"POST",
|
|
634
|
+
f"/api/kn-service/{ksvc_name}/bindings",
|
|
635
|
+
params={"restart": "true" if restart else "false"},
|
|
636
|
+
json={"name": binding_secret_name},
|
|
637
|
+
)
|
|
638
|
+
response.raise_for_status()
|
|
639
|
+
return response.json()
|
|
640
|
+
except requests.exceptions.RequestException as e:
|
|
641
|
+
error_detail = ""
|
|
642
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
643
|
+
try:
|
|
644
|
+
error_detail = e.response.json().get('message', '')
|
|
645
|
+
except Exception:
|
|
646
|
+
error_detail = e.response.text[:200] if e.response.text else ''
|
|
647
|
+
console.print(f"[red]Error binding {binding_secret_name} to {app_name}:[/red] {e}")
|
|
648
|
+
if error_detail:
|
|
649
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
650
|
+
return None
|
|
651
|
+
|
|
652
|
+
def list_service_bindings(self, app_name: str) -> Optional[List[Dict[str, Any]]]:
|
|
653
|
+
"""List the Daari services currently bound to an app.
|
|
654
|
+
|
|
655
|
+
Returns a list of {kind, displayName, k8sName} dicts, in server order.
|
|
656
|
+
Empty list when the app has never been bound. None on transport error.
|
|
657
|
+
|
|
658
|
+
Backed by coordinator GET /api/kn-service/{name}/bindings.
|
|
659
|
+
"""
|
|
660
|
+
try:
|
|
661
|
+
ksvc_name = _resolve_ksvc_name(app_name)
|
|
662
|
+
response = self._make_authenticated_request(
|
|
663
|
+
"GET",
|
|
664
|
+
f"/api/kn-service/{ksvc_name}/bindings",
|
|
665
|
+
)
|
|
666
|
+
response.raise_for_status()
|
|
667
|
+
data = response.json()
|
|
668
|
+
return data if isinstance(data, list) else []
|
|
669
|
+
except requests.exceptions.RequestException as e:
|
|
670
|
+
console.print(f"[red]Error listing bindings for {app_name}:[/red] {e}")
|
|
671
|
+
return None
|
|
672
|
+
|
|
673
|
+
def remove_service_binding(self, app_name: str, binding_key: str,
|
|
674
|
+
restart: bool = True) -> Optional[Dict[str, Any]]:
|
|
675
|
+
"""Remove a Daari binding from an app.
|
|
676
|
+
|
|
677
|
+
Mirror of add_service_binding — same primitive, opposite direction.
|
|
678
|
+
|
|
679
|
+
binding_key may be either the display-name key that appears in
|
|
680
|
+
C2A_SYSTEM_ENV.binding OR the source binding secret's k8s name.
|
|
681
|
+
The coordinator resolves either.
|
|
682
|
+
|
|
683
|
+
Idempotent: unbinding a key that isn't currently bound returns
|
|
684
|
+
200 with binding.notFound=true — safe to retry.
|
|
685
|
+
"""
|
|
686
|
+
try:
|
|
687
|
+
ksvc_name = _resolve_ksvc_name(app_name)
|
|
688
|
+
from urllib.parse import quote
|
|
689
|
+
response = self._make_authenticated_request(
|
|
690
|
+
"DELETE",
|
|
691
|
+
f"/api/kn-service/{ksvc_name}/bindings/{quote(binding_key, safe='')}",
|
|
692
|
+
params={"restart": "true" if restart else "false"},
|
|
693
|
+
)
|
|
694
|
+
response.raise_for_status()
|
|
695
|
+
return response.json()
|
|
696
|
+
except requests.exceptions.RequestException as e:
|
|
697
|
+
error_detail = ""
|
|
698
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
699
|
+
try:
|
|
700
|
+
error_detail = e.response.json().get('message', '')
|
|
701
|
+
except Exception:
|
|
702
|
+
error_detail = e.response.text[:200] if e.response.text else ''
|
|
703
|
+
console.print(f"[red]Error unbinding {binding_key} from {app_name}:[/red] {e}")
|
|
704
|
+
if error_detail:
|
|
705
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
706
|
+
return None
|
|
707
|
+
|
|
708
|
+
def sync_image_to_service(self, app_name: str) -> Optional[Dict[str, Any]]:
|
|
709
|
+
"""Sync kpack image to Knative service after a build completes.
|
|
710
|
+
|
|
711
|
+
This updates the Knative service to use the latest image from kpack.
|
|
712
|
+
Call this after a build completes to deploy the new image.
|
|
713
|
+
|
|
714
|
+
Args:
|
|
715
|
+
app_name: The application name (can be displayName or actual k8s name)
|
|
716
|
+
|
|
717
|
+
Returns:
|
|
718
|
+
Dict with sync status on success, None on failure
|
|
719
|
+
"""
|
|
720
|
+
try:
|
|
721
|
+
# Try to resolve the name first if it's a displayName
|
|
722
|
+
app = self.get_app(app_name)
|
|
723
|
+
if app:
|
|
724
|
+
actual_name = app.get('metadata', {}).get('name', app_name)
|
|
725
|
+
namespace = app.get('metadata', {}).get('namespace')
|
|
726
|
+
else:
|
|
727
|
+
actual_name = app_name
|
|
728
|
+
namespace = None
|
|
729
|
+
|
|
730
|
+
if not namespace:
|
|
731
|
+
# Try to get namespace from context
|
|
732
|
+
context = self.get_project_context()
|
|
733
|
+
namespace = context.get('namespace') if context else None
|
|
734
|
+
|
|
735
|
+
if not namespace:
|
|
736
|
+
console.print("[red]Error:[/red] Could not determine namespace")
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
payload = {"ns": namespace}
|
|
740
|
+
response = self._make_authenticated_request(
|
|
741
|
+
"POST",
|
|
742
|
+
f"/api/k8s-image/sync/{actual_name}",
|
|
743
|
+
json=payload
|
|
744
|
+
)
|
|
745
|
+
response.raise_for_status()
|
|
746
|
+
return response.json()
|
|
747
|
+
except requests.exceptions.RequestException as e:
|
|
748
|
+
error_detail = ""
|
|
749
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
750
|
+
try:
|
|
751
|
+
error_detail = e.response.json().get('message', '')
|
|
752
|
+
except:
|
|
753
|
+
error_detail = e.response.text[:200] if e.response.text else ''
|
|
754
|
+
console.print(f"[red]Error syncing image for {app_name}:[/red] {e}")
|
|
755
|
+
if error_detail:
|
|
756
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
def get_service_account(self, name: str = "system-default") -> Optional[Dict[str, Any]]:
|
|
760
|
+
"""Fetch a ServiceAccount in the active project's namespace.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
name: SA name. Defaults to `system-default` (the SA wizard-v3 apps share).
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
The SA resource dict, or None if not found.
|
|
767
|
+
"""
|
|
768
|
+
try:
|
|
769
|
+
project_id = get_config_value("context.project.id")
|
|
770
|
+
endpoint = f"/api/k8s-service-account/{name}"
|
|
771
|
+
if project_id:
|
|
772
|
+
endpoint = f"{endpoint}?projectId={project_id}"
|
|
773
|
+
|
|
774
|
+
response = self._make_authenticated_request("GET", endpoint)
|
|
775
|
+
if response.status_code == 404:
|
|
776
|
+
return None
|
|
777
|
+
response.raise_for_status()
|
|
778
|
+
if not response.text or response.text.strip() in ('', 'null'):
|
|
779
|
+
return None
|
|
780
|
+
return response.json()
|
|
781
|
+
except requests.exceptions.RequestException as e:
|
|
782
|
+
console.print(f"[red]Error fetching service account {name}:[/red] {e}")
|
|
783
|
+
return None
|
|
784
|
+
|
|
785
|
+
def get_service(self, service_name: str, namespace: str = None) -> Optional[Dict[str, Any]]:
|
|
786
|
+
"""Get details of a Knative service.
|
|
787
|
+
|
|
788
|
+
Args:
|
|
789
|
+
service_name: The service name
|
|
790
|
+
namespace: Optional namespace (uses project context if not specified)
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
Dict with service details on success, None on failure
|
|
794
|
+
"""
|
|
795
|
+
try:
|
|
796
|
+
if not namespace:
|
|
797
|
+
context = self.get_project_context()
|
|
798
|
+
namespace = context.get('namespace') if context else None
|
|
799
|
+
|
|
800
|
+
if not namespace:
|
|
801
|
+
console.print("[red]Error:[/red] Could not determine namespace")
|
|
802
|
+
return None
|
|
803
|
+
|
|
804
|
+
response = self._make_authenticated_request(
|
|
805
|
+
"GET",
|
|
806
|
+
f"/api/kn-service/{service_name}",
|
|
807
|
+
params={"ns": namespace}
|
|
808
|
+
)
|
|
809
|
+
response.raise_for_status()
|
|
810
|
+
|
|
811
|
+
# Handle empty response body (coordinator returns 200 with null when service not found)
|
|
812
|
+
if not response.text or response.text.strip() in ('', 'null'):
|
|
813
|
+
return None
|
|
814
|
+
|
|
815
|
+
data = response.json()
|
|
816
|
+
|
|
817
|
+
# Extract service from response
|
|
818
|
+
service = data.get('Service', data)
|
|
819
|
+
if service:
|
|
820
|
+
# Add computed URL from status
|
|
821
|
+
url = None
|
|
822
|
+
if 'status' in service:
|
|
823
|
+
url = service['status'].get('url')
|
|
824
|
+
if not url and 'address' in service['status']:
|
|
825
|
+
url = service['status']['address'].get('url')
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
'name': service.get('metadata', {}).get('name'),
|
|
829
|
+
'namespace': service.get('metadata', {}).get('namespace'),
|
|
830
|
+
'url': url,
|
|
831
|
+
'ready': self._is_service_ready(service),
|
|
832
|
+
'image': self._get_service_image(service),
|
|
833
|
+
'created': service.get('metadata', {}).get('creationTimestamp'),
|
|
834
|
+
'raw': service
|
|
835
|
+
}
|
|
836
|
+
return None
|
|
837
|
+
except requests.exceptions.RequestException as e:
|
|
838
|
+
console.print(f"[red]Error getting service {service_name}:[/red] {e}")
|
|
839
|
+
return None
|
|
840
|
+
|
|
841
|
+
def _is_service_ready(self, service: Dict) -> bool:
|
|
842
|
+
"""Check if a Knative service is ready."""
|
|
843
|
+
conditions = service.get('status', {}).get('conditions', [])
|
|
844
|
+
for cond in conditions:
|
|
845
|
+
if cond.get('type') == 'Ready':
|
|
846
|
+
return cond.get('status') == 'True'
|
|
847
|
+
return False
|
|
848
|
+
|
|
849
|
+
def _get_service_image(self, service: Dict) -> Optional[str]:
|
|
850
|
+
"""Extract the container image from a Knative service."""
|
|
851
|
+
try:
|
|
852
|
+
containers = service.get('spec', {}).get('template', {}).get('spec', {}).get('containers', [])
|
|
853
|
+
if containers:
|
|
854
|
+
return containers[0].get('image')
|
|
855
|
+
except:
|
|
856
|
+
pass
|
|
857
|
+
return None
|
|
858
|
+
|
|
859
|
+
def trigger_heal_agent(self, dry_run: bool = False, project_id: str = None) -> Dict[str, Any]:
|
|
860
|
+
"""Trigger the platform's self-healing agent.
|
|
861
|
+
|
|
862
|
+
The healer agent detects and resolves infrastructure issues like:
|
|
863
|
+
- ECR credential expiration
|
|
864
|
+
- Failed builds that can be retried
|
|
865
|
+
- Service health issues
|
|
866
|
+
|
|
867
|
+
Args:
|
|
868
|
+
dry_run: If True, detect issues but don't take action
|
|
869
|
+
project_id: Optional project ID to limit scope. If None, checks all projects.
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
Dict with:
|
|
873
|
+
- success: bool
|
|
874
|
+
- issues_detected: int
|
|
875
|
+
- issues_healed: int
|
|
876
|
+
- actions: list of actions taken
|
|
877
|
+
- error: optional error message
|
|
878
|
+
"""
|
|
879
|
+
try:
|
|
880
|
+
payload = {
|
|
881
|
+
"dry_run": dry_run,
|
|
882
|
+
}
|
|
883
|
+
if project_id:
|
|
884
|
+
payload["project_id"] = project_id
|
|
885
|
+
|
|
886
|
+
response = self._make_authenticated_request(
|
|
887
|
+
"POST",
|
|
888
|
+
"/api/admin/heal",
|
|
889
|
+
json=payload
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
if response.status_code == 200:
|
|
893
|
+
return response.json()
|
|
894
|
+
body_lower = (response.text or "").lower()
|
|
895
|
+
# Spring returns 404 for a truly-missing route, but some
|
|
896
|
+
# configurations wrap it as 500 with body containing "no static
|
|
897
|
+
# resource ...". Treat both as "endpoint not implemented" so the
|
|
898
|
+
# CLI can print a friendly message + suggest --local mode
|
|
899
|
+
# (SpeakEZ report finding #5).
|
|
900
|
+
if response.status_code == 404 or "no static resource" in body_lower:
|
|
901
|
+
return {
|
|
902
|
+
"success": False,
|
|
903
|
+
"error": "Heal endpoint not available. The coordinator needs to implement /api/admin/heal"
|
|
904
|
+
}
|
|
905
|
+
return {
|
|
906
|
+
"success": False,
|
|
907
|
+
"error": f"HTTP {response.status_code}: {response.text[:200]}"
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
except requests.exceptions.RequestException as e:
|
|
911
|
+
error_detail = ""
|
|
912
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
913
|
+
try:
|
|
914
|
+
error_detail = e.response.json().get('message', '')
|
|
915
|
+
except:
|
|
916
|
+
error_detail = str(e)
|
|
917
|
+
return {
|
|
918
|
+
"success": False,
|
|
919
|
+
"error": error_detail or str(e)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
def get_app_runtime_metrics(self, service_name: str = None, project_id: str = None) -> List[Dict[str, Any]]:
|
|
923
|
+
"""Get runtime metrics for services.
|
|
924
|
+
|
|
925
|
+
Args:
|
|
926
|
+
service_name: Optional specific service name. If None, returns all services.
|
|
927
|
+
project_id: Optional project ID to filter by. If None, uses context project.
|
|
928
|
+
|
|
929
|
+
Returns:
|
|
930
|
+
List of runtime metrics with pods, cpu, memory, url, status etc.
|
|
931
|
+
"""
|
|
932
|
+
try:
|
|
933
|
+
# Get project ID from context if not provided
|
|
934
|
+
if not project_id:
|
|
935
|
+
project_id = get_config_value("context.project.id")
|
|
936
|
+
|
|
937
|
+
params = {}
|
|
938
|
+
if project_id:
|
|
939
|
+
params['projectId'] = project_id
|
|
940
|
+
|
|
941
|
+
if service_name:
|
|
942
|
+
endpoint = f"/api/metrics/realtime/{service_name}"
|
|
943
|
+
if params:
|
|
944
|
+
endpoint += "?" + "&".join(f"{k}={v}" for k, v in params.items())
|
|
945
|
+
response = self._make_authenticated_request("GET", endpoint)
|
|
946
|
+
if response.status_code == 200:
|
|
947
|
+
data = response.json()
|
|
948
|
+
return [data] if isinstance(data, dict) else data
|
|
949
|
+
return []
|
|
950
|
+
else:
|
|
951
|
+
endpoint = "/api/metrics/realtime"
|
|
952
|
+
if params:
|
|
953
|
+
endpoint += "?" + "&".join(f"{k}={v}" for k, v in params.items())
|
|
954
|
+
response = self._make_authenticated_request("GET", endpoint)
|
|
955
|
+
response.raise_for_status()
|
|
956
|
+
return response.json() if isinstance(response.json(), list) else []
|
|
957
|
+
except:
|
|
958
|
+
return []
|
|
959
|
+
|
|
960
|
+
def get_knative_service(self, service_name: str) -> Optional[Dict[str, Any]]:
|
|
961
|
+
"""Get Knative service details by name."""
|
|
962
|
+
try:
|
|
963
|
+
response = self._make_authenticated_request("GET", f"/api/kn-service/{service_name}")
|
|
964
|
+
if response.status_code == 200:
|
|
965
|
+
return response.json()
|
|
966
|
+
return None
|
|
967
|
+
except:
|
|
968
|
+
return None
|
|
969
|
+
|
|
970
|
+
def restart_service(self, service_name: str, namespace: Optional[str] = None) -> Dict[str, Any]:
|
|
971
|
+
"""Restart/wake up a Knative service (useful for scaled-to-zero services).
|
|
972
|
+
|
|
973
|
+
Args:
|
|
974
|
+
service_name: The service name to restart
|
|
975
|
+
namespace: The namespace where the service is deployed
|
|
976
|
+
|
|
977
|
+
Returns:
|
|
978
|
+
Dict with 'success' bool and 'error' message if failed
|
|
979
|
+
"""
|
|
980
|
+
try:
|
|
981
|
+
url = f"/api/kn-service/{service_name}/restart"
|
|
982
|
+
if namespace:
|
|
983
|
+
url += f"?namespace={namespace}"
|
|
984
|
+
response = self._make_authenticated_request("PUT", url)
|
|
985
|
+
response.raise_for_status()
|
|
986
|
+
return {"success": True, "data": response.json()}
|
|
987
|
+
except requests.exceptions.RequestException as e:
|
|
988
|
+
error_msg = str(e)
|
|
989
|
+
# Try to get more details from response
|
|
990
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
991
|
+
try:
|
|
992
|
+
error_data = e.response.json()
|
|
993
|
+
if 'error' in error_data:
|
|
994
|
+
error_msg = error_data['error']
|
|
995
|
+
elif 'message' in error_data:
|
|
996
|
+
error_msg = error_data['message']
|
|
997
|
+
except:
|
|
998
|
+
error_msg = e.response.text[:200] if e.response.text else str(e)
|
|
999
|
+
return {"success": False, "error": error_msg}
|
|
1000
|
+
|
|
1001
|
+
def create_project(self, name: str, description: str = "") -> Optional[Dict[str, Any]]:
|
|
1002
|
+
"""Create a new project with the current user as contributor."""
|
|
1003
|
+
# Get current user info from token to add as contributor
|
|
1004
|
+
token = get_access_token()
|
|
1005
|
+
user_info = {}
|
|
1006
|
+
if token:
|
|
1007
|
+
try:
|
|
1008
|
+
token_data = parse_jwt_token(token)
|
|
1009
|
+
user_info = {
|
|
1010
|
+
"id": token_data.get("uid"),
|
|
1011
|
+
"username": token_data.get("sub"),
|
|
1012
|
+
"email": token_data.get("email"),
|
|
1013
|
+
"userType": token_data.get("userType", "SYSTEM"),
|
|
1014
|
+
"enabled": True,
|
|
1015
|
+
"givenName": token_data.get("givenName", ""),
|
|
1016
|
+
"familyName": token_data.get("familyName", ""),
|
|
1017
|
+
"source": token_data.get("source", "GOOGLE")
|
|
1018
|
+
}
|
|
1019
|
+
except Exception:
|
|
1020
|
+
pass # Fall back to empty userRights if token parsing fails
|
|
1021
|
+
|
|
1022
|
+
# Build userRights with current user
|
|
1023
|
+
user_rights = []
|
|
1024
|
+
if user_info.get("id"):
|
|
1025
|
+
user_rights.append({
|
|
1026
|
+
"user": user_info,
|
|
1027
|
+
"isContributor": True
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
payload = {
|
|
1031
|
+
"name": name,
|
|
1032
|
+
"userRights": user_rights
|
|
1033
|
+
}
|
|
1034
|
+
if description:
|
|
1035
|
+
payload["description"] = description
|
|
1036
|
+
|
|
1037
|
+
try:
|
|
1038
|
+
response = self._make_authenticated_request("POST", "/api/k8s-project/", json=payload, timeout=120)
|
|
1039
|
+
response.raise_for_status()
|
|
1040
|
+
return response.json()
|
|
1041
|
+
except requests.exceptions.RequestException as e:
|
|
1042
|
+
console.print(f"[red]Error creating project:[/red] {e}")
|
|
1043
|
+
return None
|
|
1044
|
+
|
|
1045
|
+
def get_project(self, project_id: str, quiet: bool = False) -> Optional[Dict[str, Any]]:
|
|
1046
|
+
"""Get details for a specific project.
|
|
1047
|
+
|
|
1048
|
+
Args:
|
|
1049
|
+
project_id: The project ID
|
|
1050
|
+
quiet: If True, don't print errors to console
|
|
1051
|
+
"""
|
|
1052
|
+
try:
|
|
1053
|
+
response = self._make_authenticated_request("GET", f"/api/k8s-project/{project_id}")
|
|
1054
|
+
response.raise_for_status()
|
|
1055
|
+
return response.json()
|
|
1056
|
+
except requests.exceptions.RequestException as e:
|
|
1057
|
+
if not quiet:
|
|
1058
|
+
console.print(f"[red]Error getting project {project_id}:[/red] {e}")
|
|
1059
|
+
return None
|
|
1060
|
+
|
|
1061
|
+
def delete_project(self, project_id: str) -> bool:
|
|
1062
|
+
"""Delete a project."""
|
|
1063
|
+
try:
|
|
1064
|
+
response = self._make_authenticated_request("DELETE", f"/api/k8s-project/{project_id}", timeout=120)
|
|
1065
|
+
response.raise_for_status()
|
|
1066
|
+
return True
|
|
1067
|
+
except requests.exceptions.RequestException as e:
|
|
1068
|
+
console.print(f"[red]Error deleting project {project_id}:[/red] {e}")
|
|
1069
|
+
return False
|
|
1070
|
+
|
|
1071
|
+
def create_app(self, name: str, git_url: str, project_id: str = None,
|
|
1072
|
+
git_username: str = "git", git_password: str = "",
|
|
1073
|
+
branch: str = "main", config: Dict[str, Any] = None,
|
|
1074
|
+
skip_entry_check: bool = False) -> Optional[Dict[str, Any]]:
|
|
1075
|
+
"""Create/deploy a new app within a project using the wizard API.
|
|
1076
|
+
|
|
1077
|
+
Args:
|
|
1078
|
+
name: Display name for the application
|
|
1079
|
+
git_url: Git repository URL (HTTPS format preferred)
|
|
1080
|
+
project_id: Project/AppGroup ID (uses context if not provided)
|
|
1081
|
+
git_username: Git username for authentication (default: "git")
|
|
1082
|
+
git_password: Git password/token for authentication
|
|
1083
|
+
branch: Git branch to build from (default: "main")
|
|
1084
|
+
config: Additional configuration to merge into payload
|
|
1085
|
+
skip_entry_check: If True, skip the pre-flight check that
|
|
1086
|
+
refuses repos without a working web entry point (Procfile
|
|
1087
|
+
/ auto-detected file). Default False — most users want
|
|
1088
|
+
the safety net.
|
|
1089
|
+
"""
|
|
1090
|
+
# Get project ID from context if not provided
|
|
1091
|
+
if not project_id:
|
|
1092
|
+
project_id = get_config_value("context.project.id")
|
|
1093
|
+
|
|
1094
|
+
if not project_id:
|
|
1095
|
+
console.print(f"[red]No project selected. Use 'config set context.project.id <id>' first.[/red]")
|
|
1096
|
+
return None
|
|
1097
|
+
|
|
1098
|
+
# Convert SSH URL to HTTPS if needed
|
|
1099
|
+
if git_url.startswith("git@github.com:"):
|
|
1100
|
+
git_url = git_url.replace("git@github.com:", "https://github.com/")
|
|
1101
|
+
|
|
1102
|
+
# Pre-flight: catch repos that will silently fail at runtime
|
|
1103
|
+
# because Paketo has nothing to launch (no Procfile, no
|
|
1104
|
+
# auto-detected entry point). Cheap shallow-clone + inspection.
|
|
1105
|
+
if not skip_entry_check:
|
|
1106
|
+
issue = detect_entrypoint_issue(
|
|
1107
|
+
git_url,
|
|
1108
|
+
branch=branch,
|
|
1109
|
+
git_username=git_username,
|
|
1110
|
+
git_password=git_password,
|
|
1111
|
+
)
|
|
1112
|
+
if issue is not None:
|
|
1113
|
+
console.print(f"[red]Cannot create app `{name}`:[/red] {issue.summary}\n")
|
|
1114
|
+
console.print(issue.detail)
|
|
1115
|
+
console.print(
|
|
1116
|
+
"\n[dim]Pass --skip-entry-check to bypass this check "
|
|
1117
|
+
"if you know the build will start some other way.[/dim]"
|
|
1118
|
+
)
|
|
1119
|
+
return None
|
|
1120
|
+
|
|
1121
|
+
payload = {
|
|
1122
|
+
"displayName": name,
|
|
1123
|
+
"git": {
|
|
1124
|
+
"repo": git_url,
|
|
1125
|
+
"revision": branch,
|
|
1126
|
+
"accessType": "PUBLIC",
|
|
1127
|
+
"secrets": {
|
|
1128
|
+
"username": git_username,
|
|
1129
|
+
"password": git_password
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1132
|
+
"deployAutomatically": True,
|
|
1133
|
+
"service": {
|
|
1134
|
+
"appGroupId": project_id,
|
|
1135
|
+
"name": name,
|
|
1136
|
+
"displayName": name,
|
|
1137
|
+
"containerPort": "8080",
|
|
1138
|
+
"applicationSize": "SMALL",
|
|
1139
|
+
"scaleTo0": False
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
# Merge any additional config
|
|
1144
|
+
if config:
|
|
1145
|
+
# Deep merge for nested structures
|
|
1146
|
+
for key, value in config.items():
|
|
1147
|
+
if key in payload and isinstance(payload[key], dict) and isinstance(value, dict):
|
|
1148
|
+
payload[key].update(value)
|
|
1149
|
+
else:
|
|
1150
|
+
payload[key] = value
|
|
1151
|
+
|
|
1152
|
+
try:
|
|
1153
|
+
# Pass project_id as query parameter - coordinator reads it from request params
|
|
1154
|
+
endpoint = f"/api/k8s/wizard-v3?appGroupId={project_id}"
|
|
1155
|
+
response = self._make_authenticated_request("POST", endpoint, json=payload)
|
|
1156
|
+
response.raise_for_status()
|
|
1157
|
+
return response.json()
|
|
1158
|
+
except requests.exceptions.RequestException as e:
|
|
1159
|
+
# Try to get more details from the response
|
|
1160
|
+
error_detail = ""
|
|
1161
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
1162
|
+
try:
|
|
1163
|
+
error_detail = e.response.json().get('message', '')
|
|
1164
|
+
except:
|
|
1165
|
+
error_detail = e.response.text[:200] if e.response.text else ''
|
|
1166
|
+
console.print(f"[red]Error creating app:[/red] {e}")
|
|
1167
|
+
if error_detail:
|
|
1168
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
1169
|
+
return None
|
|
1170
|
+
|
|
1171
|
+
def update_app(self, app_id: str, **kwargs) -> Optional[Dict[str, Any]]:
|
|
1172
|
+
"""Update an existing application."""
|
|
1173
|
+
try:
|
|
1174
|
+
response = self._make_authenticated_request("PUT", f"/apps/{app_id}", json=kwargs)
|
|
1175
|
+
response.raise_for_status()
|
|
1176
|
+
return response.json()
|
|
1177
|
+
except requests.exceptions.RequestException as e:
|
|
1178
|
+
console.print(f"[red]Error updating app {app_id}:[/red] {e}")
|
|
1179
|
+
return None
|
|
1180
|
+
|
|
1181
|
+
# Data Management
|
|
1182
|
+
def list_datasets(self, app_id: str = None) -> List[Dict[str, Any]]:
|
|
1183
|
+
"""List datasets, optionally filtered by application."""
|
|
1184
|
+
endpoint = "/datasets"
|
|
1185
|
+
if app_id:
|
|
1186
|
+
endpoint += f"?app_id={app_id}"
|
|
1187
|
+
|
|
1188
|
+
try:
|
|
1189
|
+
response = self._make_authenticated_request("GET", endpoint)
|
|
1190
|
+
response.raise_for_status()
|
|
1191
|
+
return response.json().get("datasets", [])
|
|
1192
|
+
except requests.exceptions.RequestException as e:
|
|
1193
|
+
console.print(f"[red]Error listing datasets:[/red] {e}")
|
|
1194
|
+
return []
|
|
1195
|
+
|
|
1196
|
+
def get_dataset(self, dataset_id: str) -> Optional[Dict[str, Any]]:
|
|
1197
|
+
"""Get details for a specific dataset."""
|
|
1198
|
+
try:
|
|
1199
|
+
response = self._make_authenticated_request("GET", f"/datasets/{dataset_id}")
|
|
1200
|
+
response.raise_for_status()
|
|
1201
|
+
return response.json()
|
|
1202
|
+
except requests.exceptions.RequestException as e:
|
|
1203
|
+
console.print(f"[red]Error getting dataset {dataset_id}:[/red] {e}")
|
|
1204
|
+
return None
|
|
1205
|
+
|
|
1206
|
+
def delete_dataset(self, dataset_id: str) -> bool:
|
|
1207
|
+
"""Delete a dataset."""
|
|
1208
|
+
try:
|
|
1209
|
+
response = self._make_authenticated_request("DELETE", f"/datasets/{dataset_id}")
|
|
1210
|
+
response.raise_for_status()
|
|
1211
|
+
return True
|
|
1212
|
+
except requests.exceptions.RequestException as e:
|
|
1213
|
+
console.print(f"[red]Error deleting dataset {dataset_id}:[/red] {e}")
|
|
1214
|
+
return False
|
|
1215
|
+
|
|
1216
|
+
def upload_data(self, app_id: str, data: Any, format: str = "json") -> Optional[Dict[str, Any]]:
|
|
1217
|
+
"""Upload data to an application."""
|
|
1218
|
+
if format == "json":
|
|
1219
|
+
payload = {"data": data, "format": format}
|
|
1220
|
+
try:
|
|
1221
|
+
response = self._make_authenticated_request(
|
|
1222
|
+
"POST",
|
|
1223
|
+
f"/apps/{app_id}/data",
|
|
1224
|
+
json=payload
|
|
1225
|
+
)
|
|
1226
|
+
response.raise_for_status()
|
|
1227
|
+
return response.json()
|
|
1228
|
+
except requests.exceptions.RequestException as e:
|
|
1229
|
+
console.print(f"[red]Error uploading data:[/red] {e}")
|
|
1230
|
+
return None
|
|
1231
|
+
else:
|
|
1232
|
+
console.print(f"[red]Unsupported format:[/red] {format}")
|
|
1233
|
+
return None
|
|
1234
|
+
|
|
1235
|
+
# Deployment Management
|
|
1236
|
+
def list_deployments(self, app_id: str = None) -> List[Dict[str, Any]]:
|
|
1237
|
+
"""List deployments, optionally filtered by application."""
|
|
1238
|
+
endpoint = "/deployments"
|
|
1239
|
+
if app_id:
|
|
1240
|
+
endpoint += f"?app_id={app_id}"
|
|
1241
|
+
|
|
1242
|
+
try:
|
|
1243
|
+
response = self._make_authenticated_request("GET", endpoint)
|
|
1244
|
+
response.raise_for_status()
|
|
1245
|
+
return response.json().get("deployments", [])
|
|
1246
|
+
except requests.exceptions.RequestException as e:
|
|
1247
|
+
console.print(f"[red]Error listing deployments:[/red] {e}")
|
|
1248
|
+
return []
|
|
1249
|
+
|
|
1250
|
+
def deploy_app(self, app_id: str, environment: str = "production") -> Optional[Dict[str, Any]]:
|
|
1251
|
+
"""Deploy an application to the specified environment."""
|
|
1252
|
+
payload = {"environment": environment}
|
|
1253
|
+
|
|
1254
|
+
try:
|
|
1255
|
+
response = self._make_authenticated_request(
|
|
1256
|
+
"POST",
|
|
1257
|
+
f"/apps/{app_id}/deploy",
|
|
1258
|
+
json=payload
|
|
1259
|
+
)
|
|
1260
|
+
response.raise_for_status()
|
|
1261
|
+
return response.json()
|
|
1262
|
+
except requests.exceptions.RequestException as e:
|
|
1263
|
+
console.print(f"[red]Error deploying app {app_id}:[/red] {e}")
|
|
1264
|
+
return None
|
|
1265
|
+
|
|
1266
|
+
def get_deployment_status(self, deployment_id: str) -> Optional[Dict[str, Any]]:
|
|
1267
|
+
"""Get the status of a deployment."""
|
|
1268
|
+
try:
|
|
1269
|
+
response = self._make_authenticated_request("GET", f"/deployments/{deployment_id}")
|
|
1270
|
+
response.raise_for_status()
|
|
1271
|
+
return response.json()
|
|
1272
|
+
except requests.exceptions.RequestException as e:
|
|
1273
|
+
console.print(f"[red]Error getting deployment status:[/red] {e}")
|
|
1274
|
+
return None
|
|
1275
|
+
|
|
1276
|
+
def get_deployment_logs(self, deployment_id: str, lines: int = 100) -> List[Dict[str, Any]]:
|
|
1277
|
+
"""Get logs for a deployment."""
|
|
1278
|
+
params = {"lines": lines}
|
|
1279
|
+
|
|
1280
|
+
try:
|
|
1281
|
+
response = self._make_authenticated_request(
|
|
1282
|
+
"GET",
|
|
1283
|
+
f"/deployments/{deployment_id}/logs",
|
|
1284
|
+
params=params
|
|
1285
|
+
)
|
|
1286
|
+
response.raise_for_status()
|
|
1287
|
+
return response.json().get("logs", [])
|
|
1288
|
+
except requests.exceptions.RequestException as e:
|
|
1289
|
+
console.print(f"[red]Error getting deployment logs:[/red] {e}")
|
|
1290
|
+
return []
|
|
1291
|
+
|
|
1292
|
+
# Logging and Monitoring
|
|
1293
|
+
def get_app_logs(self, app_id: str, lines: int = 100, follow: bool = False) -> List[Dict[str, Any]]:
|
|
1294
|
+
"""Get logs for an application."""
|
|
1295
|
+
params = {"lines": lines, "follow": follow}
|
|
1296
|
+
|
|
1297
|
+
try:
|
|
1298
|
+
response = self._make_authenticated_request(
|
|
1299
|
+
"GET",
|
|
1300
|
+
f"/apps/{app_id}/logs",
|
|
1301
|
+
params=params
|
|
1302
|
+
)
|
|
1303
|
+
response.raise_for_status()
|
|
1304
|
+
return response.json().get("logs", [])
|
|
1305
|
+
except requests.exceptions.RequestException as e:
|
|
1306
|
+
console.print(f"[red]Error getting logs:[/red] {e}")
|
|
1307
|
+
return []
|
|
1308
|
+
|
|
1309
|
+
def get_app_metrics(self, app_id: str, timeframe: str = "1h") -> Optional[Dict[str, Any]]:
|
|
1310
|
+
"""Get metrics for an application."""
|
|
1311
|
+
params = {"timeframe": timeframe}
|
|
1312
|
+
|
|
1313
|
+
try:
|
|
1314
|
+
response = self._make_authenticated_request(
|
|
1315
|
+
"GET",
|
|
1316
|
+
f"/apps/{app_id}/metrics",
|
|
1317
|
+
params=params
|
|
1318
|
+
)
|
|
1319
|
+
response.raise_for_status()
|
|
1320
|
+
return response.json()
|
|
1321
|
+
except requests.exceptions.RequestException as e:
|
|
1322
|
+
console.print(f"[red]Error getting metrics:[/red] {e}")
|
|
1323
|
+
return None
|
|
1324
|
+
|
|
1325
|
+
# Image Management
|
|
1326
|
+
def list_images(self, project_id: str = None) -> List[Dict[str, Any]]:
|
|
1327
|
+
"""List container images, optionally filtered by project."""
|
|
1328
|
+
try:
|
|
1329
|
+
# Get project ID from context if not provided
|
|
1330
|
+
if not project_id:
|
|
1331
|
+
project_id = get_config_value("context.project.id")
|
|
1332
|
+
|
|
1333
|
+
endpoint = "/api/k8s-image/"
|
|
1334
|
+
if project_id:
|
|
1335
|
+
endpoint = f"/api/k8s-image/?appGroupId={project_id}"
|
|
1336
|
+
|
|
1337
|
+
response = self._make_authenticated_request("GET", endpoint)
|
|
1338
|
+
response.raise_for_status()
|
|
1339
|
+
data = response.json()
|
|
1340
|
+
if isinstance(data, list):
|
|
1341
|
+
return data
|
|
1342
|
+
return data.get("images", [])
|
|
1343
|
+
except requests.exceptions.RequestException as e:
|
|
1344
|
+
console.print(f"[red]Error listing images:[/red] {e}")
|
|
1345
|
+
return []
|
|
1346
|
+
|
|
1347
|
+
def get_image(self, image_id: str) -> Optional[Dict[str, Any]]:
|
|
1348
|
+
"""Get details for a specific image."""
|
|
1349
|
+
try:
|
|
1350
|
+
response = self._make_authenticated_request("GET", f"/images/{image_id}")
|
|
1351
|
+
response.raise_for_status()
|
|
1352
|
+
return response.json()
|
|
1353
|
+
except requests.exceptions.RequestException as e:
|
|
1354
|
+
console.print(f"[red]Error getting image {image_id}:[/red] {e}")
|
|
1355
|
+
return None
|
|
1356
|
+
|
|
1357
|
+
def delete_image(self, image_id: str) -> bool:
|
|
1358
|
+
"""Delete an image."""
|
|
1359
|
+
try:
|
|
1360
|
+
response = self._make_authenticated_request("DELETE", f"/images/{image_id}")
|
|
1361
|
+
response.raise_for_status()
|
|
1362
|
+
return True
|
|
1363
|
+
except requests.exceptions.RequestException as e:
|
|
1364
|
+
console.print(f"[red]Error deleting image {image_id}:[/red] {e}")
|
|
1365
|
+
return False
|
|
1366
|
+
|
|
1367
|
+
def rebuild_app(self, app_name: str) -> Dict[str, Any]:
|
|
1368
|
+
"""Trigger a rebuild for an application.
|
|
1369
|
+
|
|
1370
|
+
Args:
|
|
1371
|
+
app_name: The application name, image name, or partial name.
|
|
1372
|
+
Will search for matching image if exact match not found.
|
|
1373
|
+
|
|
1374
|
+
Returns:
|
|
1375
|
+
Dict with rebuild status information
|
|
1376
|
+
|
|
1377
|
+
Raises:
|
|
1378
|
+
Exception: If rebuild fails
|
|
1379
|
+
"""
|
|
1380
|
+
try:
|
|
1381
|
+
# Try to find the image name
|
|
1382
|
+
image_name = None
|
|
1383
|
+
|
|
1384
|
+
# First, try to get app directly
|
|
1385
|
+
app = self.get_app(app_name)
|
|
1386
|
+
if app:
|
|
1387
|
+
image_name = app.get('metadata', {}).get('name', app_name)
|
|
1388
|
+
else:
|
|
1389
|
+
# Search through images to find a match
|
|
1390
|
+
images = self.list_images()
|
|
1391
|
+
for img in images:
|
|
1392
|
+
img_name = img.get('metadata', {}).get('name', '')
|
|
1393
|
+
# Match if app_name is contained in image name
|
|
1394
|
+
if app_name in img_name or img_name.endswith(f"-{app_name}-image"):
|
|
1395
|
+
image_name = img_name
|
|
1396
|
+
break
|
|
1397
|
+
|
|
1398
|
+
if not image_name:
|
|
1399
|
+
# Last resort - use the name as-is, adding -image suffix if needed
|
|
1400
|
+
image_name = app_name if app_name.endswith('-image') else f"{app_name}-image"
|
|
1401
|
+
|
|
1402
|
+
# Get project ID from context
|
|
1403
|
+
project_id = get_config_value("context.project.id")
|
|
1404
|
+
params = {}
|
|
1405
|
+
if project_id:
|
|
1406
|
+
params["projectId"] = project_id
|
|
1407
|
+
|
|
1408
|
+
response = self._make_authenticated_request(
|
|
1409
|
+
"POST",
|
|
1410
|
+
f"/api/k8s-image/rebuild/{image_name}",
|
|
1411
|
+
params=params if params else None
|
|
1412
|
+
)
|
|
1413
|
+
response.raise_for_status()
|
|
1414
|
+
return response.json() if response.text else {"status": "rebuild triggered", "image": image_name}
|
|
1415
|
+
except requests.exceptions.RequestException as e:
|
|
1416
|
+
raise Exception(f"Error triggering rebuild for {app_name}: {e}")
|
|
1417
|
+
|
|
1418
|
+
def update_app_labels(self, app_name: str, labels: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
|
1419
|
+
"""Update the labels on an application (image).
|
|
1420
|
+
|
|
1421
|
+
Calls the coordinator API to update image labels.
|
|
1422
|
+
Uses PUT /api/k8s-image/{name} with the labels field.
|
|
1423
|
+
|
|
1424
|
+
Args:
|
|
1425
|
+
app_name: The name of the app/image to update
|
|
1426
|
+
labels: Dictionary of labels to set (e.g., {"appGroupId": "project-uuid"})
|
|
1427
|
+
|
|
1428
|
+
Returns:
|
|
1429
|
+
The updated app details, or None on failure
|
|
1430
|
+
"""
|
|
1431
|
+
try:
|
|
1432
|
+
# First, get the app to find its current details
|
|
1433
|
+
app = self.get_app(app_name)
|
|
1434
|
+
if not app:
|
|
1435
|
+
console.print(f"[red]App not found:[/red] {app_name}")
|
|
1436
|
+
return None
|
|
1437
|
+
|
|
1438
|
+
actual_name = app.get('metadata', {}).get('name', app_name)
|
|
1439
|
+
spec = app.get('spec', {})
|
|
1440
|
+
git_source = spec.get('source', {}).get('git', {})
|
|
1441
|
+
|
|
1442
|
+
# Build the payload matching K8sImageRecord format
|
|
1443
|
+
payload = {
|
|
1444
|
+
"tag": spec.get('tag', ''),
|
|
1445
|
+
"serviceAccountName": spec.get('serviceAccountName', ''),
|
|
1446
|
+
"builder": spec.get('builder', {}).get('name', ''),
|
|
1447
|
+
"git": {
|
|
1448
|
+
"repo": git_source.get('url', ''),
|
|
1449
|
+
"revision": git_source.get('revision', 'main')
|
|
1450
|
+
},
|
|
1451
|
+
"labels": labels
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
response = self._make_authenticated_request(
|
|
1455
|
+
"PUT",
|
|
1456
|
+
f"/api/k8s-image/{actual_name}",
|
|
1457
|
+
json=payload
|
|
1458
|
+
)
|
|
1459
|
+
response.raise_for_status()
|
|
1460
|
+
return response.json()
|
|
1461
|
+
except requests.exceptions.RequestException as e:
|
|
1462
|
+
console.print(f"[red]Error updating app labels:[/red] {e}")
|
|
1463
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
1464
|
+
try:
|
|
1465
|
+
error_detail = e.response.json().get('message', '')
|
|
1466
|
+
if error_detail:
|
|
1467
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
1468
|
+
except:
|
|
1469
|
+
pass
|
|
1470
|
+
return None
|
|
1471
|
+
|
|
1472
|
+
def link_app_to_project(self, app_name: str, project_id: str) -> Optional[Dict[str, Any]]:
|
|
1473
|
+
"""Link an app to a project by setting its appGroupId label.
|
|
1474
|
+
|
|
1475
|
+
Args:
|
|
1476
|
+
app_name: The name of the app/image to link
|
|
1477
|
+
project_id: The project ID (UUID) to link the app to
|
|
1478
|
+
|
|
1479
|
+
Returns:
|
|
1480
|
+
The updated app details, or None on failure
|
|
1481
|
+
"""
|
|
1482
|
+
return self.update_app_labels(app_name, {"appGroupId": project_id})
|
|
1483
|
+
|
|
1484
|
+
def get_instance_logs(self, app_name: str, lines: int = 100, follow: bool = False):
|
|
1485
|
+
"""Get instance logs for an application.
|
|
1486
|
+
|
|
1487
|
+
Args:
|
|
1488
|
+
app_name: The application name (k8s resource name)
|
|
1489
|
+
lines: Number of log lines to retrieve (default 100)
|
|
1490
|
+
follow: If True, stream logs continuously (yields log lines)
|
|
1491
|
+
|
|
1492
|
+
Returns:
|
|
1493
|
+
If follow=False: String containing log output
|
|
1494
|
+
If follow=True: Generator yielding log lines
|
|
1495
|
+
"""
|
|
1496
|
+
# /api/k8s-service/{name}/instance-logs expects the KSVC name
|
|
1497
|
+
# (== the app's displayName, per coord's Utils.identifyServiceName).
|
|
1498
|
+
# get_app() returns the kpack Image whose metadata.name is
|
|
1499
|
+
# "{namePrefix}-image" — passing that here 424s with "not found"
|
|
1500
|
+
# for the entire lifetime of the app, even while it serves
|
|
1501
|
+
# traffic on its URL. SpeakEZ report 2026-08-02.
|
|
1502
|
+
ksvc_name = _resolve_ksvc_name(app_name)
|
|
1503
|
+
|
|
1504
|
+
# Get project ID from context for namespace resolution
|
|
1505
|
+
project_id = get_config_value("context.project.id")
|
|
1506
|
+
params = {"tailLines": lines}
|
|
1507
|
+
if project_id:
|
|
1508
|
+
params["projectId"] = project_id
|
|
1509
|
+
|
|
1510
|
+
if follow:
|
|
1511
|
+
# For streaming logs, use a generator
|
|
1512
|
+
return self._stream_instance_logs(ksvc_name, lines, project_id)
|
|
1513
|
+
else:
|
|
1514
|
+
try:
|
|
1515
|
+
response = self._make_authenticated_request(
|
|
1516
|
+
"GET",
|
|
1517
|
+
f"/api/k8s-service/{ksvc_name}/instance-logs",
|
|
1518
|
+
params=params
|
|
1519
|
+
)
|
|
1520
|
+
response.raise_for_status()
|
|
1521
|
+
return response.text
|
|
1522
|
+
except requests.exceptions.RequestException as e:
|
|
1523
|
+
raise Exception(f"Error getting logs for {app_name}: {e}")
|
|
1524
|
+
|
|
1525
|
+
def _stream_instance_logs(self, app_name: str, lines: int = 100, project_id: str = None):
|
|
1526
|
+
"""Stream instance logs for an application.
|
|
1527
|
+
|
|
1528
|
+
Args:
|
|
1529
|
+
app_name: The application name (k8s resource name)
|
|
1530
|
+
lines: Initial number of log lines to retrieve
|
|
1531
|
+
project_id: Project ID for namespace resolution
|
|
1532
|
+
|
|
1533
|
+
Yields:
|
|
1534
|
+
Log lines as they become available
|
|
1535
|
+
"""
|
|
1536
|
+
params = {"tailLines": lines, "follow": "true"}
|
|
1537
|
+
if project_id:
|
|
1538
|
+
params["projectId"] = project_id
|
|
1539
|
+
|
|
1540
|
+
try:
|
|
1541
|
+
token = get_access_token()
|
|
1542
|
+
if not token:
|
|
1543
|
+
raise Exception("Not authenticated. Please login first.")
|
|
1544
|
+
|
|
1545
|
+
headers = {
|
|
1546
|
+
"Authorization": f"Bearer {token}",
|
|
1547
|
+
"Accept": "text/plain"
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
url = f"{self.base_url}/api/k8s-service/{app_name}/instance-logs"
|
|
1551
|
+
|
|
1552
|
+
# Use stream=True for following logs
|
|
1553
|
+
response = requests.get(
|
|
1554
|
+
url,
|
|
1555
|
+
params=params,
|
|
1556
|
+
headers=headers,
|
|
1557
|
+
stream=True,
|
|
1558
|
+
timeout=None # No timeout for streaming
|
|
1559
|
+
)
|
|
1560
|
+
response.raise_for_status()
|
|
1561
|
+
|
|
1562
|
+
# Yield lines as they come in
|
|
1563
|
+
for line in response.iter_lines(decode_unicode=True):
|
|
1564
|
+
if line:
|
|
1565
|
+
yield line
|
|
1566
|
+
|
|
1567
|
+
except requests.exceptions.RequestException as e:
|
|
1568
|
+
raise Exception(f"Error streaming logs for {app_name}: {e}")
|
|
1569
|
+
|
|
1570
|
+
def get_builds(self, image_name: str = None, project_id: str = None) -> List[Dict[str, Any]]:
|
|
1571
|
+
"""Get kpack builds, optionally filtered by image name.
|
|
1572
|
+
|
|
1573
|
+
Args:
|
|
1574
|
+
image_name: Optional image name to filter builds
|
|
1575
|
+
project_id: Optional project ID to filter builds
|
|
1576
|
+
|
|
1577
|
+
Returns:
|
|
1578
|
+
List of build objects
|
|
1579
|
+
"""
|
|
1580
|
+
try:
|
|
1581
|
+
params = {}
|
|
1582
|
+
if image_name:
|
|
1583
|
+
# Resolve to actual k8s name
|
|
1584
|
+
app = self.get_app(image_name)
|
|
1585
|
+
if app:
|
|
1586
|
+
image_name = app.get('metadata', {}).get('name', image_name)
|
|
1587
|
+
params['imageName'] = image_name
|
|
1588
|
+
|
|
1589
|
+
# Add project ID if provided or from context
|
|
1590
|
+
if project_id is None:
|
|
1591
|
+
project_id = get_config_value("context.project.id")
|
|
1592
|
+
if project_id:
|
|
1593
|
+
params['appGroupId'] = project_id
|
|
1594
|
+
|
|
1595
|
+
response = self._make_authenticated_request(
|
|
1596
|
+
"GET",
|
|
1597
|
+
"/api/k8s/builds",
|
|
1598
|
+
params=params
|
|
1599
|
+
)
|
|
1600
|
+
response.raise_for_status()
|
|
1601
|
+
return response.json()
|
|
1602
|
+
except requests.exceptions.RequestException as e:
|
|
1603
|
+
console.print(f"[red]Error getting builds:[/red] {e}")
|
|
1604
|
+
return []
|
|
1605
|
+
|
|
1606
|
+
def get_latest_commit(self, image_name: str, project_id: str = None) -> Optional[str]:
|
|
1607
|
+
"""Get the latest build's commit SHA for an image.
|
|
1608
|
+
|
|
1609
|
+
Args:
|
|
1610
|
+
image_name: The image name (k8s name)
|
|
1611
|
+
project_id: Optional project ID
|
|
1612
|
+
|
|
1613
|
+
Returns:
|
|
1614
|
+
Short commit SHA (7 chars) or None if not found
|
|
1615
|
+
"""
|
|
1616
|
+
try:
|
|
1617
|
+
builds = self.get_builds(image_name=image_name, project_id=project_id)
|
|
1618
|
+
if not builds:
|
|
1619
|
+
return None
|
|
1620
|
+
|
|
1621
|
+
# Sort by build number to get latest
|
|
1622
|
+
sorted_builds = sorted(
|
|
1623
|
+
builds,
|
|
1624
|
+
key=lambda b: int(b.get('metadata', {}).get('labels', {}).get('image.kpack.io/buildNumber', 0)),
|
|
1625
|
+
reverse=True
|
|
1626
|
+
)
|
|
1627
|
+
latest = sorted_builds[0] if sorted_builds else None
|
|
1628
|
+
if latest:
|
|
1629
|
+
revision = latest.get('spec', {}).get('source', {}).get('git', {}).get('revision', '')
|
|
1630
|
+
if revision and len(revision) >= 7:
|
|
1631
|
+
return revision[:7]
|
|
1632
|
+
return None
|
|
1633
|
+
except Exception:
|
|
1634
|
+
return None
|
|
1635
|
+
|
|
1636
|
+
def get_build_logs(self, build_name: str, namespace: str = None, lines: int = 100, follow: bool = False) -> str:
|
|
1637
|
+
"""Get build logs for a specific build.
|
|
1638
|
+
|
|
1639
|
+
Args:
|
|
1640
|
+
build_name: The build name (e.g., "1234567890-myapp-image-build-1")
|
|
1641
|
+
namespace: Optional namespace (uses project context if not provided)
|
|
1642
|
+
lines: Number of log lines (not currently supported by backend)
|
|
1643
|
+
follow: Follow log output (not currently supported by backend)
|
|
1644
|
+
|
|
1645
|
+
Returns:
|
|
1646
|
+
String containing build log output
|
|
1647
|
+
"""
|
|
1648
|
+
try:
|
|
1649
|
+
# Build the pod name from build name
|
|
1650
|
+
# Build names are like: 1234567890-myapp-image-build-1
|
|
1651
|
+
# Pod names are: {build-name}-build-pod
|
|
1652
|
+
pod_name = f"{build_name}-build-pod"
|
|
1653
|
+
|
|
1654
|
+
# Get project ID from context for namespace resolution
|
|
1655
|
+
project_id = get_config_value("context.project.id")
|
|
1656
|
+
params = {}
|
|
1657
|
+
if project_id:
|
|
1658
|
+
params["projectId"] = project_id
|
|
1659
|
+
|
|
1660
|
+
response = self._make_authenticated_request(
|
|
1661
|
+
"GET",
|
|
1662
|
+
f"/api/k8s/pod-logs/{pod_name}",
|
|
1663
|
+
params=params if params else None
|
|
1664
|
+
)
|
|
1665
|
+
response.raise_for_status()
|
|
1666
|
+
return response.text
|
|
1667
|
+
except requests.exceptions.RequestException as e:
|
|
1668
|
+
raise Exception(f"Error getting build logs for {build_name}: {e}")
|
|
1669
|
+
|
|
1670
|
+
def get_latest_build_logs(self, app_name: str) -> str:
|
|
1671
|
+
"""Get build logs for the latest build of an app.
|
|
1672
|
+
|
|
1673
|
+
Args:
|
|
1674
|
+
app_name: The application name
|
|
1675
|
+
|
|
1676
|
+
Returns:
|
|
1677
|
+
String containing build log output, or empty string if no builds
|
|
1678
|
+
"""
|
|
1679
|
+
try:
|
|
1680
|
+
# Get app details to find the build info
|
|
1681
|
+
app = self.get_app(app_name)
|
|
1682
|
+
if not app:
|
|
1683
|
+
return ""
|
|
1684
|
+
|
|
1685
|
+
actual_name = app.get('metadata', {}).get('name', app_name)
|
|
1686
|
+
status = app.get('status', {})
|
|
1687
|
+
build_counter = status.get('buildCounter', 0)
|
|
1688
|
+
|
|
1689
|
+
if build_counter == 0:
|
|
1690
|
+
return "No builds available for this application."
|
|
1691
|
+
|
|
1692
|
+
# Construct the build name (not pod name - get_build_logs adds -build-pod)
|
|
1693
|
+
# Format: {image-name}-build-{build-number}
|
|
1694
|
+
build_name = f"{actual_name}-build-{build_counter}"
|
|
1695
|
+
|
|
1696
|
+
return self.get_build_logs(build_name)
|
|
1697
|
+
except Exception as e:
|
|
1698
|
+
return f"Error getting build logs: {e}"
|
|
1699
|
+
|
|
1700
|
+
# Secrets Management
|
|
1701
|
+
def list_git_secrets(self) -> List[Dict[str, Any]]:
|
|
1702
|
+
"""List Git secrets in the current project context.
|
|
1703
|
+
|
|
1704
|
+
Returns:
|
|
1705
|
+
List of Git secrets with metadata
|
|
1706
|
+
"""
|
|
1707
|
+
try:
|
|
1708
|
+
project_id = get_config_value("context.project.id")
|
|
1709
|
+
endpoint = "/api/k8s-secrets/git"
|
|
1710
|
+
if project_id:
|
|
1711
|
+
endpoint = f"{endpoint}?appGroupId={project_id}"
|
|
1712
|
+
|
|
1713
|
+
response = self._make_authenticated_request("GET", endpoint)
|
|
1714
|
+
response.raise_for_status()
|
|
1715
|
+
data = response.json()
|
|
1716
|
+
return data if isinstance(data, list) else []
|
|
1717
|
+
except requests.exceptions.RequestException as e:
|
|
1718
|
+
console.print(f"[red]Error listing Git secrets:[/red] {e}")
|
|
1719
|
+
return []
|
|
1720
|
+
|
|
1721
|
+
def get_git_secret(self, secret_name: str) -> Optional[Dict[str, Any]]:
|
|
1722
|
+
"""Get a Git secret by name and return its credentials.
|
|
1723
|
+
|
|
1724
|
+
Args:
|
|
1725
|
+
secret_name: The name of the secret (k8s name or display name)
|
|
1726
|
+
|
|
1727
|
+
Returns:
|
|
1728
|
+
Dict with 'username' and 'password' keys, or None if not found
|
|
1729
|
+
"""
|
|
1730
|
+
try:
|
|
1731
|
+
project_id = get_config_value("context.project.id")
|
|
1732
|
+
endpoint = f"/api/k8s-secrets/{secret_name}/"
|
|
1733
|
+
if project_id:
|
|
1734
|
+
endpoint = f"{endpoint}?projectId={project_id}"
|
|
1735
|
+
|
|
1736
|
+
response = self._make_authenticated_request("GET", endpoint)
|
|
1737
|
+
response.raise_for_status()
|
|
1738
|
+
data = response.json()
|
|
1739
|
+
|
|
1740
|
+
# Extract credentials from secret data
|
|
1741
|
+
# The secret data may be base64 encoded or plain text
|
|
1742
|
+
secret_data = data.get('data', {})
|
|
1743
|
+
|
|
1744
|
+
# Try different field names for username
|
|
1745
|
+
username = (secret_data.get('username') or
|
|
1746
|
+
secret_data.get('git-username') or
|
|
1747
|
+
secret_data.get('user') or
|
|
1748
|
+
'git')
|
|
1749
|
+
|
|
1750
|
+
# Try different field names for password/token
|
|
1751
|
+
password = (secret_data.get('password') or
|
|
1752
|
+
secret_data.get('git-password') or
|
|
1753
|
+
secret_data.get('token') or
|
|
1754
|
+
secret_data.get('git-token') or
|
|
1755
|
+
'')
|
|
1756
|
+
|
|
1757
|
+
# Base64 decode if needed (check if it looks like base64)
|
|
1758
|
+
import base64
|
|
1759
|
+
try:
|
|
1760
|
+
if username and len(username) % 4 == 0 and not ' ' in username:
|
|
1761
|
+
decoded = base64.b64decode(username).decode('utf-8')
|
|
1762
|
+
if decoded.isprintable():
|
|
1763
|
+
username = decoded
|
|
1764
|
+
except Exception:
|
|
1765
|
+
pass
|
|
1766
|
+
|
|
1767
|
+
try:
|
|
1768
|
+
if password and len(password) % 4 == 0 and not ' ' in password:
|
|
1769
|
+
decoded = base64.b64decode(password).decode('utf-8')
|
|
1770
|
+
if decoded.isprintable():
|
|
1771
|
+
password = decoded
|
|
1772
|
+
except Exception:
|
|
1773
|
+
pass
|
|
1774
|
+
|
|
1775
|
+
return {'username': username, 'password': password}
|
|
1776
|
+
except requests.exceptions.RequestException as e:
|
|
1777
|
+
console.print(f"[red]Error getting secret {secret_name}:[/red] {e}")
|
|
1778
|
+
return None
|
|
1779
|
+
|
|
1780
|
+
def create_git_secret(self, display_name: str, username: str, password: str) -> Optional[Dict[str, Any]]:
|
|
1781
|
+
"""Create a new Git secret for accessing private repositories.
|
|
1782
|
+
|
|
1783
|
+
Args:
|
|
1784
|
+
display_name: Human-readable name for the secret
|
|
1785
|
+
username: Git username (e.g., GitHub username)
|
|
1786
|
+
password: Git password or Personal Access Token
|
|
1787
|
+
|
|
1788
|
+
Returns:
|
|
1789
|
+
Created secret details with name, or None on failure
|
|
1790
|
+
"""
|
|
1791
|
+
try:
|
|
1792
|
+
project_id = get_config_value("context.project.id")
|
|
1793
|
+
endpoint = "/api/k8s-secrets/git"
|
|
1794
|
+
if project_id:
|
|
1795
|
+
endpoint = f"{endpoint}?appGroupId={project_id}"
|
|
1796
|
+
|
|
1797
|
+
payload = {
|
|
1798
|
+
"displayName": display_name,
|
|
1799
|
+
"username": username,
|
|
1800
|
+
"password": password
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
response = self._make_authenticated_request("POST", endpoint, json=payload)
|
|
1804
|
+
response.raise_for_status()
|
|
1805
|
+
return response.json()
|
|
1806
|
+
except requests.exceptions.RequestException as e:
|
|
1807
|
+
error_detail = ""
|
|
1808
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
1809
|
+
try:
|
|
1810
|
+
error_detail = e.response.json().get('message', '')
|
|
1811
|
+
except:
|
|
1812
|
+
error_detail = e.response.text[:200] if e.response.text else ''
|
|
1813
|
+
console.print(f"[red]Error creating Git secret:[/red] {e}")
|
|
1814
|
+
if error_detail:
|
|
1815
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
1816
|
+
return None
|
|
1817
|
+
|
|
1818
|
+
def update_git_secret(self, name: str, username: Optional[str], password: str) -> Optional[Dict[str, Any]]:
|
|
1819
|
+
"""Rotate the credentials in an existing Git secret.
|
|
1820
|
+
|
|
1821
|
+
Patches the in-cluster K8s Secret in place, so the SA's `secrets` list
|
|
1822
|
+
and any apps already referencing the secret remain undisturbed — no
|
|
1823
|
+
re-attachment, no rebuild ordering side-effects.
|
|
1824
|
+
|
|
1825
|
+
Args:
|
|
1826
|
+
name: The K8s secret name (resolves display name → K8s name first).
|
|
1827
|
+
username: New Git username; pass None to leave unchanged.
|
|
1828
|
+
password: New password / PAT.
|
|
1829
|
+
|
|
1830
|
+
Returns:
|
|
1831
|
+
Updated secret metadata on success, None on failure.
|
|
1832
|
+
"""
|
|
1833
|
+
try:
|
|
1834
|
+
actual_name = name
|
|
1835
|
+
try:
|
|
1836
|
+
existing = self.get_git_secret(name)
|
|
1837
|
+
if existing is None:
|
|
1838
|
+
console.print(f"[red]Secret not found:[/red] {name}")
|
|
1839
|
+
return None
|
|
1840
|
+
if username is None:
|
|
1841
|
+
username = existing.get("username") or "git"
|
|
1842
|
+
except Exception:
|
|
1843
|
+
pass # fall through with the caller-provided name
|
|
1844
|
+
|
|
1845
|
+
project_id = get_config_value("context.project.id")
|
|
1846
|
+
endpoint = f"/api/k8s-secrets/git/{actual_name}"
|
|
1847
|
+
if project_id:
|
|
1848
|
+
endpoint = f"{endpoint}?appGroupId={project_id}"
|
|
1849
|
+
|
|
1850
|
+
payload = {
|
|
1851
|
+
"username": username,
|
|
1852
|
+
"password": password,
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
response = self._make_authenticated_request("PUT", endpoint, json=payload)
|
|
1856
|
+
response.raise_for_status()
|
|
1857
|
+
try:
|
|
1858
|
+
return response.json()
|
|
1859
|
+
except ValueError:
|
|
1860
|
+
return {"name": actual_name}
|
|
1861
|
+
except requests.exceptions.RequestException as e:
|
|
1862
|
+
error_detail = ""
|
|
1863
|
+
if hasattr(e, "response") and e.response is not None:
|
|
1864
|
+
try:
|
|
1865
|
+
error_detail = e.response.json().get("message", "")
|
|
1866
|
+
except Exception:
|
|
1867
|
+
error_detail = e.response.text[:200] if e.response.text else ""
|
|
1868
|
+
console.print(f"[red]Error updating Git secret:[/red] {e}")
|
|
1869
|
+
if error_detail:
|
|
1870
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
1871
|
+
return None
|
|
1872
|
+
|
|
1873
|
+
def delete_secret(self, name: str) -> bool:
|
|
1874
|
+
"""Delete a secret by name.
|
|
1875
|
+
|
|
1876
|
+
Args:
|
|
1877
|
+
name: The K8s secret name (not display name)
|
|
1878
|
+
|
|
1879
|
+
Returns:
|
|
1880
|
+
True if deleted successfully, False otherwise
|
|
1881
|
+
"""
|
|
1882
|
+
try:
|
|
1883
|
+
project_id = get_config_value("context.project.id")
|
|
1884
|
+
endpoint = f"/api/k8s-secrets/{name}/"
|
|
1885
|
+
if project_id:
|
|
1886
|
+
endpoint = f"{endpoint}?appGroupId={project_id}"
|
|
1887
|
+
|
|
1888
|
+
response = self._make_authenticated_request("DELETE", endpoint)
|
|
1889
|
+
response.raise_for_status()
|
|
1890
|
+
return True
|
|
1891
|
+
except requests.exceptions.RequestException as e:
|
|
1892
|
+
console.print(f"[red]Error deleting secret:[/red] {e}")
|
|
1893
|
+
return False
|
|
1894
|
+
|
|
1895
|
+
def list_registry_secrets(self) -> List[Dict[str, Any]]:
|
|
1896
|
+
"""List container registry secrets in the current project context.
|
|
1897
|
+
|
|
1898
|
+
Returns:
|
|
1899
|
+
List of registry secrets with metadata
|
|
1900
|
+
"""
|
|
1901
|
+
try:
|
|
1902
|
+
project_id = get_config_value("context.project.id")
|
|
1903
|
+
endpoint = "/api/k8s-secrets/registry"
|
|
1904
|
+
if project_id:
|
|
1905
|
+
endpoint = f"{endpoint}?appGroupId={project_id}"
|
|
1906
|
+
|
|
1907
|
+
response = self._make_authenticated_request("GET", endpoint)
|
|
1908
|
+
response.raise_for_status()
|
|
1909
|
+
data = response.json()
|
|
1910
|
+
return data if isinstance(data, list) else []
|
|
1911
|
+
except requests.exceptions.RequestException as e:
|
|
1912
|
+
console.print(f"[red]Error listing registry secrets:[/red] {e}")
|
|
1913
|
+
return []
|
|
1914
|
+
|
|
1915
|
+
# ==================== Domain Management ====================
|
|
1916
|
+
|
|
1917
|
+
def list_platform_domains(self) -> List[Dict[str, Any]]:
|
|
1918
|
+
"""List platform domains available to all users.
|
|
1919
|
+
|
|
1920
|
+
Returns:
|
|
1921
|
+
List of platform domains with their configuration
|
|
1922
|
+
"""
|
|
1923
|
+
try:
|
|
1924
|
+
response = self._make_authenticated_request("GET", "/api/domains/platform")
|
|
1925
|
+
response.raise_for_status()
|
|
1926
|
+
data = response.json()
|
|
1927
|
+
return data if isinstance(data, list) else []
|
|
1928
|
+
except requests.exceptions.RequestException as e:
|
|
1929
|
+
console.print(f"[red]Error listing platform domains:[/red] {e}")
|
|
1930
|
+
return []
|
|
1931
|
+
|
|
1932
|
+
def list_domains(self, project_id: str = None) -> List[Dict[str, Any]]:
|
|
1933
|
+
"""List domains for a project (includes platform domains).
|
|
1934
|
+
|
|
1935
|
+
Args:
|
|
1936
|
+
project_id: Project ID to list domains for (uses context if not specified)
|
|
1937
|
+
|
|
1938
|
+
Returns:
|
|
1939
|
+
List of domains (project + platform domains)
|
|
1940
|
+
"""
|
|
1941
|
+
try:
|
|
1942
|
+
if project_id is None:
|
|
1943
|
+
project_id = get_config_value("context.project.id")
|
|
1944
|
+
if not project_id:
|
|
1945
|
+
console.print("[yellow]No project specified. Use context or --project.[/yellow]")
|
|
1946
|
+
return self.list_platform_domains()
|
|
1947
|
+
|
|
1948
|
+
response = self._make_authenticated_request("GET", f"/api/domains/?projectId={project_id}")
|
|
1949
|
+
response.raise_for_status()
|
|
1950
|
+
data = response.json()
|
|
1951
|
+
return data if isinstance(data, list) else []
|
|
1952
|
+
except requests.exceptions.RequestException as e:
|
|
1953
|
+
console.print(f"[red]Error listing domains:[/red] {e}")
|
|
1954
|
+
return []
|
|
1955
|
+
|
|
1956
|
+
def register_domain(self, domain: str, project_id: str = None, dns_method: str = "CNAME_ONLY") -> Optional[Dict[str, Any]]:
|
|
1957
|
+
"""Register a custom domain for a project.
|
|
1958
|
+
|
|
1959
|
+
Args:
|
|
1960
|
+
domain: Domain name to register (e.g., mycompany.com)
|
|
1961
|
+
project_id: Project ID (uses context if not specified)
|
|
1962
|
+
dns_method: DNS setup method (DELEGATION, SUBDOMAIN_DELEGATION, CNAME_ONLY)
|
|
1963
|
+
|
|
1964
|
+
Returns:
|
|
1965
|
+
Dict with domain details and nameservers on success, None on failure
|
|
1966
|
+
"""
|
|
1967
|
+
try:
|
|
1968
|
+
if project_id is None:
|
|
1969
|
+
project_id = get_config_value("context.project.id")
|
|
1970
|
+
if not project_id:
|
|
1971
|
+
console.print("[red]Error:[/red] No project specified. Use context or --project.")
|
|
1972
|
+
return None
|
|
1973
|
+
|
|
1974
|
+
payload = {
|
|
1975
|
+
"domain": domain,
|
|
1976
|
+
"projectId": project_id,
|
|
1977
|
+
"dnsMethod": dns_method
|
|
1978
|
+
}
|
|
1979
|
+
response = self._make_authenticated_request("POST", "/api/domains/", json=payload)
|
|
1980
|
+
response.raise_for_status()
|
|
1981
|
+
return response.json()
|
|
1982
|
+
except requests.exceptions.RequestException as e:
|
|
1983
|
+
error_detail = ""
|
|
1984
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
1985
|
+
try:
|
|
1986
|
+
error_detail = e.response.json().get('message', '')
|
|
1987
|
+
except:
|
|
1988
|
+
error_detail = e.response.text[:200] if e.response.text else ''
|
|
1989
|
+
console.print(f"[red]Error registering domain:[/red] {e}")
|
|
1990
|
+
if error_detail:
|
|
1991
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
1992
|
+
return None
|
|
1993
|
+
|
|
1994
|
+
def check_domain_status(self, domain: str) -> Optional[Dict[str, Any]]:
|
|
1995
|
+
"""Check DNS delegation status for a domain.
|
|
1996
|
+
|
|
1997
|
+
Args:
|
|
1998
|
+
domain: Domain name to check
|
|
1999
|
+
|
|
2000
|
+
Returns:
|
|
2001
|
+
Dict with DNS status information
|
|
2002
|
+
"""
|
|
2003
|
+
try:
|
|
2004
|
+
response = self._make_authenticated_request("GET", f"/api/domains/{domain}/status")
|
|
2005
|
+
response.raise_for_status()
|
|
2006
|
+
return response.json()
|
|
2007
|
+
except requests.exceptions.RequestException as e:
|
|
2008
|
+
console.print(f"[red]Error checking domain status:[/red] {e}")
|
|
2009
|
+
return None
|
|
2010
|
+
|
|
2011
|
+
def delete_domain(self, domain_id: str) -> bool:
|
|
2012
|
+
"""Delete a custom domain.
|
|
2013
|
+
|
|
2014
|
+
Args:
|
|
2015
|
+
domain_id: Domain UUID to delete
|
|
2016
|
+
|
|
2017
|
+
Returns:
|
|
2018
|
+
True on success, False on failure
|
|
2019
|
+
"""
|
|
2020
|
+
try:
|
|
2021
|
+
response = self._make_authenticated_request("DELETE", f"/api/domains/{domain_id}")
|
|
2022
|
+
response.raise_for_status()
|
|
2023
|
+
return True
|
|
2024
|
+
except requests.exceptions.RequestException as e:
|
|
2025
|
+
console.print(f"[red]Error deleting domain:[/red] {e}")
|
|
2026
|
+
return False
|
|
2027
|
+
|
|
2028
|
+
def list_domain_assignments(self, project_id: str = None, app_name: str = None) -> List[Dict[str, Any]]:
|
|
2029
|
+
"""List domain assignments for a project, optionally filtered by app.
|
|
2030
|
+
|
|
2031
|
+
Args:
|
|
2032
|
+
project_id: Project ID (uses context if not specified)
|
|
2033
|
+
app_name: Filter by app name (optional)
|
|
2034
|
+
|
|
2035
|
+
Returns:
|
|
2036
|
+
List of domain assignments
|
|
2037
|
+
"""
|
|
2038
|
+
try:
|
|
2039
|
+
if project_id is None:
|
|
2040
|
+
project_id = get_config_value("context.project.id")
|
|
2041
|
+
if not project_id:
|
|
2042
|
+
console.print("[yellow]No project specified.[/yellow]")
|
|
2043
|
+
return []
|
|
2044
|
+
|
|
2045
|
+
url = f"/api/domains/assignments?projectId={project_id}"
|
|
2046
|
+
if app_name:
|
|
2047
|
+
url += f"&appName={app_name}"
|
|
2048
|
+
response = self._make_authenticated_request("GET", url)
|
|
2049
|
+
response.raise_for_status()
|
|
2050
|
+
data = response.json()
|
|
2051
|
+
return data if isinstance(data, list) else []
|
|
2052
|
+
except requests.exceptions.RequestException as e:
|
|
2053
|
+
console.print(f"[red]Error listing domain assignments:[/red] {e}")
|
|
2054
|
+
return []
|
|
2055
|
+
|
|
2056
|
+
def assign_domain(self, subdomain: str, domain: str, app_name: str, project_id: str = None) -> Optional[Dict[str, Any]]:
|
|
2057
|
+
"""Assign a subdomain to an app.
|
|
2058
|
+
|
|
2059
|
+
Args:
|
|
2060
|
+
subdomain: Subdomain name (e.g., "api" for api.example.com)
|
|
2061
|
+
domain: Base domain (e.g., "apps.clue2.app")
|
|
2062
|
+
app_name: Application name to assign the domain to
|
|
2063
|
+
project_id: Project ID (uses context if not specified)
|
|
2064
|
+
|
|
2065
|
+
Returns:
|
|
2066
|
+
Dict with assignment details on success, None on failure
|
|
2067
|
+
"""
|
|
2068
|
+
try:
|
|
2069
|
+
if project_id is None:
|
|
2070
|
+
project_id = get_config_value("context.project.id")
|
|
2071
|
+
if not project_id:
|
|
2072
|
+
console.print("[red]Error:[/red] No project specified. Use context or --project.")
|
|
2073
|
+
return None
|
|
2074
|
+
|
|
2075
|
+
payload = {
|
|
2076
|
+
"subdomain": subdomain,
|
|
2077
|
+
"domain": domain,
|
|
2078
|
+
"appName": app_name,
|
|
2079
|
+
"projectId": project_id
|
|
2080
|
+
}
|
|
2081
|
+
response = self._make_authenticated_request("POST", "/api/domains/assignments", json=payload)
|
|
2082
|
+
response.raise_for_status()
|
|
2083
|
+
return response.json()
|
|
2084
|
+
except requests.exceptions.RequestException as e:
|
|
2085
|
+
error_detail = ""
|
|
2086
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
2087
|
+
try:
|
|
2088
|
+
error_detail = e.response.json().get('message', '')
|
|
2089
|
+
except:
|
|
2090
|
+
error_detail = e.response.text[:200] if e.response.text else ''
|
|
2091
|
+
console.print(f"[red]Error assigning domain:[/red] {e}")
|
|
2092
|
+
if error_detail:
|
|
2093
|
+
console.print(f"[red]Details:[/red] {error_detail}")
|
|
2094
|
+
return None
|
|
2095
|
+
|
|
2096
|
+
def unassign_domain(self, assignment_id: str) -> bool:
|
|
2097
|
+
"""Remove domain assignment from an app.
|
|
2098
|
+
|
|
2099
|
+
Args:
|
|
2100
|
+
assignment_id: Assignment UUID to remove
|
|
2101
|
+
|
|
2102
|
+
Returns:
|
|
2103
|
+
True on success, False on failure
|
|
2104
|
+
"""
|
|
2105
|
+
try:
|
|
2106
|
+
response = self._make_authenticated_request("DELETE", f"/api/domains/assignments/{assignment_id}")
|
|
2107
|
+
response.raise_for_status()
|
|
2108
|
+
return True
|
|
2109
|
+
except requests.exceptions.RequestException as e:
|
|
2110
|
+
console.print(f"[red]Error unassigning domain:[/red] {e}")
|
|
2111
|
+
return False
|
|
2112
|
+
|
|
2113
|
+
# -----------------------------------------------------------------
|
|
2114
|
+
# User / member management (bugfix — SCK-460 followup)
|
|
2115
|
+
#
|
|
2116
|
+
# These methods lived only on SDKClientWrapper before, but
|
|
2117
|
+
# `get_sdk()` returns a Clue2AppSDK — so every `c2a user *` call
|
|
2118
|
+
# (including my SCK-460 `invite_member`) crashed with
|
|
2119
|
+
# "'Clue2AppSDK' object has no attribute '_get_current_account_id'".
|
|
2120
|
+
# Adding them directly to Clue2AppSDK so the main CLI path works.
|
|
2121
|
+
# -----------------------------------------------------------------
|
|
2122
|
+
|
|
2123
|
+
def _get_current_account_id(self) -> Optional[str]:
|
|
2124
|
+
"""Get current user's account ID from the JWT.
|
|
2125
|
+
|
|
2126
|
+
The slim JWT minted by auth carries `account.id` (nested), not
|
|
2127
|
+
a top-level `accountId`. Older code that read `accountId`
|
|
2128
|
+
silently returned None — hence "Could not determine account
|
|
2129
|
+
ID" when running `c2a user invite -P …`. Read both shapes so
|
|
2130
|
+
we survive any future claim rename.
|
|
2131
|
+
"""
|
|
2132
|
+
try:
|
|
2133
|
+
token = get_access_token()
|
|
2134
|
+
if not token:
|
|
2135
|
+
return None
|
|
2136
|
+
from .auth import parse_jwt_token
|
|
2137
|
+
token_data = parse_jwt_token(token) or {}
|
|
2138
|
+
# Modern shape: account = {id, name, ...}
|
|
2139
|
+
acct = token_data.get("account")
|
|
2140
|
+
if isinstance(acct, dict) and acct.get("id"):
|
|
2141
|
+
return acct["id"]
|
|
2142
|
+
# Legacy / alternate shapes some code paths might have written.
|
|
2143
|
+
return (
|
|
2144
|
+
token_data.get("accountId")
|
|
2145
|
+
or token_data.get("account_id")
|
|
2146
|
+
or None
|
|
2147
|
+
)
|
|
2148
|
+
except Exception:
|
|
2149
|
+
return None
|
|
2150
|
+
|
|
2151
|
+
def invite_member(self, account_id: str, email: str,
|
|
2152
|
+
role: str = "MEMBER",
|
|
2153
|
+
can_create_projects: bool = False,
|
|
2154
|
+
project_grants: Optional[List[Dict[str, str]]] = None,
|
|
2155
|
+
) -> Dict[str, Any]:
|
|
2156
|
+
"""Invite a member to an account with optional project grants.
|
|
2157
|
+
|
|
2158
|
+
Uses POST /api/accounts/{accountId}/members. Idempotent for
|
|
2159
|
+
existing same-account emails. Refuses cross-account moves.
|
|
2160
|
+
"""
|
|
2161
|
+
payload = {
|
|
2162
|
+
"email": email,
|
|
2163
|
+
"role": role,
|
|
2164
|
+
"canCreateProjects": can_create_projects,
|
|
2165
|
+
"projectGrants": project_grants or [],
|
|
2166
|
+
}
|
|
2167
|
+
try:
|
|
2168
|
+
response = self._make_authenticated_request(
|
|
2169
|
+
"POST",
|
|
2170
|
+
f"/api/accounts/{account_id}/members",
|
|
2171
|
+
json=payload,
|
|
2172
|
+
)
|
|
2173
|
+
if not response.ok:
|
|
2174
|
+
# Surface coord's error envelope — silently raise-for-status
|
|
2175
|
+
# was swallowing the body, which made 500s undebuggable.
|
|
2176
|
+
# Coord returns {"error_code": ..., "message": ...} on failure.
|
|
2177
|
+
body_snippet = (response.text or "")[:600]
|
|
2178
|
+
raise Exception(
|
|
2179
|
+
f"HTTP {response.status_code} POST /api/accounts/{account_id}/members\n"
|
|
2180
|
+
f" body: {body_snippet}"
|
|
2181
|
+
)
|
|
2182
|
+
return response.json() if response.text else {}
|
|
2183
|
+
except Exception as e:
|
|
2184
|
+
raise Exception(f"Error inviting member: {e}")
|
|
2185
|
+
|
|
2186
|
+
def _resolve_project(self, name_or_id: str) -> Dict[str, Any]:
|
|
2187
|
+
"""Resolve a project name OR UUID string to the FULL project record.
|
|
2188
|
+
|
|
2189
|
+
Returns the K8sProjectRecord dict which already carries accountId
|
|
2190
|
+
(per K8sProjectRecord.java) — so callers can invite into the
|
|
2191
|
+
project's OWNING account without a second round-trip. SCK-475
|
|
2192
|
+
surfaced that returning just the id led the invite path to
|
|
2193
|
+
POST into the SYSTEM caller's own account, which coord rejected.
|
|
2194
|
+
"""
|
|
2195
|
+
import uuid as _uuid
|
|
2196
|
+
projects = self.list_projects(suppress_errors=False) or []
|
|
2197
|
+
try:
|
|
2198
|
+
_uuid.UUID(name_or_id)
|
|
2199
|
+
# UUID input — find matching record so we can return accountId too.
|
|
2200
|
+
for p in projects:
|
|
2201
|
+
if str(p.get("id") or p.get("uuid")) == name_or_id:
|
|
2202
|
+
return p
|
|
2203
|
+
# UUID not in list_projects (caller may not be a member of it).
|
|
2204
|
+
# Fall back to a direct fetch.
|
|
2205
|
+
resp = self._make_authenticated_request(
|
|
2206
|
+
"GET", f"/api/k8s-project/{name_or_id}"
|
|
2207
|
+
)
|
|
2208
|
+
resp.raise_for_status()
|
|
2209
|
+
return resp.json() or {}
|
|
2210
|
+
except ValueError:
|
|
2211
|
+
pass
|
|
2212
|
+
# Name lookup path.
|
|
2213
|
+
needle = name_or_id.strip().lower()
|
|
2214
|
+
matches = [p for p in projects
|
|
2215
|
+
if str(p.get("name", "")).strip().lower() == needle]
|
|
2216
|
+
if not matches:
|
|
2217
|
+
available = ", ".join(
|
|
2218
|
+
sorted({str(p.get("name")) for p in projects if p.get("name")})
|
|
2219
|
+
) or "(none — check `c2a project list`)"
|
|
2220
|
+
raise Exception(
|
|
2221
|
+
f"No project named '{name_or_id}'. Available: {available}"
|
|
2222
|
+
)
|
|
2223
|
+
if len(matches) > 1:
|
|
2224
|
+
raise Exception(
|
|
2225
|
+
f"Multiple projects named '{name_or_id}' — pass the UUID "
|
|
2226
|
+
f"directly (e.g. `-P <project-uuid>`)."
|
|
2227
|
+
)
|
|
2228
|
+
return matches[0]
|
|
2229
|
+
|
|
2230
|
+
def _resolve_project_id(self, name_or_id: str) -> str:
|
|
2231
|
+
"""Back-compat shim — delegates to _resolve_project()."""
|
|
2232
|
+
rec = self._resolve_project(name_or_id)
|
|
2233
|
+
return rec.get("id") or rec.get("uuid")
|
|
2234
|
+
|
|
2235
|
+
def create_user(self, account_id: str, username: str, email: str,
|
|
2236
|
+
given_name: str = "", family_name: str = "",
|
|
2237
|
+
password: str = None, source: str = "LOCAL",
|
|
2238
|
+
groups: List[str] = None, enabled: bool = True
|
|
2239
|
+
) -> Dict[str, Any]:
|
|
2240
|
+
"""Create a user via the legacy auth-service endpoint. Kept for
|
|
2241
|
+
back-compat with `c2a user invite -u foo -e foo@x -p pass`.
|
|
2242
|
+
The invite-with-projects flow uses invite_member() above.
|
|
2243
|
+
"""
|
|
2244
|
+
try:
|
|
2245
|
+
payload = {
|
|
2246
|
+
"username": username,
|
|
2247
|
+
"email": email,
|
|
2248
|
+
"givenName": given_name,
|
|
2249
|
+
"familyName": family_name,
|
|
2250
|
+
"source": source,
|
|
2251
|
+
"enabled": enabled,
|
|
2252
|
+
"groups": groups or []
|
|
2253
|
+
}
|
|
2254
|
+
if password and source == "LOCAL":
|
|
2255
|
+
payload["password"] = password
|
|
2256
|
+
response = self._make_authenticated_request(
|
|
2257
|
+
"POST",
|
|
2258
|
+
f"/api/user/account/{account_id}",
|
|
2259
|
+
json=payload
|
|
2260
|
+
)
|
|
2261
|
+
return response.json() if response.text else {}
|
|
2262
|
+
except Exception as e:
|
|
2263
|
+
raise Exception(f"Error creating user: {e}")
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
class IntegrationTestRunner:
|
|
2267
|
+
"""
|
|
2268
|
+
Integration test runner based on SDK functionality.
|
|
2269
|
+
|
|
2270
|
+
This class provides comprehensive testing capabilities for the Clue2App CLI
|
|
2271
|
+
and SDK integration, simulating the integration tests you might find in
|
|
2272
|
+
the clue2app-python-sdk repository.
|
|
2273
|
+
"""
|
|
2274
|
+
|
|
2275
|
+
def __init__(self, sdk: Clue2AppSDK):
|
|
2276
|
+
self.sdk = sdk
|
|
2277
|
+
self.test_results = []
|
|
2278
|
+
|
|
2279
|
+
def run_all_tests(self) -> bool:
|
|
2280
|
+
"""Run all integration tests."""
|
|
2281
|
+
console.print("[bold blue]🧪 Running Integration Tests[/bold blue]")
|
|
2282
|
+
console.print()
|
|
2283
|
+
|
|
2284
|
+
tests = [
|
|
2285
|
+
("Authentication Test", self.test_authentication),
|
|
2286
|
+
("App Management Test", self.test_app_management),
|
|
2287
|
+
("Data Upload Test", self.test_data_operations),
|
|
2288
|
+
("Deployment Test", self.test_deployment),
|
|
2289
|
+
("Logging Test", self.test_logging),
|
|
2290
|
+
]
|
|
2291
|
+
|
|
2292
|
+
all_passed = True
|
|
2293
|
+
|
|
2294
|
+
for test_name, test_func in tests:
|
|
2295
|
+
console.print(f"🔬 Running {test_name}...")
|
|
2296
|
+
try:
|
|
2297
|
+
result = test_func()
|
|
2298
|
+
if result:
|
|
2299
|
+
console.print(f" ✅ [green]{test_name} PASSED[/green]")
|
|
2300
|
+
else:
|
|
2301
|
+
console.print(f" ❌ [red]{test_name} FAILED[/red]")
|
|
2302
|
+
all_passed = False
|
|
2303
|
+
self.test_results.append((test_name, result))
|
|
2304
|
+
except Exception as e:
|
|
2305
|
+
console.print(f" ❌ [red]{test_name} ERROR: {e}[/red]")
|
|
2306
|
+
all_passed = False
|
|
2307
|
+
self.test_results.append((test_name, False))
|
|
2308
|
+
|
|
2309
|
+
console.print()
|
|
2310
|
+
if all_passed:
|
|
2311
|
+
console.print("✅ [bold green]All tests passed![/bold green]")
|
|
2312
|
+
else:
|
|
2313
|
+
console.print("❌ [bold red]Some tests failed[/bold red]")
|
|
2314
|
+
|
|
2315
|
+
return all_passed
|
|
2316
|
+
|
|
2317
|
+
def test_authentication(self) -> bool:
|
|
2318
|
+
"""Test authentication functionality."""
|
|
2319
|
+
from .auth import is_authenticated
|
|
2320
|
+
return is_authenticated()
|
|
2321
|
+
|
|
2322
|
+
def test_app_management(self) -> bool:
|
|
2323
|
+
"""Test application management operations."""
|
|
2324
|
+
try:
|
|
2325
|
+
# Test listing apps
|
|
2326
|
+
apps = self.sdk.list_apps()
|
|
2327
|
+
return isinstance(apps, list)
|
|
2328
|
+
except Exception:
|
|
2329
|
+
return False
|
|
2330
|
+
|
|
2331
|
+
def test_data_operations(self) -> bool:
|
|
2332
|
+
"""Test data upload and management."""
|
|
2333
|
+
# Test listing datasets
|
|
2334
|
+
datasets = self.sdk.list_datasets()
|
|
2335
|
+
return isinstance(datasets, list)
|
|
2336
|
+
|
|
2337
|
+
def test_deployment(self) -> bool:
|
|
2338
|
+
"""Test deployment operations."""
|
|
2339
|
+
# Test listing deployments
|
|
2340
|
+
deployments = self.sdk.list_deployments()
|
|
2341
|
+
return isinstance(deployments, list)
|
|
2342
|
+
|
|
2343
|
+
def test_logging(self) -> bool:
|
|
2344
|
+
"""Test logging functionality."""
|
|
2345
|
+
# This would test log retrieval in a real implementation
|
|
2346
|
+
return True # Placeholder
|
|
2347
|
+
|
|
2348
|
+
|
|
2349
|
+
# Global SDK instance
|
|
2350
|
+
_sdk_instance = None
|
|
2351
|
+
|
|
2352
|
+
|
|
2353
|
+
def get_sdk() -> Clue2AppSDK:
|
|
2354
|
+
"""Get a global SDK instance."""
|
|
2355
|
+
global _sdk_instance
|
|
2356
|
+
if _sdk_instance is None:
|
|
2357
|
+
_sdk_instance = Clue2AppSDK()
|
|
2358
|
+
return _sdk_instance
|
|
2359
|
+
|
|
2360
|
+
|
|
2361
|
+
class AppsNamespace:
|
|
2362
|
+
"""Namespace class providing apps-related methods for SDK client compatibility."""
|
|
2363
|
+
|
|
2364
|
+
def __init__(self, sdk: Clue2AppSDK):
|
|
2365
|
+
self.sdk = sdk
|
|
2366
|
+
|
|
2367
|
+
def get_projects(self):
|
|
2368
|
+
"""Get all projects."""
|
|
2369
|
+
return self.sdk.list_projects()
|
|
2370
|
+
|
|
2371
|
+
def deploy_app_v3(self, payload: Dict[str, Any], project_id: str = None,
|
|
2372
|
+
skip_entry_check: bool = False) -> Dict[str, Any]:
|
|
2373
|
+
"""Deploy app using wizard-v3 API."""
|
|
2374
|
+
# Map to the SDK's create_app method which calls wizard-v3 API
|
|
2375
|
+
name = payload.get('displayName', payload.get('service', {}).get('name', 'app'))
|
|
2376
|
+
git_url = payload.get('git', {}).get('repo', '')
|
|
2377
|
+
branch = payload.get('git', {}).get('revision', 'main')
|
|
2378
|
+
git_secrets = payload.get('git', {}).get('secrets', {})
|
|
2379
|
+
git_username = git_secrets.get('username', 'git')
|
|
2380
|
+
git_password = git_secrets.get('password', '')
|
|
2381
|
+
|
|
2382
|
+
return self.sdk.create_app(
|
|
2383
|
+
name=name,
|
|
2384
|
+
git_url=git_url,
|
|
2385
|
+
project_id=project_id,
|
|
2386
|
+
git_username=git_username,
|
|
2387
|
+
git_password=git_password,
|
|
2388
|
+
branch=branch,
|
|
2389
|
+
config=payload,
|
|
2390
|
+
skip_entry_check=skip_entry_check,
|
|
2391
|
+
)
|
|
2392
|
+
|
|
2393
|
+
def get_images(self, project_id: str = None):
|
|
2394
|
+
"""Get images for a project."""
|
|
2395
|
+
return self.sdk.list_images(project_id=project_id)
|
|
2396
|
+
|
|
2397
|
+
def get_builds(self):
|
|
2398
|
+
"""Get all builds."""
|
|
2399
|
+
return self.sdk.list_deployments()
|
|
2400
|
+
|
|
2401
|
+
def get_build_logs(self, build_name: str, namespace: str = None, lines: int = 100, follow: bool = False):
|
|
2402
|
+
"""Get build logs."""
|
|
2403
|
+
return self.sdk.get_build_logs(build_name, namespace=namespace, lines=lines, follow=follow)
|
|
2404
|
+
|
|
2405
|
+
def get_logs(self, app_name: str, log_type: str = "instance", lines: int = 100):
|
|
2406
|
+
"""Get application logs."""
|
|
2407
|
+
return self.sdk.get_instance_logs(app_name, lines=lines)
|
|
2408
|
+
|
|
2409
|
+
|
|
2410
|
+
class SDKClientWrapper:
|
|
2411
|
+
"""Wrapper class providing SDK client interface compatible with clue2app package."""
|
|
2412
|
+
|
|
2413
|
+
def __init__(self, sdk: Clue2AppSDK):
|
|
2414
|
+
self._sdk = sdk
|
|
2415
|
+
self.apps = AppsNamespace(sdk)
|
|
2416
|
+
|
|
2417
|
+
def set_token(self, token: str):
|
|
2418
|
+
"""Set authentication token (no-op, token is managed by auth module)."""
|
|
2419
|
+
pass
|
|
2420
|
+
|
|
2421
|
+
# =========================================================================
|
|
2422
|
+
# User Management
|
|
2423
|
+
# =========================================================================
|
|
2424
|
+
|
|
2425
|
+
def list_users(self, account_id: str = None) -> List[Dict[str, Any]]:
|
|
2426
|
+
"""List users in an account.
|
|
2427
|
+
|
|
2428
|
+
Args:
|
|
2429
|
+
account_id: Account ID (uses current account if not specified)
|
|
2430
|
+
|
|
2431
|
+
Returns:
|
|
2432
|
+
List of user objects
|
|
2433
|
+
"""
|
|
2434
|
+
try:
|
|
2435
|
+
if not account_id:
|
|
2436
|
+
# Get current user's account
|
|
2437
|
+
account_id = self._get_current_account_id()
|
|
2438
|
+
if not account_id:
|
|
2439
|
+
raise Exception("No account ID available")
|
|
2440
|
+
|
|
2441
|
+
response = self._make_authenticated_request(
|
|
2442
|
+
"GET",
|
|
2443
|
+
f"/api/user/account/{account_id}"
|
|
2444
|
+
)
|
|
2445
|
+
return response.json() if response.text else []
|
|
2446
|
+
except Exception as e:
|
|
2447
|
+
raise Exception(f"Error listing users: {e}")
|
|
2448
|
+
|
|
2449
|
+
def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
2450
|
+
"""Get user by ID.
|
|
2451
|
+
|
|
2452
|
+
Args:
|
|
2453
|
+
user_id: User UUID
|
|
2454
|
+
|
|
2455
|
+
Returns:
|
|
2456
|
+
User object or None
|
|
2457
|
+
"""
|
|
2458
|
+
try:
|
|
2459
|
+
response = self._make_authenticated_request(
|
|
2460
|
+
"GET",
|
|
2461
|
+
f"/api/user/{user_id}"
|
|
2462
|
+
)
|
|
2463
|
+
return response.json() if response.text else None
|
|
2464
|
+
except Exception as e:
|
|
2465
|
+
raise Exception(f"Error getting user: {e}")
|
|
2466
|
+
|
|
2467
|
+
def create_user(self, account_id: str, username: str, email: str,
|
|
2468
|
+
given_name: str = "", family_name: str = "",
|
|
2469
|
+
password: str = None, source: str = "LOCAL",
|
|
2470
|
+
groups: List[str] = None, enabled: bool = True
|
|
2471
|
+
) -> Dict[str, Any]:
|
|
2472
|
+
"""Create a new user in an account.
|
|
2473
|
+
|
|
2474
|
+
Args:
|
|
2475
|
+
account_id: Account ID
|
|
2476
|
+
username: Username
|
|
2477
|
+
email: Email address
|
|
2478
|
+
given_name: First name
|
|
2479
|
+
family_name: Last name
|
|
2480
|
+
password: Password (required for LOCAL source)
|
|
2481
|
+
source: Auth source (LOCAL, GOOGLE, GITHUB)
|
|
2482
|
+
groups: List of group IDs to add user to
|
|
2483
|
+
enabled: Whether user is enabled
|
|
2484
|
+
|
|
2485
|
+
Returns:
|
|
2486
|
+
Created user object
|
|
2487
|
+
"""
|
|
2488
|
+
try:
|
|
2489
|
+
payload = {
|
|
2490
|
+
"username": username,
|
|
2491
|
+
"email": email,
|
|
2492
|
+
"givenName": given_name,
|
|
2493
|
+
"familyName": family_name,
|
|
2494
|
+
"source": source,
|
|
2495
|
+
"enabled": enabled,
|
|
2496
|
+
"groups": groups or []
|
|
2497
|
+
}
|
|
2498
|
+
if password and source == "LOCAL":
|
|
2499
|
+
payload["password"] = password
|
|
2500
|
+
|
|
2501
|
+
response = self._make_authenticated_request(
|
|
2502
|
+
"POST",
|
|
2503
|
+
f"/api/user/account/{account_id}",
|
|
2504
|
+
json=payload
|
|
2505
|
+
)
|
|
2506
|
+
return response.json() if response.text else {}
|
|
2507
|
+
except Exception as e:
|
|
2508
|
+
raise Exception(f"Error creating user: {e}")
|
|
2509
|
+
|
|
2510
|
+
def invite_member(self, account_id: str, email: str,
|
|
2511
|
+
role: str = "MEMBER",
|
|
2512
|
+
can_create_projects: bool = False,
|
|
2513
|
+
project_grants: Optional[List[Dict[str, str]]] = None,
|
|
2514
|
+
) -> Dict[str, Any]:
|
|
2515
|
+
"""Invite a member to an account, optionally with per-project grants.
|
|
2516
|
+
|
|
2517
|
+
Uses POST /api/accounts/{accountId}/members (AccountMembersController.
|
|
2518
|
+
invite). Unlike create_user() — which hits the auth service's legacy
|
|
2519
|
+
/api/user/account/{id} and can't attach project grants — this endpoint
|
|
2520
|
+
is the invite-by-email flow on coord and accepts project_grants in
|
|
2521
|
+
the SAME atomic call, closing the two-step onboarding gap.
|
|
2522
|
+
|
|
2523
|
+
Coord's own contract (from AccountMemberRecord.java):
|
|
2524
|
+
- If the email already exists in the same account, the response is
|
|
2525
|
+
a no-op (current row returned) — idempotent.
|
|
2526
|
+
- If the email exists in a DIFFERENT account, coord refuses with
|
|
2527
|
+
"Cross-account moves aren't supported automatically yet."
|
|
2528
|
+
- projectGrants is optional — omit to leave the invitee at
|
|
2529
|
+
account-level with no project access.
|
|
2530
|
+
|
|
2531
|
+
Args:
|
|
2532
|
+
account_id: Account UUID to invite into.
|
|
2533
|
+
email: Invitee email.
|
|
2534
|
+
role: "ACCOUNT_ADMIN" or "MEMBER" (default MEMBER).
|
|
2535
|
+
can_create_projects: Whether the member can create new projects.
|
|
2536
|
+
project_grants: List of {"projectId": "<uuid>", "role":
|
|
2537
|
+
"PROJECT_ADMIN"|"CONTRIBUTOR"|"VIEWER"}.
|
|
2538
|
+
|
|
2539
|
+
Returns:
|
|
2540
|
+
The member record coord returns (id, email, role, etc.).
|
|
2541
|
+
"""
|
|
2542
|
+
payload = {
|
|
2543
|
+
"email": email,
|
|
2544
|
+
"role": role,
|
|
2545
|
+
"canCreateProjects": can_create_projects,
|
|
2546
|
+
"projectGrants": project_grants or [],
|
|
2547
|
+
}
|
|
2548
|
+
try:
|
|
2549
|
+
response = self._make_authenticated_request(
|
|
2550
|
+
"POST",
|
|
2551
|
+
f"/api/accounts/{account_id}/members",
|
|
2552
|
+
json=payload,
|
|
2553
|
+
)
|
|
2554
|
+
if not response.ok:
|
|
2555
|
+
# Surface coord's error envelope — silently raise-for-status
|
|
2556
|
+
# was swallowing the body, which made 500s undebuggable.
|
|
2557
|
+
# Coord returns {"error_code": ..., "message": ...} on failure.
|
|
2558
|
+
body_snippet = (response.text or "")[:600]
|
|
2559
|
+
raise Exception(
|
|
2560
|
+
f"HTTP {response.status_code} POST /api/accounts/{account_id}/members\n"
|
|
2561
|
+
f" body: {body_snippet}"
|
|
2562
|
+
)
|
|
2563
|
+
return response.json() if response.text else {}
|
|
2564
|
+
except Exception as e:
|
|
2565
|
+
raise Exception(f"Error inviting member: {e}")
|
|
2566
|
+
|
|
2567
|
+
def _resolve_project_id(self, name_or_id: str) -> str:
|
|
2568
|
+
"""Resolve a project name OR UUID string to a canonical UUID.
|
|
2569
|
+
|
|
2570
|
+
If the input parses as a UUID, it is returned unchanged (no lookup).
|
|
2571
|
+
Otherwise, list_projects() is called and a case-insensitive name
|
|
2572
|
+
match is attempted. Raises if the name doesn't match exactly one
|
|
2573
|
+
project — ambiguous or missing.
|
|
2574
|
+
"""
|
|
2575
|
+
import uuid as _uuid
|
|
2576
|
+
try:
|
|
2577
|
+
_uuid.UUID(name_or_id)
|
|
2578
|
+
return name_or_id
|
|
2579
|
+
except ValueError:
|
|
2580
|
+
pass
|
|
2581
|
+
projects = self.list_projects(suppress_errors=False) or []
|
|
2582
|
+
needle = name_or_id.strip().lower()
|
|
2583
|
+
matches = [p for p in projects
|
|
2584
|
+
if str(p.get("name", "")).strip().lower() == needle]
|
|
2585
|
+
if not matches:
|
|
2586
|
+
available = ", ".join(
|
|
2587
|
+
sorted({str(p.get("name")) for p in projects if p.get("name")})
|
|
2588
|
+
) or "(none — check `c2a project list`)"
|
|
2589
|
+
raise Exception(
|
|
2590
|
+
f"No project named '{name_or_id}'. Available: {available}"
|
|
2591
|
+
)
|
|
2592
|
+
if len(matches) > 1:
|
|
2593
|
+
raise Exception(
|
|
2594
|
+
f"Multiple projects named '{name_or_id}' — pass the UUID "
|
|
2595
|
+
f"directly (e.g. `-P <project-uuid>`)."
|
|
2596
|
+
)
|
|
2597
|
+
return matches[0].get("id") or matches[0].get("uuid")
|
|
2598
|
+
|
|
2599
|
+
def update_user(self, user_id: str, **kwargs) -> Dict[str, Any]:
|
|
2600
|
+
"""Update a user.
|
|
2601
|
+
|
|
2602
|
+
Args:
|
|
2603
|
+
user_id: User UUID
|
|
2604
|
+
**kwargs: Fields to update (givenName, familyName, enabled, groups)
|
|
2605
|
+
|
|
2606
|
+
Returns:
|
|
2607
|
+
Updated user object
|
|
2608
|
+
"""
|
|
2609
|
+
try:
|
|
2610
|
+
# Map snake_case to camelCase
|
|
2611
|
+
payload = {}
|
|
2612
|
+
field_map = {
|
|
2613
|
+
'given_name': 'givenName',
|
|
2614
|
+
'family_name': 'familyName',
|
|
2615
|
+
'enabled': 'enabled',
|
|
2616
|
+
'groups': 'groups'
|
|
2617
|
+
}
|
|
2618
|
+
for key, value in kwargs.items():
|
|
2619
|
+
if key in field_map:
|
|
2620
|
+
payload[field_map[key]] = value
|
|
2621
|
+
else:
|
|
2622
|
+
payload[key] = value
|
|
2623
|
+
|
|
2624
|
+
response = self._make_authenticated_request(
|
|
2625
|
+
"PUT",
|
|
2626
|
+
f"/api/user/{user_id}",
|
|
2627
|
+
json=payload
|
|
2628
|
+
)
|
|
2629
|
+
return response.json() if response.text else {}
|
|
2630
|
+
except Exception as e:
|
|
2631
|
+
raise Exception(f"Error updating user: {e}")
|
|
2632
|
+
|
|
2633
|
+
def delete_user(self, user_id: str) -> bool:
|
|
2634
|
+
"""Delete a user.
|
|
2635
|
+
|
|
2636
|
+
Args:
|
|
2637
|
+
user_id: User UUID
|
|
2638
|
+
|
|
2639
|
+
Returns:
|
|
2640
|
+
True if deleted successfully
|
|
2641
|
+
"""
|
|
2642
|
+
try:
|
|
2643
|
+
self._make_authenticated_request(
|
|
2644
|
+
"DELETE",
|
|
2645
|
+
f"/api/user/{user_id}"
|
|
2646
|
+
)
|
|
2647
|
+
return True
|
|
2648
|
+
except Exception as e:
|
|
2649
|
+
raise Exception(f"Error deleting user: {e}")
|
|
2650
|
+
|
|
2651
|
+
# =========================================================================
|
|
2652
|
+
# Group Management
|
|
2653
|
+
# =========================================================================
|
|
2654
|
+
|
|
2655
|
+
def list_groups(self, account_id: str = None) -> List[Dict[str, Any]]:
|
|
2656
|
+
"""List groups in an account.
|
|
2657
|
+
|
|
2658
|
+
Args:
|
|
2659
|
+
account_id: Account ID (uses current account if not specified)
|
|
2660
|
+
|
|
2661
|
+
Returns:
|
|
2662
|
+
List of group objects
|
|
2663
|
+
"""
|
|
2664
|
+
try:
|
|
2665
|
+
if not account_id:
|
|
2666
|
+
account_id = self._get_current_account_id()
|
|
2667
|
+
if not account_id:
|
|
2668
|
+
raise Exception("No account ID available")
|
|
2669
|
+
|
|
2670
|
+
response = self._make_authenticated_request(
|
|
2671
|
+
"GET",
|
|
2672
|
+
f"/api/group/account/{account_id}"
|
|
2673
|
+
)
|
|
2674
|
+
return response.json() if response.text else []
|
|
2675
|
+
except Exception as e:
|
|
2676
|
+
raise Exception(f"Error listing groups: {e}")
|
|
2677
|
+
|
|
2678
|
+
def create_group(self, name: str, role_id: str, account_id: str = None,
|
|
2679
|
+
enabled: bool = True) -> Dict[str, Any]:
|
|
2680
|
+
"""Create a new group.
|
|
2681
|
+
|
|
2682
|
+
Args:
|
|
2683
|
+
name: Group name
|
|
2684
|
+
role_id: Role ID to assign to group
|
|
2685
|
+
account_id: Account ID (uses current account if not specified)
|
|
2686
|
+
enabled: Whether group is enabled
|
|
2687
|
+
|
|
2688
|
+
Returns:
|
|
2689
|
+
Created group object
|
|
2690
|
+
"""
|
|
2691
|
+
try:
|
|
2692
|
+
if not account_id:
|
|
2693
|
+
account_id = self._get_current_account_id()
|
|
2694
|
+
if not account_id:
|
|
2695
|
+
raise Exception("No account ID available")
|
|
2696
|
+
|
|
2697
|
+
payload = {
|
|
2698
|
+
"name": name,
|
|
2699
|
+
"accountId": account_id,
|
|
2700
|
+
"userRole": {"id": role_id},
|
|
2701
|
+
"enabled": enabled
|
|
2702
|
+
}
|
|
2703
|
+
response = self._make_authenticated_request(
|
|
2704
|
+
"POST",
|
|
2705
|
+
"/api/group/",
|
|
2706
|
+
json=payload
|
|
2707
|
+
)
|
|
2708
|
+
return response.json() if response.text else {}
|
|
2709
|
+
except Exception as e:
|
|
2710
|
+
raise Exception(f"Error creating group: {e}")
|
|
2711
|
+
|
|
2712
|
+
def update_group(self, group_id: str, **kwargs) -> Dict[str, Any]:
|
|
2713
|
+
"""Update a group.
|
|
2714
|
+
|
|
2715
|
+
Args:
|
|
2716
|
+
group_id: Group UUID
|
|
2717
|
+
**kwargs: Fields to update (name, roleId, enabled)
|
|
2718
|
+
|
|
2719
|
+
Returns:
|
|
2720
|
+
Updated group object
|
|
2721
|
+
"""
|
|
2722
|
+
try:
|
|
2723
|
+
payload = {}
|
|
2724
|
+
if 'name' in kwargs:
|
|
2725
|
+
payload['name'] = kwargs['name']
|
|
2726
|
+
if 'role_id' in kwargs:
|
|
2727
|
+
payload['userRole'] = {'id': kwargs['role_id']}
|
|
2728
|
+
if 'enabled' in kwargs:
|
|
2729
|
+
payload['enabled'] = kwargs['enabled']
|
|
2730
|
+
|
|
2731
|
+
response = self._make_authenticated_request(
|
|
2732
|
+
"PUT",
|
|
2733
|
+
f"/api/group/{group_id}",
|
|
2734
|
+
json=payload
|
|
2735
|
+
)
|
|
2736
|
+
return response.json() if response.text else {}
|
|
2737
|
+
except Exception as e:
|
|
2738
|
+
raise Exception(f"Error updating group: {e}")
|
|
2739
|
+
|
|
2740
|
+
def delete_group(self, group_id: str) -> bool:
|
|
2741
|
+
"""Delete a group.
|
|
2742
|
+
|
|
2743
|
+
Args:
|
|
2744
|
+
group_id: Group UUID
|
|
2745
|
+
|
|
2746
|
+
Returns:
|
|
2747
|
+
True if deleted successfully
|
|
2748
|
+
"""
|
|
2749
|
+
try:
|
|
2750
|
+
self._make_authenticated_request(
|
|
2751
|
+
"DELETE",
|
|
2752
|
+
f"/api/group/{group_id}"
|
|
2753
|
+
)
|
|
2754
|
+
return True
|
|
2755
|
+
except Exception as e:
|
|
2756
|
+
raise Exception(f"Error deleting group: {e}")
|
|
2757
|
+
|
|
2758
|
+
# =========================================================================
|
|
2759
|
+
# Role Management
|
|
2760
|
+
# =========================================================================
|
|
2761
|
+
|
|
2762
|
+
def list_roles(self) -> List[Dict[str, Any]]:
|
|
2763
|
+
"""List all available roles.
|
|
2764
|
+
|
|
2765
|
+
Returns:
|
|
2766
|
+
List of role objects
|
|
2767
|
+
"""
|
|
2768
|
+
try:
|
|
2769
|
+
response = self._make_authenticated_request(
|
|
2770
|
+
"GET",
|
|
2771
|
+
"/api/role/"
|
|
2772
|
+
)
|
|
2773
|
+
return response.json() if response.text else []
|
|
2774
|
+
except Exception as e:
|
|
2775
|
+
raise Exception(f"Error listing roles: {e}")
|
|
2776
|
+
|
|
2777
|
+
def _get_current_account_id(self) -> Optional[str]:
|
|
2778
|
+
"""Get current user's account ID from token or context."""
|
|
2779
|
+
try:
|
|
2780
|
+
token = get_access_token()
|
|
2781
|
+
if token:
|
|
2782
|
+
from .auth import parse_jwt_token
|
|
2783
|
+
token_data = parse_jwt_token(token)
|
|
2784
|
+
return token_data.get("accountId")
|
|
2785
|
+
except:
|
|
2786
|
+
pass
|
|
2787
|
+
return None
|
|
2788
|
+
|
|
2789
|
+
|
|
2790
|
+
def _get_sdk_client() -> SDKClientWrapper:
|
|
2791
|
+
"""Get an SDK client wrapper compatible with clue2app package interface.
|
|
2792
|
+
|
|
2793
|
+
Returns a wrapper that provides .apps namespace with methods like:
|
|
2794
|
+
- get_projects()
|
|
2795
|
+
- deploy_app_v3()
|
|
2796
|
+
- get_images()
|
|
2797
|
+
- get_builds()
|
|
2798
|
+
- get_build_logs()
|
|
2799
|
+
- get_logs()
|
|
2800
|
+
"""
|
|
2801
|
+
sdk = get_sdk()
|
|
2802
|
+
return SDKClientWrapper(sdk)
|
|
2803
|
+
|
|
2804
|
+
|
|
2805
|
+
# =============================================================================
|
|
2806
|
+
# Repository Analysis (Pre-flight checks)
|
|
2807
|
+
# =============================================================================
|
|
2808
|
+
|
|
2809
|
+
def analyze_repo(path: str) -> Dict[str, Any]:
|
|
2810
|
+
"""Analyze a repository structure before deployment.
|
|
2811
|
+
|
|
2812
|
+
Scans for common patterns and issues that might cause deployment problems.
|
|
2813
|
+
|
|
2814
|
+
Args:
|
|
2815
|
+
path: Path to the repository root
|
|
2816
|
+
|
|
2817
|
+
Returns:
|
|
2818
|
+
Dict with 'issues', 'warnings', 'suggestions', and 'detected' keys
|
|
2819
|
+
"""
|
|
2820
|
+
import os
|
|
2821
|
+
|
|
2822
|
+
issues = [] # Critical - will likely fail
|
|
2823
|
+
warnings = [] # May cause problems
|
|
2824
|
+
suggestions = [] # Helpful tips
|
|
2825
|
+
detected = {} # What we detected about the app
|
|
2826
|
+
|
|
2827
|
+
def exists(filename):
|
|
2828
|
+
return os.path.exists(os.path.join(path, filename))
|
|
2829
|
+
|
|
2830
|
+
def read_file(filename):
|
|
2831
|
+
try:
|
|
2832
|
+
with open(os.path.join(path, filename), 'r') as f:
|
|
2833
|
+
return f.read()
|
|
2834
|
+
except:
|
|
2835
|
+
return None
|
|
2836
|
+
|
|
2837
|
+
def load_json(filename):
|
|
2838
|
+
try:
|
|
2839
|
+
with open(os.path.join(path, filename), 'r') as f:
|
|
2840
|
+
return json.load(f)
|
|
2841
|
+
except:
|
|
2842
|
+
return None
|
|
2843
|
+
|
|
2844
|
+
# === Detect app type ===
|
|
2845
|
+
app_type = None
|
|
2846
|
+
framework = None
|
|
2847
|
+
|
|
2848
|
+
# --- Node.js / JavaScript ---
|
|
2849
|
+
if exists('package.json'):
|
|
2850
|
+
app_type = 'nodejs'
|
|
2851
|
+
pkg = load_json('package.json')
|
|
2852
|
+
|
|
2853
|
+
if pkg:
|
|
2854
|
+
scripts = pkg.get('scripts', {})
|
|
2855
|
+
deps = {**pkg.get('dependencies', {}), **pkg.get('devDependencies', {})}
|
|
2856
|
+
detected['package_name'] = pkg.get('name', 'unknown')
|
|
2857
|
+
detected['node_scripts'] = list(scripts.keys())
|
|
2858
|
+
|
|
2859
|
+
# Check for start command
|
|
2860
|
+
has_start = 'start' in scripts
|
|
2861
|
+
has_procfile = exists('Procfile')
|
|
2862
|
+
has_server_file = any(exists(f) for f in ['server.js', 'serve.js', 'app.js', 'index.js'])
|
|
2863
|
+
|
|
2864
|
+
if has_start:
|
|
2865
|
+
detected['start_command'] = scripts['start']
|
|
2866
|
+
elif has_procfile:
|
|
2867
|
+
detected['start_source'] = 'Procfile'
|
|
2868
|
+
elif has_server_file:
|
|
2869
|
+
warnings.append("No 'start' script but server file found - buildpack may detect it")
|
|
2870
|
+
else:
|
|
2871
|
+
issues.append("No 'start' script and no Procfile - app won't know how to run")
|
|
2872
|
+
|
|
2873
|
+
# Detect framework
|
|
2874
|
+
if 'vite' in deps:
|
|
2875
|
+
framework = 'vite'
|
|
2876
|
+
detected['framework'] = 'Vite'
|
|
2877
|
+
if 'react' in deps:
|
|
2878
|
+
detected['framework'] = 'Vite + React'
|
|
2879
|
+
|
|
2880
|
+
# Vite apps need a static file server for production
|
|
2881
|
+
# NOTE: node_modules is pruned at runtime by the buildpack,
|
|
2882
|
+
# so packages like 'serve' won't be available. Use a server.js
|
|
2883
|
+
# with only Node.js built-ins (http, fs, path).
|
|
2884
|
+
has_server = any(exists(f) for f in ['serve.js', 'server.js'])
|
|
2885
|
+
|
|
2886
|
+
if 'start' not in scripts and not has_server:
|
|
2887
|
+
issues.append("Vite/React app needs a server.js to serve the built files")
|
|
2888
|
+
suggestions.append('Create a server.js using Node.js built-in http/fs modules to serve dist/')
|
|
2889
|
+
suggestions.append('Do NOT use the "serve" npm package — node_modules is pruned at runtime by the buildpack')
|
|
2890
|
+
warnings.append("Don't use 'vite preview' in production - it's for local testing only")
|
|
2891
|
+
elif 'vite preview' in scripts.get('start', ''):
|
|
2892
|
+
warnings.append("'vite preview' is NOT for production - use a proper static file server")
|
|
2893
|
+
suggestions.append('Create a server.js using Node.js built-in http/fs modules to serve dist/')
|
|
2894
|
+
|
|
2895
|
+
# Check for dist folder (built output)
|
|
2896
|
+
if exists('dist'):
|
|
2897
|
+
detected['has_dist'] = True
|
|
2898
|
+
detected['build_output'] = 'dist/'
|
|
2899
|
+
|
|
2900
|
+
if 'next' in deps:
|
|
2901
|
+
framework = 'nextjs'
|
|
2902
|
+
detected['framework'] = 'Next.js'
|
|
2903
|
+
# Next.js usually works out of box
|
|
2904
|
+
|
|
2905
|
+
if 'express' in deps:
|
|
2906
|
+
detected['has_express'] = True
|
|
2907
|
+
if not any(exists(f) for f in ['server.js', 'app.js', 'index.js']):
|
|
2908
|
+
warnings.append("Has express but no server.js/app.js/index.js found")
|
|
2909
|
+
|
|
2910
|
+
if 'fastify' in deps:
|
|
2911
|
+
detected['has_fastify'] = True
|
|
2912
|
+
|
|
2913
|
+
# --- Python ---
|
|
2914
|
+
if exists('requirements.txt') or exists('pyproject.toml') or exists('setup.py'):
|
|
2915
|
+
app_type = 'python'
|
|
2916
|
+
detected['python_app'] = True
|
|
2917
|
+
|
|
2918
|
+
reqs_content = read_file('requirements.txt') or ''
|
|
2919
|
+
|
|
2920
|
+
if 'fastapi' in reqs_content.lower():
|
|
2921
|
+
framework = 'fastapi'
|
|
2922
|
+
detected['framework'] = 'FastAPI'
|
|
2923
|
+
if not exists('Procfile'):
|
|
2924
|
+
suggestions.append('Add Procfile: web: uvicorn main:app --host 0.0.0.0 --port 8080')
|
|
2925
|
+
|
|
2926
|
+
if 'flask' in reqs_content.lower():
|
|
2927
|
+
framework = 'flask'
|
|
2928
|
+
detected['framework'] = 'Flask'
|
|
2929
|
+
if not exists('Procfile'):
|
|
2930
|
+
suggestions.append('Add Procfile: web: gunicorn app:app --bind 0.0.0.0:8080')
|
|
2931
|
+
|
|
2932
|
+
if 'django' in reqs_content.lower():
|
|
2933
|
+
framework = 'django'
|
|
2934
|
+
detected['framework'] = 'Django'
|
|
2935
|
+
|
|
2936
|
+
if not any(exists(f) for f in ['main.py', 'app.py', 'wsgi.py', 'asgi.py']):
|
|
2937
|
+
warnings.append("Python app - no main.py, app.py, or wsgi.py found")
|
|
2938
|
+
|
|
2939
|
+
# --- Go ---
|
|
2940
|
+
if exists('go.mod'):
|
|
2941
|
+
app_type = 'go'
|
|
2942
|
+
detected['go_app'] = True
|
|
2943
|
+
|
|
2944
|
+
if not exists('main.go') and not exists('cmd/'):
|
|
2945
|
+
warnings.append("Go app - no main.go or cmd/ directory found")
|
|
2946
|
+
|
|
2947
|
+
# --- Java ---
|
|
2948
|
+
if exists('pom.xml') or exists('build.gradle'):
|
|
2949
|
+
app_type = 'java'
|
|
2950
|
+
detected['java_app'] = True
|
|
2951
|
+
|
|
2952
|
+
# === General Checks ===
|
|
2953
|
+
|
|
2954
|
+
# Procfile
|
|
2955
|
+
if not exists('Procfile'):
|
|
2956
|
+
if app_type in ['python', 'nodejs']:
|
|
2957
|
+
warnings.append("No Procfile - buildpack will guess start command")
|
|
2958
|
+
else:
|
|
2959
|
+
detected['has_procfile'] = True
|
|
2960
|
+
procfile_content = read_file('Procfile')
|
|
2961
|
+
if procfile_content:
|
|
2962
|
+
detected['procfile_content'] = procfile_content.strip()
|
|
2963
|
+
|
|
2964
|
+
# Dockerfile
|
|
2965
|
+
if exists('Dockerfile'):
|
|
2966
|
+
detected['has_dockerfile'] = True
|
|
2967
|
+
suggestions.append("Dockerfile found - consider using Docker buildpack for more control")
|
|
2968
|
+
|
|
2969
|
+
# Server entry points
|
|
2970
|
+
server_files = ['server.js', 'serve.js', 'app.js', 'index.js', 'main.py', 'app.py', 'main.go']
|
|
2971
|
+
found_servers = [f for f in server_files if exists(f)]
|
|
2972
|
+
if found_servers:
|
|
2973
|
+
detected['server_files'] = found_servers
|
|
2974
|
+
elif app_type and app_type not in ['java']:
|
|
2975
|
+
if framework not in ['nextjs']: # Next.js doesn't need explicit server
|
|
2976
|
+
warnings.append(f"No server entry point found ({', '.join(server_files[:3])}...)")
|
|
2977
|
+
|
|
2978
|
+
# Port configuration
|
|
2979
|
+
if exists('.env'):
|
|
2980
|
+
env_content = read_file('.env') or ''
|
|
2981
|
+
if 'PORT' in env_content:
|
|
2982
|
+
detected['has_port_config'] = True
|
|
2983
|
+
|
|
2984
|
+
detected['app_type'] = app_type
|
|
2985
|
+
detected['framework'] = framework
|
|
2986
|
+
|
|
2987
|
+
return {
|
|
2988
|
+
'issues': issues,
|
|
2989
|
+
'warnings': warnings,
|
|
2990
|
+
'suggestions': suggestions,
|
|
2991
|
+
'detected': detected,
|
|
2992
|
+
'ready': len(issues) == 0
|
|
2993
|
+
}
|