Skip to content

Commit 60d6dc7

Browse files
authored
Merge pull request #1060 from DataRecce/feature/drc-2592-cli-add-recce-doctor-command-for-environment-diagnostics
feat(cli): Add recce-cloud doctor command for environment diagnostics (DRC-2592)
2 parents 07a1399 + 39a2daa commit 60d6dc7

File tree

9 files changed

+1324
-21
lines changed

9 files changed

+1324
-21
lines changed

recce_cloud/recce_cloud/auth/login.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,10 +251,14 @@ def check_login_status() -> Tuple[bool, Optional[str]]:
251251
"""
252252
Check current login status.
253253
254+
Checks for authentication token in this order:
255+
1. RECCE_API_TOKEN environment variable
256+
2. Stored token from profile (via get_api_token)
257+
254258
Returns:
255259
Tuple of (is_logged_in, user_email_or_none).
256260
"""
257-
token = get_api_token()
261+
token = os.getenv("RECCE_API_TOKEN") or get_api_token()
258262
if not token:
259263
return False, None
260264

recce_cloud/recce_cloud/cli.py

Lines changed: 128 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import subprocess
99
import sys
10+
from typing import Optional
1011

1112
import click
1213
from rich.console import Console
@@ -15,6 +16,7 @@
1516
from recce_cloud import __version__
1617
from recce_cloud.artifact import get_adapter_type, verify_artifacts_path
1718
from recce_cloud.ci_providers import CIDetector
19+
from recce_cloud.commands.diagnostics import doctor
1820
from recce_cloud.delete import (
1921
delete_existing_session,
2022
delete_with_platform_apis,
@@ -49,6 +51,10 @@ def cloud_cli():
4951
pass
5052

5153

54+
# Register commands from command modules
55+
cloud_cli.add_command(doctor)
56+
57+
5258
@cloud_cli.command()
5359
def version():
5460
"""Show the version of recce-cloud."""
@@ -359,10 +365,89 @@ def init(org, project, status, clear):
359365
console.print(f"[red]Error:[/red] Failed to fetch data from Recce Cloud: {e}")
360366
sys.exit(1)
361367
except Exception as e:
362-
console.print(f"[red]Error:[/red] {e}")
368+
logger.debug("Unexpected error during init: %s", e, exc_info=True)
369+
console.print(f"[red]Error:[/red] An unexpected error occurred: {e}")
370+
console.print(" Try running 'recce-cloud login' again or check your network connection.")
363371
sys.exit(1)
364372

365373

374+
def _get_production_session_id(console: Console, token: str) -> Optional[str]:
375+
"""
376+
Fetch the production session ID from Recce Cloud.
377+
378+
Returns the session ID if found, None otherwise (with error message printed).
379+
"""
380+
from recce_cloud.api.client import RecceCloudClient
381+
from recce_cloud.api.exceptions import RecceCloudException
382+
from recce_cloud.config.project_config import get_project_binding
383+
384+
# Get project binding
385+
binding = get_project_binding()
386+
if not binding:
387+
# Check environment variables as fallback
388+
env_org = os.environ.get("RECCE_ORG")
389+
env_project = os.environ.get("RECCE_PROJECT")
390+
if env_org and env_project:
391+
binding = {"org": env_org, "project": env_project}
392+
else:
393+
console.print("[red]Error:[/red] No project binding found")
394+
console.print("Run 'recce-cloud init' to bind this directory to a project")
395+
return None
396+
397+
org_slug = binding.get("org")
398+
project_slug = binding.get("project")
399+
400+
try:
401+
client = RecceCloudClient(token)
402+
403+
# Get org and project IDs
404+
org_info = client.get_organization(org_slug)
405+
if not org_info:
406+
console.print(f"[red]Error:[/red] Organization '{org_slug}' not found")
407+
return None
408+
org_id = org_info.get("id")
409+
if not org_id:
410+
console.print(f"[red]Error:[/red] Organization '{org_slug}' response missing ID")
411+
return None
412+
413+
project_info = client.get_project(org_id, project_slug)
414+
if not project_info:
415+
console.print(f"[red]Error:[/red] Project '{project_slug}' not found")
416+
return None
417+
project_id = project_info.get("id")
418+
if not project_id:
419+
console.print(f"[red]Error:[/red] Project '{project_slug}' response missing ID")
420+
return None
421+
422+
# List sessions and find production session
423+
sessions = client.list_sessions(org_id, project_id)
424+
for session in sessions:
425+
if session.get("is_base"):
426+
session_id = session.get("id")
427+
if not session_id:
428+
console.print("[red]Error:[/red] Production session found but has no ID")
429+
return None
430+
session_name = session.get("name") or "(unnamed)"
431+
session_id_display = session_id[:8] if len(session_id) >= 8 else session_id
432+
console.print(
433+
f"[cyan]Info:[/cyan] Found production session '{session_name}' (ID: {session_id_display}...)"
434+
)
435+
return session_id
436+
437+
console.print("[red]Error:[/red] No production session found")
438+
console.print("Create a production session first using 'recce-cloud upload --type prod' or via CI pipeline")
439+
return None
440+
441+
except RecceCloudException as e:
442+
console.print(f"[red]Error:[/red] Failed to fetch sessions: {e}")
443+
return None
444+
except Exception as e:
445+
logger.debug("Unexpected error in _get_production_session_id: %s", e, exc_info=True)
446+
console.print(f"[red]Error:[/red] Unexpected error: {e}")
447+
console.print(" Check your network connection and try again.")
448+
return None
449+
450+
366451
@cloud_cli.command()
367452
@click.option(
368453
"--target-path",
@@ -593,22 +678,41 @@ def upload(target_path, session_id, session_name, skip_confirmation, cr, session
593678
skip_confirmation=skip_confirmation,
594679
)
595680
else:
596-
# Platform-specific workflow: Use platform APIs to create session and upload
681+
# GitHub Action or GitLab CI/CD workflow: Use platform APIs to create session and upload
597682
# This workflow MUST use CI job tokens (CI_JOB_TOKEN or GITHUB_TOKEN)
598683
if not ci_info or not ci_info.access_token:
599-
console.print("[red]Error:[/red] Platform-specific upload requires CI environment")
600-
console.print(
601-
"Either run in GitHub Actions/GitLab CI or provide --session-id/--session-name for generic upload"
602-
)
603-
sys.exit(2)
604-
605-
token = ci_info.access_token
606-
if ci_info.platform == "github-actions":
607-
console.print("[cyan]Info:[/cyan] Using GITHUB_TOKEN for platform-specific authentication")
608-
elif ci_info.platform == "gitlab-ci":
609-
console.print("[cyan]Info:[/cyan] Using CI_JOB_TOKEN for platform-specific authentication")
684+
# If --type prod is specified outside CI, fetch the production session and upload to it
685+
if session_type == "prod":
686+
from recce_cloud.auth.profile import get_api_token
687+
688+
token = os.getenv("RECCE_API_TOKEN") or get_api_token()
689+
if not token:
690+
console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
691+
console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
692+
sys.exit(2)
693+
694+
# Fetch the production session ID
695+
prod_session_id = _get_production_session_id(console, token)
696+
if not prod_session_id:
697+
sys.exit(2)
698+
699+
upload_to_existing_session(
700+
console, token, prod_session_id, manifest_path, catalog_path, adapter_type, target_path
701+
)
702+
else:
703+
console.print("[red]Error:[/red] Platform-specific upload requires CI environment")
704+
console.print(
705+
"Either run in GitHub Actions/GitLab CI or provide --session-id/--session-name for generic upload"
706+
)
707+
sys.exit(2)
708+
else:
709+
token = ci_info.access_token
710+
if ci_info.platform == "github-actions":
711+
console.print("[cyan]Info:[/cyan] Using GITHUB_TOKEN for platform-specific authentication")
712+
elif ci_info.platform == "gitlab-ci":
713+
console.print("[cyan]Info:[/cyan] Using CI_JOB_TOKEN for platform-specific authentication")
610714

611-
upload_with_platform_apis(console, token, ci_info, manifest_path, catalog_path, adapter_type, target_path)
715+
upload_with_platform_apis(console, token, ci_info, manifest_path, catalog_path, adapter_type, target_path)
612716

613717

614718
@cloud_cli.command(name="list")
@@ -682,16 +786,24 @@ def list_sessions_cmd(session_type, output_json):
682786
if not org_info:
683787
console.print(f"[red]Error:[/red] Organization '{org}' not found or you don't have access")
684788
sys.exit(2)
685-
org_id = org_info["id"]
789+
org_id = org_info.get("id")
790+
if not org_id:
791+
console.print(f"[red]Error:[/red] Organization '{org}' response missing ID")
792+
sys.exit(2)
686793

687794
project_info = client.get_project(org_id, project)
688795
if not project_info:
689796
console.print(f"[red]Error:[/red] Project '{project}' not found in organization '{org}'")
690797
sys.exit(2)
691-
project_id = project_info["id"]
798+
project_id = project_info.get("id")
799+
if not project_id:
800+
console.print(f"[red]Error:[/red] Project '{project}' response missing ID")
801+
sys.exit(2)
692802

693803
except Exception as e:
804+
logger.debug("Failed to initialize client for list_sessions: %s", e, exc_info=True)
694805
console.print(f"[red]Error:[/red] Failed to initialize: {e}")
806+
console.print(" Check your authentication and network connection.")
695807
sys.exit(2)
696808

697809
# Helper to derive session type from fields:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# CLI commands - presentation layer
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""
2+
Diagnostics CLI commands.
3+
4+
This module contains CLI presentation logic for diagnostic commands,
5+
delegating business logic to the diagnostic service.
6+
"""
7+
8+
import json
9+
import sys
10+
11+
import click
12+
from rich.console import Console
13+
from rich.panel import Panel
14+
15+
from recce_cloud.services.diagnostic_service import (
16+
CheckStatus,
17+
DiagnosticResults,
18+
DiagnosticService,
19+
)
20+
21+
22+
class DiagnosticRenderer:
23+
"""Renders diagnostic results to the console."""
24+
25+
def __init__(self, console: Console):
26+
self.console = console
27+
28+
def render_header(self) -> None:
29+
"""Render the diagnostic header."""
30+
header = Panel(
31+
"[bold]🩺 Recce Doctor[/bold]\n[dim]Checking your Recce Cloud setup...[/dim]",
32+
expand=False,
33+
padding=(0, 3),
34+
)
35+
self.console.print()
36+
self.console.print(header)
37+
self.console.print()
38+
self.console.print("━" * 65)
39+
self.console.print()
40+
41+
def render_login_check(self, results: DiagnosticResults) -> None:
42+
"""Render the login status check."""
43+
self.console.print("[bold]1. Login Status[/bold] ($ recce-cloud login)")
44+
check = results.login
45+
46+
if check.passed:
47+
email = check.details.get("email", "Unknown")
48+
self.console.print(f"[green]✓[/green] Logged in as [cyan]{email}[/cyan]")
49+
else:
50+
self._render_failure(check)
51+
52+
self.console.print()
53+
54+
def render_project_binding_check(self, results: DiagnosticResults) -> None:
55+
"""Render the project binding check."""
56+
self.console.print("[bold]2. Project Binding[/bold] ($ recce-cloud init)")
57+
check = results.project_binding
58+
59+
if check.passed:
60+
org = check.details.get("org")
61+
project = check.details.get("project")
62+
source = check.details.get("source")
63+
source_label = " (via env vars)" if source == "env_vars" else ""
64+
self.console.print(f"[green]✓[/green] Bound to [cyan]{org}/{project}[/cyan]{source_label}")
65+
else:
66+
self._render_failure(check)
67+
68+
self.console.print()
69+
70+
def render_production_check(self, results: DiagnosticResults) -> None:
71+
"""Render the production metadata check."""
72+
self.console.print("[bold]3. Production Metadata[/bold]")
73+
check = results.production_metadata
74+
75+
if check.status == CheckStatus.SKIP:
76+
self.console.print(f"[yellow]⚠[/yellow] {check.message}")
77+
elif check.passed:
78+
session_name = check.details.get("session_name", "(unnamed)")
79+
relative_time = check.details.get("relative_time")
80+
time_str = f" (uploaded {relative_time})" if relative_time else ""
81+
self.console.print(f'[green]✓[/green] Found production session "[cyan]{session_name}[/cyan]"{time_str}')
82+
else:
83+
self._render_failure(check)
84+
85+
self.console.print()
86+
87+
def render_dev_session_check(self, results: DiagnosticResults) -> None:
88+
"""Render the dev session check."""
89+
self.console.print("[bold]4. Dev Session[/bold]")
90+
check = results.dev_session
91+
92+
if check.status == CheckStatus.SKIP:
93+
self.console.print(f"[yellow]⚠[/yellow] {check.message}")
94+
elif check.passed:
95+
session_name = check.details.get("session_name", "(unnamed)")
96+
relative_time = check.details.get("relative_time")
97+
time_str = f" (uploaded {relative_time})" if relative_time else ""
98+
self.console.print(f'[green]✓[/green] Found dev session "[cyan]{session_name}[/cyan]"{time_str}')
99+
else:
100+
self._render_failure(check)
101+
102+
def render_summary(self, results: DiagnosticResults) -> None:
103+
"""Render the summary section."""
104+
self.console.print()
105+
self.console.print("━" * 65)
106+
self.console.print()
107+
108+
if results.all_passed:
109+
self.console.print("[green]✓ All checks passed![/green] Your Recce setup is ready.")
110+
self.console.print()
111+
self.console.print("Next step:")
112+
self.console.print(" $ recce-cloud review --session-name <session_name>")
113+
else:
114+
self.console.print(
115+
f"[yellow]⚠ {results.passed_count}/{results.total_count} checks passed.[/yellow] "
116+
"See above for remediation steps."
117+
)
118+
119+
def render_all(self, results: DiagnosticResults) -> None:
120+
"""Render all diagnostic results."""
121+
self.render_header()
122+
self.render_login_check(results)
123+
self.render_project_binding_check(results)
124+
self.render_production_check(results)
125+
self.render_dev_session_check(results)
126+
self.render_summary(results)
127+
128+
def _render_failure(self, check) -> None:
129+
"""Render a failed check with its suggestion."""
130+
self.console.print(f"[red]✗[/red] {check.message}")
131+
if check.suggestion:
132+
self.console.print()
133+
self.console.print("[dim]→ To fix:[/dim]")
134+
for line in check.suggestion.split("\n"):
135+
self.console.print(f" {line}")
136+
137+
138+
@click.command()
139+
@click.option(
140+
"--json",
141+
"output_json",
142+
is_flag=True,
143+
help="Output in JSON format for scripting",
144+
)
145+
def doctor(output_json: bool) -> None:
146+
"""
147+
Check Recce Cloud setup and configuration.
148+
149+
Validates login status, project binding, and session availability.
150+
Provides actionable suggestions when issues are found.
151+
152+
\b
153+
Examples:
154+
# Check setup status
155+
recce-cloud doctor
156+
157+
# Machine-readable output
158+
recce-cloud doctor --json
159+
"""
160+
console = Console()
161+
162+
# Run diagnostic checks
163+
service = DiagnosticService()
164+
results = service.run_all_checks()
165+
166+
# Output results
167+
if output_json:
168+
console.print(json.dumps(results.to_dict(), indent=2, default=str))
169+
else:
170+
renderer = DiagnosticRenderer(console)
171+
renderer.render_all(results)
172+
173+
# Exit with appropriate code
174+
sys.exit(0 if results.all_passed else 1)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Services layer for business logic

0 commit comments

Comments
 (0)