vantage-cli 0.1.1__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.
Files changed (46) hide show
  1. vantage_cli/__init__.py +131 -0
  2. vantage_cli/apps/__init__.py +22 -0
  3. vantage_cli/apps/common.py +78 -0
  4. vantage_cli/apps/juju_localhost/__init__.py +17 -0
  5. vantage_cli/apps/juju_localhost/app.py +255 -0
  6. vantage_cli/apps/juju_localhost/bundle_yaml.py +143 -0
  7. vantage_cli/apps/microk8s/README.md +47 -0
  8. vantage_cli/apps/microk8s/__init__.py +3 -0
  9. vantage_cli/apps/microk8s/app.py +301 -0
  10. vantage_cli/apps/multipass_singlenode/__init__.py +12 -0
  11. vantage_cli/apps/multipass_singlenode/app.py +173 -0
  12. vantage_cli/apps/templates.py +178 -0
  13. vantage_cli/auth.py +429 -0
  14. vantage_cli/cache.py +143 -0
  15. vantage_cli/client.py +84 -0
  16. vantage_cli/command_base.py +63 -0
  17. vantage_cli/commands/__init__.py +1 -0
  18. vantage_cli/commands/clouds/__init__.py +20 -0
  19. vantage_cli/commands/clouds/add.py +81 -0
  20. vantage_cli/commands/clouds/delete.py +61 -0
  21. vantage_cli/commands/clouds/render.py +146 -0
  22. vantage_cli/commands/clouds/update.py +97 -0
  23. vantage_cli/commands/clusters/__init__.py +27 -0
  24. vantage_cli/commands/clusters/create.py +270 -0
  25. vantage_cli/commands/clusters/delete.py +101 -0
  26. vantage_cli/commands/clusters/get.py +30 -0
  27. vantage_cli/commands/clusters/list.py +84 -0
  28. vantage_cli/commands/clusters/render.py +233 -0
  29. vantage_cli/commands/clusters/schema.py +31 -0
  30. vantage_cli/commands/clusters/utils.py +248 -0
  31. vantage_cli/commands/profile/__init__.py +30 -0
  32. vantage_cli/commands/profile/crud.py +529 -0
  33. vantage_cli/commands/profile/render.py +55 -0
  34. vantage_cli/config.py +161 -0
  35. vantage_cli/constants.py +40 -0
  36. vantage_cli/exceptions.py +127 -0
  37. vantage_cli/format.py +39 -0
  38. vantage_cli/gql_client.py +655 -0
  39. vantage_cli/main.py +303 -0
  40. vantage_cli/render.py +56 -0
  41. vantage_cli/schemas.py +48 -0
  42. vantage_cli/time_loop.py +124 -0
  43. vantage_cli-0.1.1.dist-info/METADATA +30 -0
  44. vantage_cli-0.1.1.dist-info/RECORD +46 -0
  45. vantage_cli-0.1.1.dist-info/WHEEL +4 -0
  46. vantage_cli-0.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,233 @@
