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/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
+ }