alphai 0.1.1__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alphai/__init__.py +40 -2
- alphai/auth.py +31 -11
- alphai/cleanup.py +351 -0
- alphai/cli.py +45 -910
- alphai/client.py +115 -70
- alphai/commands/__init__.py +24 -0
- alphai/commands/config.py +67 -0
- alphai/commands/docker.py +615 -0
- alphai/commands/jupyter.py +350 -0
- alphai/commands/notebooks.py +1173 -0
- alphai/commands/orgs.py +27 -0
- alphai/commands/projects.py +35 -0
- alphai/config.py +15 -5
- alphai/docker.py +80 -45
- alphai/exceptions.py +122 -0
- alphai/jupyter_manager.py +577 -0
- alphai/notebook_renderer.py +473 -0
- alphai/utils.py +67 -0
- {alphai-0.1.1.dist-info → alphai-0.2.0.dist-info}/METADATA +9 -8
- alphai-0.2.0.dist-info/RECORD +23 -0
- alphai-0.1.1.dist-info/RECORD +0 -12
- {alphai-0.1.1.dist-info → alphai-0.2.0.dist-info}/WHEEL +0 -0
- {alphai-0.1.1.dist-info → alphai-0.2.0.dist-info}/entry_points.txt +0 -0
- {alphai-0.1.1.dist-info → alphai-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"""Docker-related commands for alphai CLI.
|
|
2
|
+
|
|
3
|
+
Contains `run` and `cleanup` commands for Docker container management.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import subprocess
|
|
9
|
+
import webbrowser
|
|
10
|
+
from typing import Optional, Dict
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.prompt import Prompt, Confirm
|
|
15
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
|
|
18
|
+
from ..client import AlphAIClient
|
|
19
|
+
from ..config import Config
|
|
20
|
+
from ..docker import DockerManager
|
|
21
|
+
from ..cleanup import DockerCleanupManager
|
|
22
|
+
from ..utils import get_logger
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_frontend_url(api_url: str) -> str:
|
|
29
|
+
"""Convert API URL to frontend URL for browser opening."""
|
|
30
|
+
if api_url.startswith("http://localhost") or api_url.startswith("https://localhost"):
|
|
31
|
+
return api_url.replace("/api", "").rstrip("/")
|
|
32
|
+
elif "runalph.ai" in api_url:
|
|
33
|
+
if "/api" in api_url:
|
|
34
|
+
return api_url.replace("runalph.ai/api", "runalph.ai").rstrip("/")
|
|
35
|
+
else:
|
|
36
|
+
return api_url.replace("runalph.ai", "runalph.ai").rstrip("/")
|
|
37
|
+
else:
|
|
38
|
+
return api_url.replace("/api", "").rstrip("/")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _select_organization(client: AlphAIClient) -> str:
|
|
42
|
+
"""Interactively select an organization."""
|
|
43
|
+
import questionary
|
|
44
|
+
|
|
45
|
+
logger.debug("Prompting user to select organization")
|
|
46
|
+
console.print("[yellow]No organization specified. Please select one:[/yellow]")
|
|
47
|
+
|
|
48
|
+
with Progress(
|
|
49
|
+
SpinnerColumn(),
|
|
50
|
+
TextColumn("[progress.description]{task.description}"),
|
|
51
|
+
console=console
|
|
52
|
+
) as progress:
|
|
53
|
+
task = progress.add_task("Fetching organizations...", total=None)
|
|
54
|
+
orgs_data = client.get_organizations()
|
|
55
|
+
progress.update(task, completed=1)
|
|
56
|
+
|
|
57
|
+
if not orgs_data or len(orgs_data) == 0:
|
|
58
|
+
logger.error("No organizations found for user")
|
|
59
|
+
console.print("[red]No organizations found. Please create one first.[/red]")
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
org_choices = []
|
|
63
|
+
for org_data in orgs_data:
|
|
64
|
+
display_name = f"{org_data.name} ({org_data.slug})"
|
|
65
|
+
org_choices.append(questionary.Choice(title=display_name, value=org_data.slug))
|
|
66
|
+
|
|
67
|
+
selected_org_slug = questionary.select(
|
|
68
|
+
"Select organization (use ↑↓ arrows and press Enter):",
|
|
69
|
+
choices=org_choices,
|
|
70
|
+
style=questionary.Style([
|
|
71
|
+
('question', 'bold'),
|
|
72
|
+
('pointer', 'fg:#673ab7 bold'),
|
|
73
|
+
('highlighted', 'fg:#673ab7 bold'),
|
|
74
|
+
('selected', 'fg:#cc5454'),
|
|
75
|
+
('instruction', 'fg:#888888 italic')
|
|
76
|
+
])
|
|
77
|
+
).ask()
|
|
78
|
+
|
|
79
|
+
if not selected_org_slug:
|
|
80
|
+
logger.warning("User cancelled organization selection")
|
|
81
|
+
console.print("[red]No organization selected. Exiting.[/red]")
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
selected_org_name = next((o.name for o in orgs_data if o.slug == selected_org_slug), selected_org_slug)
|
|
85
|
+
console.print(f"[green]✓ Selected organization: {selected_org_name} ({selected_org_slug})[/green]")
|
|
86
|
+
logger.info(f"Organization selected: {selected_org_slug}")
|
|
87
|
+
|
|
88
|
+
return selected_org_slug
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _get_project_name() -> str:
|
|
92
|
+
"""Interactively get project name from user."""
|
|
93
|
+
logger.debug("Prompting user to enter project name")
|
|
94
|
+
console.print("[yellow]No project specified. Please enter a project name:[/yellow]")
|
|
95
|
+
|
|
96
|
+
while True:
|
|
97
|
+
project = Prompt.ask("Enter project name")
|
|
98
|
+
if project and project.strip():
|
|
99
|
+
project = project.strip()
|
|
100
|
+
console.print(f"[green]✓ Will create project: {project}[/green]")
|
|
101
|
+
logger.info(f"Project name entered: {project}")
|
|
102
|
+
return project
|
|
103
|
+
else:
|
|
104
|
+
console.print("[red]Project name cannot be empty[/red]")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_env_vars(env: tuple) -> Dict[str, str]:
|
|
108
|
+
"""Parse environment variables from tuple of KEY=VALUE strings."""
|
|
109
|
+
env_vars = {}
|
|
110
|
+
for e in env:
|
|
111
|
+
if '=' in e:
|
|
112
|
+
key, value = e.split('=', 1)
|
|
113
|
+
env_vars[key] = value
|
|
114
|
+
else:
|
|
115
|
+
console.print(f"[yellow]Warning: Invalid environment variable format: {e}[/yellow]")
|
|
116
|
+
logger.debug(f"Parsed {len(env_vars)} environment variables")
|
|
117
|
+
return env_vars
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_volumes(volume: tuple) -> Dict[str, str]:
|
|
121
|
+
"""Parse volume mounts from tuple of HOST:CONTAINER strings."""
|
|
122
|
+
volumes = {}
|
|
123
|
+
for v in volume:
|
|
124
|
+
if ':' in v:
|
|
125
|
+
host_path, container_path = v.split(':', 1)
|
|
126
|
+
volumes[host_path] = container_path
|
|
127
|
+
else:
|
|
128
|
+
console.print(f"[yellow]Warning: Invalid volume format: {v}[/yellow]")
|
|
129
|
+
logger.debug(f"Parsed {len(volumes)} volume mounts")
|
|
130
|
+
return volumes
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_jupyter_installed(docker_manager: DockerManager, container_id: str) -> bool:
|
|
134
|
+
"""Check if Jupyter is actually installed in the container."""
|
|
135
|
+
try:
|
|
136
|
+
result = subprocess.run(
|
|
137
|
+
["docker", "exec", container_id, "which", "jupyter"],
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
timeout=10
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if result.returncode == 0:
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
result = subprocess.run(
|
|
147
|
+
["docker", "exec", container_id, "which", "jupyter-lab"],
|
|
148
|
+
capture_output=True,
|
|
149
|
+
text=True,
|
|
150
|
+
timeout=10
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return result.returncode == 0
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
console.print(f"[yellow]Warning: Could not check Jupyter installation: {e}[/yellow]")
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_cloudflared_installed(docker_manager: DockerManager, container_id: str) -> bool:
|
|
161
|
+
"""Check if cloudflared is installed in the container."""
|
|
162
|
+
try:
|
|
163
|
+
result = subprocess.run(
|
|
164
|
+
["docker", "exec", container_id, "which", "cloudflared"],
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
timeout=10
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return result.returncode == 0
|
|
171
|
+
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.warning(f"Could not check cloudflared installation: {e}")
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _install_jupyter_in_container(docker_manager: DockerManager, container_id: str) -> bool:
|
|
178
|
+
"""Install Jupyter in a container that doesn't have it."""
|
|
179
|
+
package_manager = docker_manager._detect_package_manager(container_id)
|
|
180
|
+
|
|
181
|
+
if not package_manager:
|
|
182
|
+
console.print("[red]Could not detect package manager for Jupyter installation[/red]")
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
if package_manager in ['apt', 'apt-get']:
|
|
187
|
+
install_commands = [
|
|
188
|
+
"apt-get update",
|
|
189
|
+
"apt-get install -y python3-pip",
|
|
190
|
+
"pip3 install jupyter jupyterlab"
|
|
191
|
+
]
|
|
192
|
+
elif package_manager in ['yum', 'dnf']:
|
|
193
|
+
install_commands = [
|
|
194
|
+
f"{package_manager} update -y",
|
|
195
|
+
f"{package_manager} install -y python3-pip",
|
|
196
|
+
"pip3 install jupyter jupyterlab"
|
|
197
|
+
]
|
|
198
|
+
elif package_manager == 'apk':
|
|
199
|
+
install_commands = [
|
|
200
|
+
"apk update",
|
|
201
|
+
"apk add --no-cache python3 py3-pip",
|
|
202
|
+
"pip3 install jupyter jupyterlab"
|
|
203
|
+
]
|
|
204
|
+
else:
|
|
205
|
+
install_commands = [
|
|
206
|
+
"pip3 install jupyter jupyterlab"
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
for cmd in install_commands:
|
|
210
|
+
result = subprocess.run(
|
|
211
|
+
["docker", "exec", "--user", "root", container_id, "bash", "-c", cmd],
|
|
212
|
+
capture_output=True,
|
|
213
|
+
text=True,
|
|
214
|
+
timeout=120
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if result.returncode != 0:
|
|
218
|
+
console.print(f"[red]Failed to run: {cmd}[/red]")
|
|
219
|
+
console.print(f"[red]Error: {result.stderr}[/red]")
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
console.print("[green]✓ Jupyter installed successfully[/green]")
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
except Exception as e:
|
|
226
|
+
console.print(f"[red]Error installing Jupyter: {e}[/red]")
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _setup_jupyter_in_container(
|
|
231
|
+
docker_manager: DockerManager,
|
|
232
|
+
container_id: str,
|
|
233
|
+
jupyter_port: int,
|
|
234
|
+
jupyter_token: str
|
|
235
|
+
) -> bool:
|
|
236
|
+
"""Setup Jupyter in container if needed."""
|
|
237
|
+
logger.info(f"Setting up Jupyter in container {container_id[:12]}")
|
|
238
|
+
|
|
239
|
+
if not _is_jupyter_installed(docker_manager, container_id):
|
|
240
|
+
console.print("[yellow]Installing Jupyter in container...[/yellow]")
|
|
241
|
+
if not _install_jupyter_in_container(docker_manager, container_id):
|
|
242
|
+
logger.error("Failed to install Jupyter")
|
|
243
|
+
console.print("[red]Failed to install Jupyter[/red]")
|
|
244
|
+
return False
|
|
245
|
+
else:
|
|
246
|
+
console.print("[green]✓ Jupyter is already installed[/green]")
|
|
247
|
+
|
|
248
|
+
success, actual_token = docker_manager.ensure_jupyter_running(
|
|
249
|
+
container_id,
|
|
250
|
+
jupyter_port,
|
|
251
|
+
jupyter_token,
|
|
252
|
+
force_restart=True
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if not success:
|
|
256
|
+
console.print("[yellow]⚠ Jupyter may not be running[/yellow]")
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
logger.info("Jupyter setup completed successfully")
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _connect_to_cloud(
|
|
264
|
+
client: AlphAIClient,
|
|
265
|
+
docker_manager: DockerManager,
|
|
266
|
+
container_id: str,
|
|
267
|
+
org: str,
|
|
268
|
+
project: str,
|
|
269
|
+
app_port: int,
|
|
270
|
+
jupyter_port: int,
|
|
271
|
+
jupyter_token: Optional[str]
|
|
272
|
+
):
|
|
273
|
+
"""Connect container to cloud and setup project."""
|
|
274
|
+
logger.info(f"Connecting project {project} in org {org} to cloud")
|
|
275
|
+
console.print("[yellow]Connecting to cloud...[/yellow]")
|
|
276
|
+
|
|
277
|
+
# Create the connection (tunnel + project) via API
|
|
278
|
+
connection_data = client.create_tunnel_with_project(
|
|
279
|
+
org_slug=org,
|
|
280
|
+
project_name=project,
|
|
281
|
+
app_port=app_port,
|
|
282
|
+
jupyter_port=jupyter_port,
|
|
283
|
+
jupyter_token=jupyter_token
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if not connection_data:
|
|
287
|
+
logger.error("Failed to connect to cloud")
|
|
288
|
+
console.print("[red]Failed to connect to cloud[/red]")
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
# Install connector agent if needed
|
|
292
|
+
if not _is_cloudflared_installed(docker_manager, container_id):
|
|
293
|
+
console.print("[yellow]Installing connector...[/yellow]")
|
|
294
|
+
if not docker_manager.install_cloudflared_in_container(container_id):
|
|
295
|
+
console.print("[yellow]Warning: Connector installation failed, but container is running[/yellow]")
|
|
296
|
+
return None
|
|
297
|
+
else:
|
|
298
|
+
console.print("[green]✓ Connector ready[/green]")
|
|
299
|
+
|
|
300
|
+
# Start the connector
|
|
301
|
+
cloudflared_token = connection_data.cloudflared_token if hasattr(connection_data, 'cloudflared_token') else connection_data.cloudflared_token
|
|
302
|
+
if not docker_manager.setup_tunnel_in_container(container_id, cloudflared_token):
|
|
303
|
+
console.print("[yellow]Warning: Connector setup failed, but container is running[/yellow]")
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
logger.info("Cloud connection established successfully")
|
|
307
|
+
console.print("[green]✓ Connected to cloud[/green]")
|
|
308
|
+
return connection_data
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _display_deployment_summary(
|
|
312
|
+
container,
|
|
313
|
+
connection_data,
|
|
314
|
+
app_port: int,
|
|
315
|
+
jupyter_port: int,
|
|
316
|
+
jupyter_token: Optional[str],
|
|
317
|
+
config: Config,
|
|
318
|
+
org: str,
|
|
319
|
+
project: str
|
|
320
|
+
):
|
|
321
|
+
"""Display deployment summary panel."""
|
|
322
|
+
logger.debug("Displaying deployment summary")
|
|
323
|
+
console.print("\n[bold green]🎉 Setup complete![/bold green]")
|
|
324
|
+
|
|
325
|
+
summary_content = []
|
|
326
|
+
summary_content.append(f"[bold]Container ID:[/bold] {container.id[:12]}")
|
|
327
|
+
summary_content.append("")
|
|
328
|
+
summary_content.append("[bold blue]Local URL:[/bold blue]")
|
|
329
|
+
summary_content.append(f" • App: http://localhost:{app_port}")
|
|
330
|
+
if jupyter_token:
|
|
331
|
+
summary_content.append(f" • Jupyter: http://localhost:{jupyter_port}?token={jupyter_token}")
|
|
332
|
+
else:
|
|
333
|
+
summary_content.append(f" • Jupyter: http://localhost:{jupyter_port}")
|
|
334
|
+
summary_content.append("")
|
|
335
|
+
summary_content.append("[bold green]Public URL:[/bold green]")
|
|
336
|
+
summary_content.append(f" • App: {connection_data.app_url}")
|
|
337
|
+
if jupyter_token:
|
|
338
|
+
summary_content.append(f" • Jupyter: {connection_data.jupyter_url}?token={jupyter_token}")
|
|
339
|
+
else:
|
|
340
|
+
summary_content.append(f" • Jupyter: {connection_data.jupyter_url}")
|
|
341
|
+
summary_content.append("")
|
|
342
|
+
if jupyter_token:
|
|
343
|
+
summary_content.append("[bold cyan]Jupyter Token:[/bold cyan]")
|
|
344
|
+
summary_content.append(f" {jupyter_token}")
|
|
345
|
+
summary_content.append("")
|
|
346
|
+
summary_content.append("[bold yellow]Management:[/bold yellow]")
|
|
347
|
+
summary_content.append(f" • Stop container: docker stop {container.id[:12]}")
|
|
348
|
+
summary_content.append(f" • View logs: docker logs {container.id[:12]}")
|
|
349
|
+
summary_content.append(f" • Cleanup: alphai cleanup {container.id[:12]}")
|
|
350
|
+
summary_content.append("")
|
|
351
|
+
summary_content.append("[bold cyan]Quick Cleanup:[/bold cyan]")
|
|
352
|
+
summary_content.append(" • Press Ctrl+C to automatically cleanup all resources")
|
|
353
|
+
|
|
354
|
+
panel = Panel(
|
|
355
|
+
"\n".join(summary_content),
|
|
356
|
+
title="🚀 Deployment Summary",
|
|
357
|
+
title_align="left",
|
|
358
|
+
border_style="green"
|
|
359
|
+
)
|
|
360
|
+
console.print(panel)
|
|
361
|
+
|
|
362
|
+
with Progress(
|
|
363
|
+
SpinnerColumn(),
|
|
364
|
+
TextColumn("[progress.description]{task.description}"),
|
|
365
|
+
console=console
|
|
366
|
+
) as progress:
|
|
367
|
+
task = progress.add_task("Waiting for cloud connection...", total=None)
|
|
368
|
+
time.sleep(5)
|
|
369
|
+
progress.update(task, completed=1)
|
|
370
|
+
|
|
371
|
+
# Use project slug from API response if available
|
|
372
|
+
frontend_url = _get_frontend_url(config.api_url)
|
|
373
|
+
project_slug = project
|
|
374
|
+
if connection_data.project_data:
|
|
375
|
+
if hasattr(connection_data.project_data, 'slug') and connection_data.project_data.slug:
|
|
376
|
+
project_slug = connection_data.project_data.slug
|
|
377
|
+
elif hasattr(connection_data.project_data, 'name') and connection_data.project_data.name:
|
|
378
|
+
project_slug = connection_data.project_data.name
|
|
379
|
+
|
|
380
|
+
project_url = f"{frontend_url}/{org}/{project_slug}"
|
|
381
|
+
console.print(f"\n[cyan]🌐 Opening browser to: {project_url}[/cyan]")
|
|
382
|
+
try:
|
|
383
|
+
webbrowser.open(project_url)
|
|
384
|
+
logger.info(f"Opened browser to {project_url}")
|
|
385
|
+
except Exception as e:
|
|
386
|
+
logger.warning(f"Could not open browser: {e}")
|
|
387
|
+
console.print(f"[yellow]Warning: Could not open browser automatically: {e}[/yellow]")
|
|
388
|
+
console.print(f"[yellow]Please manually visit: {project_url}[/yellow]")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@click.command()
|
|
392
|
+
@click.option('--image', default="quay.io/jupyter/datascience-notebook:latest", required=True, help='Docker image to run')
|
|
393
|
+
@click.option('--app-port', default=5000, help='Application port (default: 5000)')
|
|
394
|
+
@click.option('--jupyter-port', default=8888, help='Jupyter port (default: 8888)')
|
|
395
|
+
@click.option('--name', help='Container name')
|
|
396
|
+
@click.option('--env', multiple=True, help='Environment variables (format: KEY=VALUE)')
|
|
397
|
+
@click.option('--volume', multiple=True, help='Volume mounts (format: HOST_PATH:CONTAINER_PATH)')
|
|
398
|
+
@click.option('--detach', '-d', is_flag=True, help='Run container in background')
|
|
399
|
+
@click.option('--local', is_flag=True, help='Run locally only (no cloud connection)')
|
|
400
|
+
@click.option('--org', help='Organization slug (interactive selection if not provided)')
|
|
401
|
+
@click.option('--project', help='Project name (interactive selection if not provided)')
|
|
402
|
+
@click.option('--command', help='Custom command to run in container (overrides default)')
|
|
403
|
+
@click.option('--ensure-jupyter', is_flag=True, help='Ensure Jupyter is running (auto-start if needed)')
|
|
404
|
+
@click.pass_context
|
|
405
|
+
def run(
|
|
406
|
+
ctx: click.Context,
|
|
407
|
+
image: str,
|
|
408
|
+
app_port: int,
|
|
409
|
+
jupyter_port: int,
|
|
410
|
+
name: Optional[str],
|
|
411
|
+
env: tuple,
|
|
412
|
+
volume: tuple,
|
|
413
|
+
detach: bool,
|
|
414
|
+
local: bool,
|
|
415
|
+
org: Optional[str],
|
|
416
|
+
project: Optional[str],
|
|
417
|
+
command: Optional[str],
|
|
418
|
+
ensure_jupyter: bool
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Launch and manage local Docker containers with cloud connection."""
|
|
421
|
+
logger.info(f"Starting run command: image={image}, connect_cloud={not local}")
|
|
422
|
+
config: Config = ctx.obj['config']
|
|
423
|
+
client: AlphAIClient = ctx.obj['client']
|
|
424
|
+
docker_manager = DockerManager(console)
|
|
425
|
+
|
|
426
|
+
# Cloud connection is default behavior unless --local is specified
|
|
427
|
+
connect_cloud = not local
|
|
428
|
+
|
|
429
|
+
# Set up cleanup manager
|
|
430
|
+
cleanup_mgr = DockerCleanupManager(
|
|
431
|
+
console=console,
|
|
432
|
+
docker_manager=docker_manager,
|
|
433
|
+
client=client
|
|
434
|
+
)
|
|
435
|
+
cleanup_mgr.install_signal_handlers()
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
# Validate cloud connection requirements and get org/project
|
|
439
|
+
if connect_cloud:
|
|
440
|
+
if not config.bearer_token:
|
|
441
|
+
logger.error("Cloud connection requested but no authentication token found")
|
|
442
|
+
console.print("[red]Error: Authentication required for cloud connection. Please run 'alphai login' first.[/red]")
|
|
443
|
+
console.print("[yellow]Tip: Use --local flag to run without cloud connection[/yellow]")
|
|
444
|
+
sys.exit(1)
|
|
445
|
+
|
|
446
|
+
if not org:
|
|
447
|
+
org = _select_organization(client)
|
|
448
|
+
|
|
449
|
+
if not project:
|
|
450
|
+
project = _get_project_name()
|
|
451
|
+
|
|
452
|
+
ensure_jupyter = True
|
|
453
|
+
|
|
454
|
+
# Generate Jupyter token upfront if we'll need it
|
|
455
|
+
jupyter_token = None
|
|
456
|
+
if ensure_jupyter or connect_cloud:
|
|
457
|
+
jupyter_token = docker_manager.generate_jupyter_token()
|
|
458
|
+
console.print(f"[cyan]Generated Jupyter token: {jupyter_token[:12]}...[/cyan]")
|
|
459
|
+
logger.debug(f"Generated Jupyter token: {jupyter_token[:12]}...")
|
|
460
|
+
|
|
461
|
+
# Parse environment variables and volumes
|
|
462
|
+
env_vars = _parse_env_vars(env)
|
|
463
|
+
volumes = _parse_volumes(volume)
|
|
464
|
+
|
|
465
|
+
# Generate Jupyter startup command if needed
|
|
466
|
+
startup_command = None
|
|
467
|
+
if command:
|
|
468
|
+
startup_command = command
|
|
469
|
+
elif ensure_jupyter or connect_cloud:
|
|
470
|
+
startup_command = "tail -f /dev/null"
|
|
471
|
+
console.print("[yellow]Using keep-alive command to control Jupyter startup[/yellow]")
|
|
472
|
+
else:
|
|
473
|
+
startup_command = "tail -f /dev/null"
|
|
474
|
+
console.print("[yellow]Keeping container alive for interactive use[/yellow]")
|
|
475
|
+
|
|
476
|
+
# Start the container
|
|
477
|
+
container = docker_manager.run_container(
|
|
478
|
+
image=image,
|
|
479
|
+
name=name,
|
|
480
|
+
ports={app_port: app_port, jupyter_port: jupyter_port},
|
|
481
|
+
environment=env_vars,
|
|
482
|
+
volumes=volumes,
|
|
483
|
+
detach=True,
|
|
484
|
+
command=startup_command
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
if not container:
|
|
488
|
+
console.print("[red]Failed to start container[/red]")
|
|
489
|
+
sys.exit(1)
|
|
490
|
+
|
|
491
|
+
console.print("[green]✓ Container started[/green]")
|
|
492
|
+
console.print(f"[blue]Container ID: {container.id[:12]}[/blue]")
|
|
493
|
+
|
|
494
|
+
# Register container for cleanup
|
|
495
|
+
cleanup_mgr.set_container(container.id)
|
|
496
|
+
|
|
497
|
+
# Verify container is actually running
|
|
498
|
+
time.sleep(2)
|
|
499
|
+
|
|
500
|
+
if not docker_manager.is_container_running(container.id):
|
|
501
|
+
status = docker_manager.get_container_status(container.id)
|
|
502
|
+
console.print("[red]Container failed to start or exited immediately[/red]")
|
|
503
|
+
console.print(f"[red]Status: {status}[/red]")
|
|
504
|
+
|
|
505
|
+
logs = docker_manager.get_container_logs(container.id, tail=20)
|
|
506
|
+
if logs:
|
|
507
|
+
console.print("[yellow]Container logs:[/yellow]")
|
|
508
|
+
console.print(f"[dim]{logs}[/dim]")
|
|
509
|
+
|
|
510
|
+
sys.exit(1)
|
|
511
|
+
|
|
512
|
+
console.print("[green]✓ Container is running[/green]")
|
|
513
|
+
|
|
514
|
+
# Install and ensure Jupyter is running if requested
|
|
515
|
+
if ensure_jupyter:
|
|
516
|
+
if not _setup_jupyter_in_container(docker_manager, container.id, jupyter_port, jupyter_token):
|
|
517
|
+
console.print("[yellow]⚠ Jupyter setup had issues, continuing...[/yellow]")
|
|
518
|
+
|
|
519
|
+
if connect_cloud:
|
|
520
|
+
# Connect to cloud
|
|
521
|
+
connection_data = _connect_to_cloud(
|
|
522
|
+
client, docker_manager, container.id,
|
|
523
|
+
org, project, app_port, jupyter_port, jupyter_token
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
if not connection_data:
|
|
527
|
+
logger.error("Failed to connect to cloud, exiting")
|
|
528
|
+
console.print("[red]Failed to connect to cloud[/red]")
|
|
529
|
+
sys.exit(1)
|
|
530
|
+
|
|
531
|
+
# Store IDs for cleanup
|
|
532
|
+
cleanup_mgr.set_tunnel(connection_data.id)
|
|
533
|
+
if connection_data.project_data and hasattr(connection_data.project_data, 'id'):
|
|
534
|
+
cleanup_mgr.set_project(connection_data.project_data.id)
|
|
535
|
+
|
|
536
|
+
# Display summary
|
|
537
|
+
_display_deployment_summary(
|
|
538
|
+
container, connection_data, app_port, jupyter_port,
|
|
539
|
+
jupyter_token, config, org, project
|
|
540
|
+
)
|
|
541
|
+
else:
|
|
542
|
+
# Local mode - just display local URLs
|
|
543
|
+
console.print(f"[blue]Application: http://localhost:{app_port}[/blue]")
|
|
544
|
+
if jupyter_token:
|
|
545
|
+
console.print(f"[blue]Jupyter: http://localhost:{jupyter_port}?token={jupyter_token}[/blue]")
|
|
546
|
+
console.print(f"[dim]Jupyter Token: {jupyter_token}[/dim]")
|
|
547
|
+
else:
|
|
548
|
+
console.print(f"[blue]Jupyter: http://localhost:{jupyter_port}[/blue]")
|
|
549
|
+
console.print(f"[dim]Check container logs for Jupyter token: docker logs {container.id[:12]}[/dim]")
|
|
550
|
+
|
|
551
|
+
console.print("\n[bold yellow]Cleanup:[/bold yellow]")
|
|
552
|
+
console.print(f" • Stop container: docker stop {container.id[:12]}")
|
|
553
|
+
console.print(f" • Quick cleanup: alphai cleanup {container.id[:12]}")
|
|
554
|
+
console.print(" • Press Ctrl+C to automatically stop and remove container")
|
|
555
|
+
|
|
556
|
+
if not detach:
|
|
557
|
+
console.print(f"[dim]Container is running in background. Use 'docker logs {container.id[:12]}' to view logs.[/dim]")
|
|
558
|
+
|
|
559
|
+
# Keep the process running and wait for Ctrl+C for cleanup
|
|
560
|
+
console.print("\n[bold green]🎯 Container is running! Press Ctrl+C to cleanup all resources.[/bold green]")
|
|
561
|
+
while True:
|
|
562
|
+
time.sleep(1)
|
|
563
|
+
|
|
564
|
+
except KeyboardInterrupt:
|
|
565
|
+
pass
|
|
566
|
+
finally:
|
|
567
|
+
cleanup_mgr.cleanup()
|
|
568
|
+
cleanup_mgr.restore_signal_handlers()
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@click.command()
|
|
572
|
+
@click.argument('container_id')
|
|
573
|
+
@click.option('--force', is_flag=True, help='Skip confirmation and force cleanup')
|
|
574
|
+
@click.pass_context
|
|
575
|
+
def cleanup(
|
|
576
|
+
ctx: click.Context,
|
|
577
|
+
container_id: str,
|
|
578
|
+
force: bool
|
|
579
|
+
) -> None:
|
|
580
|
+
"""Clean up containers and projects created by alphai run.
|
|
581
|
+
|
|
582
|
+
This command performs comprehensive cleanup by:
|
|
583
|
+
1. Stopping any running services in the container
|
|
584
|
+
2. Stopping and removing the Docker container
|
|
585
|
+
3. Cleaning up the associated project
|
|
586
|
+
|
|
587
|
+
Examples:
|
|
588
|
+
alphai cleanup abc123456789 # Cleanup with confirmation
|
|
589
|
+
alphai cleanup abc123456789 --force # Skip confirmations
|
|
590
|
+
"""
|
|
591
|
+
config: Config = ctx.obj['config']
|
|
592
|
+
client: AlphAIClient = ctx.obj['client']
|
|
593
|
+
docker_manager = DockerManager(console)
|
|
594
|
+
|
|
595
|
+
# Confirmation unless force is used
|
|
596
|
+
if not force:
|
|
597
|
+
console.print(f"[yellow]Will cleanup: Container {container_id[:12]}[/yellow]")
|
|
598
|
+
if not Confirm.ask("Continue with cleanup?"):
|
|
599
|
+
console.print("[yellow]Cancelled[/yellow]")
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
console.print("[bold]🔄 Starting cleanup process...[/bold]")
|
|
603
|
+
|
|
604
|
+
# Container cleanup
|
|
605
|
+
success = docker_manager.cleanup_container_and_tunnel(
|
|
606
|
+
container_id=container_id,
|
|
607
|
+
force=force
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Summary
|
|
611
|
+
if success:
|
|
612
|
+
console.print("\n[bold green]✅ Cleanup completed successfully![/bold green]")
|
|
613
|
+
else:
|
|
614
|
+
console.print("\n[bold yellow]⚠ Cleanup completed with warnings[/bold yellow]")
|
|
615
|
+
console.print("[dim]Check the output above for details[/dim]")
|