1
+ # © 2025 Vantage Compute, Inc. All rights reserved.
2
+ # Confidential and proprietary. Unauthorized use prohibited.
3
+ """Render helpers for cluster command output formatting."""
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from rich import print_json
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from vantage_cli.render import StyleMapper
13
+
14
+
15
+ def render_clusters_table(
16
+ clusters: List[Dict[str, Any]],
17
+ title: str = "Clusters List",
18
+ total_count: Optional[int] = None,
19
+ json_output: bool = False,
20
+ ) -> None:
21
+ """Render a list of clusters in a Rich table format.
22
+
23
+ Args:
24
+ clusters: List of cluster dictionaries
25
+ title: Title for the table
26
+ total_count: Total number of clusters available
27
+ json_output: If True, output as JSON instead of a table
28
+ """
29
+ if json_output:
30
+ # Output as JSON with Rich formatting
31
+ output = {"clusters": clusters, "total": total_count or len(clusters)}
32
+ print_json(data=output)
33
+ return
34
+
35
+ if not clusters:
36
+ console = Console()
37
+ console.print()
38
+ console.print(Panel("No clusters found.", title="[yellow]No Results"))
39
+ console.print()
40
+ return
41
+
42
+ # Define cluster-specific styling
43
+ style_mapper = StyleMapper(
44
+ name="bold cyan",
45
+ status="green",
46
+ provider="blue",
47
+ ownerEmail="yellow",
48
+ clientId="white",
49
+ description="dim white",
50
+ )
51
+
52
+ # Create the table
53
+ table = Table(
54
+ title=title,
55
+ caption=f"Items: {len(clusters)}{f' of {total_count}' if total_count else ''}",
56
+ show_header=True,
57
+ header_style="bold white",
58
+ )
59
+
60
+ # Add columns based on the first cluster's keys
61
+ first_cluster = clusters[0]
62
+
63
+ # Define column order and display names
64
+ column_mapping = {
65
+ "name": "Name",
66
+ "status": "Status",
67
+ "provider": "Provider",
68
+ "ownerEmail": "Owner Email",
69
+ "clientId": "Client ID",
70
+ "description": "Description",
71
+ }
72
+
73
+ # Add columns in the specified order
74
+ for key, display_name in column_mapping.items():
75
+ if key in first_cluster:
76
+ table.add_column(display_name, **style_mapper.map_style(key))
77
+
78
+ # Add rows
79
+ for cluster in clusters:
80
+ row_values = []
81
+ for key in column_mapping.keys():
82
+ if key in cluster:
83
+ value = cluster.get(key, "")
84
+ # Handle None values and long descriptions
85
+ if value is None:
86
+ value = ""
87
+ elif key == "description" and isinstance(value, str) and len(value) > 50:
88
+ value = value[:47] + "..."
89
+ row_values.append(str(value))
90
+ table.add_row(*row_values)
91
+
92
+ # Print the table
93
+ console = Console()
94
+ console.print()
95
+ console.print(table)
96
+ console.print()
97
+
98
+
99
+ def render_cluster_details(cluster: Dict[str, Any], json_output: bool = False) -> None:
100
+ """Render detailed information for a single cluster.
101
+
102
+ Args:
103
+ cluster: Cluster dictionary with details
104
+ json_output: If True, output as JSON instead of a formatted view
105
+ """
106
+ if json_output:
107
+ # Output as JSON with Rich formatting
108
+ print_json(data=cluster)
109
+ return
110
+
111
+ if not cluster:
112
+ console = Console()
113
+ console.print()
114
+ console.print(Panel("Cluster not found.", title="[red]Not Found"))
115
+ console.print()
116
+ return
117
+
118
+ console = Console()
119
+ cluster_name = cluster.get("name", "Unknown")
120
+
121
+ # Create a table matching the profile get command style
122
+ table = Table(
123
+ title=f"Cluster Details: {cluster_name}", show_header=True, header_style="bold white"
124
+ )
125
+
126
+ table.add_column("Property", style="bold cyan")
127
+ table.add_column("Value", style="white")
128
+
129
+ # Add basic cluster information
130
+ table.add_row("Name", cluster.get("name", "N/A"))
131
+ table.add_row("Status", cluster.get("status", "N/A"))
132
+ table.add_row("Provider", cluster.get("provider", "N/A"))
133
+ table.add_row("Owner Email", cluster.get("ownerEmail", "N/A"))
134
+ table.add_row("Client ID", cluster.get("clientId", "N/A"))
135
+
136
+ # Add client secret if available
137
+ client_secret = cluster.get("client_secret")
138
+ if client_secret:
139
+ table.add_row("Client Secret", f"[dim]{client_secret}[/dim]")
140
+ else:
141
+ table.add_row("Client Secret", "[dim]Not available[/dim]")
142
+
143
+ table.add_row("Description", cluster.get("description", "N/A"))
144
+
145
+ cloud_account_id = cluster.get("cloudAccountId")
146
+ table.add_row(
147
+ "Cloud Account ID", str(cloud_account_id) if cloud_account_id is not None else "None"
148
+ )
149
+
150
+ # Add creation parameters if available
151
+ creation_params = cluster.get("creationParameters", {})
152
+ if creation_params:
153
+ # Add a separator row
154
+ table.add_row("", "") # Empty row for spacing
155
+ table.add_row("[bold]Creation Parameters[/bold]", "")
156
+
157
+ for key, value in creation_params.items():
158
+ if value is not None:
159
+ # Format key for display
160
+ display_key = key.replace("_", " ").title()
161
+ display_value = str(value) if not isinstance(value, dict) else "Complex Object"
162
+ table.add_row(f" {display_key}", display_value)
163
+
164
+ console.print()
165
+ console.print(table)
166
+ console.print()
167
+
168
+
169
+ def render_cluster_creation_result(cluster: Dict[str, Any], json_output: bool = False) -> None:
170
+ """Render the result of cluster creation.
171
+
172
+ Args:
173
+ cluster: Created cluster dictionary
174
+ json_output: If True, output as JSON instead of a formatted view
175
+ """
176
+ if json_output:
177
+ print_json(data=cluster)
178
+ return
179
+
180
+ console = Console()
181
+ console.print()
182
+ console.print(
183
+ Panel(
184
+ f"✅ Cluster '[bold cyan]{cluster.get('name', 'Unknown')}[/bold cyan]' created successfully!",
185
+ title="[green]Cluster Created[/green]",
186
+ border_style="green",
187
+ )
188
+ )
189
+
190
+ # Show basic details
191
+ render_cluster_details(cluster, json_output=False)
192
+
193
+
194
+ def render_cluster_deletion_result(
195
+ cluster_name: str, success: bool = True, json_output: bool = False
196
+ ) -> None:
197
+ """Render the result of cluster deletion.
198
+
199
+ Args:
200
+ cluster_name: Name of the deleted cluster
201
+ success: Whether deletion was successful
202
+ json_output: If True, output as JSON instead of a formatted view
203
+ """
204
+ if json_output:
205
+ result = {
206
+ "cluster_name": cluster_name,
207
+ "deleted": success,
208
+ "message": f"Cluster '{cluster_name}' {'deleted' if success else 'deletion failed'}",
209
+ }
210
+ print_json(data=result)
211
+ return
212
+
213
+ console = Console()
214
+ console.print()
215
+
216
+ if success:
217
+ console.print(
218
+ Panel(
219
+ f"✅ Cluster '[bold cyan]{cluster_name}[/bold cyan]' deleted successfully!",
220
+ title="[green]Cluster Deleted[/green]",
221
+ border_style="green",
222
+ )
223
+ )
224
+ else:
225
+ console.print(
226
+ Panel(
227
+ f"❌ Failed to delete cluster '[bold cyan]{cluster_name}[/bold cyan]'",
228
+ title="[red]Deletion Failed[/red]",
229
+ border_style="red",
230
+ )
231
+ )
232
+
233
+ console.print()
@@ -0,0 +1,31 @@
1
+ """Schema definitions for cluster commands."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class VantageClusterContext(BaseModel):
9
+ """Vantage cluster context for generating juju configuration."""
10
+
11
+ client_id: str
12
+ client_secret: str
13
+ oidc_domain: str
14
+ oidc_base_url: str
15
+ base_api_url: str
16
+ tunnel_api_url: str
17
+ jupyterhub_token: str
18
+
19
+
20
+ class ClusterDetailSchema(BaseModel):
21
+ """Schema for detailed cluster information."""
22
+
23
+ name: str
24
+ status: str
25
+ client_id: str
26
+ client_secret: Optional[str] = None
27
+ description: str
28
+ owner_mail: str
29
+ provider: str
30
+ cloud_account_id: Optional[str] = None
31
+ creation_parameters: Dict[str, Any]
@@ -0,0 +1,248 @@
1
+ # © 2025 Vantage Compute, Inc. All rights reserved.
2
+ # Confidential and proprietary. Unauthorized use prohibited.
3
+ """Shared utilities for cluster commands."""
4
+
5
+ import importlib
6
+ import logging
7
+ import textwrap
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
+ import httpx
12
+ import typer
13
+ from loguru import logger
14
+
15
+ from vantage_cli.auth import extract_persona
16
+ from vantage_cli.config import Settings
17
+ from vantage_cli.exceptions import Abort
18
+ from vantage_cli.gql_client import create_async_graphql_client
19
+
20
+
21
+ def get_available_apps() -> Dict[str, Dict[str, Any]]:
22
+ """Dynamically discover available deployment apps."""
23
+ apps: Dict[str, Dict[str, Any]] = {}
24
+
25
+ # Register the apps maintained with the vantage cli
26
+ apps_dir = Path(__file__).parent.parent.parent / "apps"
27
+
28
+ if not apps_dir.exists():
29
+ logging.warning(f"Apps directory not found: {apps_dir}")
30
+ return apps
31
+
32
+ # Scan each app directory
33
+ for app_path in apps_dir.iterdir():
34
+ if app_path.is_dir() and not app_path.name.startswith("__"):
35
+ app_name = app_path.name
36
+ app_module_path = app_path / "app.py"
37
+
38
+ if app_module_path.exists():
39
+ # Convert directory name to the command name (e.g., "juju_localhost" -> "juju-localhost")
40
+ command_name = app_name.replace("_", "-")
41
+
42
+ try:
43
+ # Dynamically import the app module
44
+ app_module = importlib.import_module(f"vantage_cli.apps.{app_name}.app")
45
+
46
+ # Check if deploy function exists
47
+ if hasattr(app_module, "deploy"):
48
+ deploy_function = getattr(app_module, "deploy")
49
+ # Use command_name (with hyphens) as key so CLI can find it
50
+ apps[command_name] = {
51
+ "module": app_module,
52
+ "deploy_function": deploy_function,
53
+ }
54
+ except (ImportError, AttributeError, ModuleNotFoundError) as e:
55
+ logger.warning(f"Failed to import app '{app_name}': {e}")
56
+ except Exception as e:
57
+ logger.error(f"Unexpected error importing app '{app_name}': {e}")
58
+ logger.debug(f"Full traceback for {app_name}", exc_info=True)
59
+
60
+ return apps
61
+
62
+
63
+ def get_cloud_choices() -> list[str]:
64
+ """Get the list of supported clouds from settings."""
65
+ settings = Settings()
66
+ return settings.supported_clouds
67
+
68
+
69
+ def get_app_choices() -> list[str]:
70
+ """Get the list of available deployment apps."""
71
+ try:
72
+ apps = get_available_apps()
73
+ return list(apps.keys())
74
+ except Exception as e:
75
+ logger.warning(f"Failed to get available apps: {e}")
76
+ return []
77
+
78
+
79
+ async def get_cluster_client_secret(ctx: typer.Context, client_id: str) -> Optional[str]:
80
+ """Get the client secret for the cluster from vantage-api using GraphQL client auth.
81
+
82
+ Args:
83
+ ctx: Typer context carrying settings/profile
84
+ client_id: The client ID of the cluster
85
+
86
+ Returns:
87
+ The client secret if found, None otherwise
88
+ """
89
+ try:
90
+ # Get user authentication using the same method as GraphQL client
91
+ persona = extract_persona(ctx.obj.profile)
92
+ access_token = persona.token_set.access_token
93
+
94
+ # Use vantage-api admin/management/clients endpoint
95
+ api_url = f"{ctx.obj.settings.api_base_url}/admin/management/clients"
96
+
97
+ headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
98
+
99
+ async with httpx.AsyncClient() as client:
100
+ # First, search for the client by clientId
101
+ params = {"client_id": client_id}
102
+ logger.debug(f"Searching for client with ID: {client_id} at {api_url}")
103
+ response = await client.get(api_url, headers=headers, params=params)
104
+
105
+ if response.status_code != 200:
106
+ logger.error(
107
+ f"Failed to query vantage-api clients: {response.status_code} - {response.text}"
108
+ )
109
+ return None
110
+
111
+ response_data = response.json()
112
+ clients = response_data.get("clients", [])
113
+
114
+ if not clients:
115
+ logger.warning(f"No client found with clientId: {client_id}")
116
+ return None
117
+
118
+ # Get the first matching client's internal ID
119
+ vantage_client = clients[0]
120
+ internal_id = vantage_client.get("id")
121
+
122
+ if not internal_id:
123
+ logger.error(f"No internal ID found for client: {client_id}")
124
+ return None
125
+
126
+ # Get the client secret using the internal ID
127
+ secret_url = f"{api_url}/{internal_id}"
128
+ logger.debug(f"Fetching client secret from: {secret_url}")
129
+ secret_response = await client.get(secret_url, headers=headers)
130
+
131
+ if secret_response.status_code != 200:
132
+ logger.error(
133
+ f"Failed to get client secret: {secret_response.status_code} - {secret_response.text}"
134
+ )
135
+ return None
136
+
137
+ secret_data = secret_response.json()
138
+ logger.debug(f"Client secret response data: {secret_data}")
139
+ # Try both camelCase and snake_case field names
140
+ client_secret = secret_data.get("client_secret")
141
+
142
+ if client_secret:
143
+ logger.debug(f"Successfully retrieved client secret for {client_id}")
144
+ else:
145
+ logger.warning(f"Client secret not found in response for {client_id}")
146
+ logger.debug(f"Available keys in response: {list(secret_data.keys())}")
147
+
148
+ return client_secret
149
+
150
+ except Exception as e:
151
+ logger.error(f"Error retrieving client secret for {client_id}: {e}")
152
+ typer.Exit(code=1)
153
+
154
+
155
+ async def get_cluster_by_name(ctx: typer.Context, cluster_name: str) -> Dict[str, Any] | None:
156
+ """Get cluster details by name from vantage-api using GraphQL client auth.
157
+
158
+ Args:
159
+ ctx: Typer context carrying settings/profile
160
+ cluster_name: The name of the cluster to retrieve
161
+
162
+ Returns:
163
+ The cluster data if found, None otherwise
164
+ """
165
+ # Ensure we have settings configured
166
+ if not ctx.obj or not ctx.obj.settings:
167
+ raise Abort(
168
+ "No settings configured. Please run 'vantage config set' first.",
169
+ subject="Configuration Required",
170
+ log_message="Settings not configured",
171
+ )
172
+
173
+ query = textwrap.dedent("""\
174
+ query getClusters($first: Int!) {
175
+ clusters(first: $first) {
176
+ edges {
177
+ node {
178
+ name
179
+ status
180
+ clientId
181
+ description
182
+ ownerEmail
183
+ provider
184
+ cloudAccountId
185
+ creationParameters
186
+ }
187
+ }
188
+ }
189
+ }
190
+ """)
191
+
192
+ variables = {"first": 100} # Fetch up to 100 clusters
193
+
194
+ try:
195
+ # Create async GraphQL client
196
+ graphql_client = create_async_graphql_client(ctx.obj.settings, ctx.obj.profile)
197
+
198
+ # Execute the query
199
+ logger.debug(f"Executing cluster get query for: {cluster_name}")
200
+ response_data = await graphql_client.execute_async(query, variables)
201
+
202
+ # Extract cluster data
203
+ clusters_data = response_data.get("clusters", {})
204
+ clusters = [edge["node"] for edge in clusters_data.get("edges", [])]
205
+
206
+ # Filter clusters by name (case-insensitive)
207
+ matching_clusters = [
208
+ cluster
209
+ for cluster in clusters
210
+ if cluster.get("name", "").lower() == cluster_name.lower()
211
+ ]
212
+
213
+ if not matching_clusters:
214
+ raise Abort(
215
+ f"No cluster found with name '{cluster_name}'.",
216
+ subject="Cluster Not Found",
217
+ log_message=f"Cluster '{cluster_name}' not found",
218
+ )
219
+
220
+ # Get the first (and should be only) cluster
221
+ cluster = matching_clusters[0]
222
+
223
+ # Fetch client secret from API if clientId is available
224
+ client_id = cluster.get("clientId")
225
+ if client_id:
226
+ try:
227
+ client_secret = await get_cluster_client_secret(ctx, client_id)
228
+ cluster["client_secret"] = client_secret
229
+ except Exception as e:
230
+ logger.warning(
231
+ f"Failed to retrieve client secret for cluster '{cluster_name}': {e}"
232
+ )
233
+ cluster["client_secret"] = None
234
+ else:
235
+ cluster["client_secret"] = None
236
+
237
+ return cluster
238
+
239
+ except Abort:
240
+ # Re-raise Abort exceptions as they contain user-friendly messages
241
+ raise
242
+ except (httpx.RequestError, ValueError, KeyError, AttributeError) as e:
243
+ logger.error(f"Unexpected error getting cluster '{cluster_name}': {e}")
244
+ raise Abort(
245
+ f"An unexpected error occurred while getting cluster '{cluster_name}'.",
246
+ subject="Unexpected Error",
247
+ log_message=f"Unexpected error: {e}",
248
+ )
@@ -0,0 +1,30 @@
1
+ # © 2025 Vantage Compute, Inc. All rights reserved.
2
+ # Confidential and proprietary. Unauthorized use prohibited.
3
+ """Profile management commands for Vantage CLI."""
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from .crud import create_profile, delete_profile, get_profile, list_profiles, use_profile
9
+
10
+ console = Console()
11
+
12
+ # Create the profile app
13
+ profile_app = typer.Typer(
14
+ name="profile",
15
+ help="Manage Vantage CLI profiles to work with different environments and configurations.",
16
+ invoke_without_command=True,
17
+ no_args_is_help=True,
18
+ context_settings={
19
+ "allow_extra_args": True,
20
+ "allow_interspersed_args": True,
21
+ "ignore_unknown_options": True,
22
+ },
23
+ )
24
+
25
+ # Register subcommands
26
+ profile_app.command("create")(create_profile)
27
+ profile_app.command("delete")(delete_profile)
28
+ profile_app.command("get")(get_profile)
29
+ profile_app.command("list")(list_profiles)
30
+ profile_app.command("use")(use_profile)