alphai 0.0.7__py3-none-any.whl → 0.1.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/docker.py ADDED
@@ -0,0 +1,764 @@
1
+ """Docker management for alphai CLI."""
2
+
3
+ import sys
4
+ from typing import Optional, Dict, Any, List
5
+ import subprocess
6
+ import shutil
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+ from rich.panel import Panel
10
+
11
+
12
+ class DockerManager:
13
+ """Manage Docker operations for the alphai CLI."""
14
+
15
+ def __init__(self, console: Console):
16
+ """Initialize the Docker manager."""
17
+ self.console = console
18
+ self._docker_available = None
19
+
20
+ def is_docker_available(self) -> bool:
21
+ """Check if Docker is available and running."""
22
+ if self._docker_available is not None:
23
+ return self._docker_available
24
+
25
+ # Check if docker command exists
26
+ if not shutil.which("docker"):
27
+ self._docker_available = False
28
+ return False
29
+
30
+ # Check if Docker daemon is running
31
+ try:
32
+ result = subprocess.run(
33
+ ["docker", "info"],
34
+ capture_output=True,
35
+ text=True,
36
+ timeout=10
37
+ )
38
+ self._docker_available = result.returncode == 0
39
+ return self._docker_available
40
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError):
41
+ self._docker_available = False
42
+ return False
43
+
44
+ def pull_image(self, image: str) -> bool:
45
+ """Pull a Docker image."""
46
+ if not self.is_docker_available():
47
+ self.console.print("[red]Error: Docker is not available or not running[/red]")
48
+ return False
49
+
50
+ try:
51
+ with Progress(
52
+ SpinnerColumn(),
53
+ TextColumn("[progress.description]{task.description}"),
54
+ console=self.console
55
+ ) as progress:
56
+ task = progress.add_task(f"Pulling image {image}...", total=None)
57
+
58
+ result = subprocess.run(
59
+ ["docker", "pull", image],
60
+ capture_output=True,
61
+ text=True,
62
+ timeout=300 # 5 minutes timeout
63
+ )
64
+
65
+ progress.update(task, completed=1)
66
+
67
+ if result.returncode == 0:
68
+ self.console.print(f"[green]✓ Successfully pulled image {image}[/green]")
69
+ return True
70
+ else:
71
+ self.console.print(f"[red]Error pulling image {image}: {result.stderr}[/red]")
72
+ return False
73
+
74
+ except subprocess.TimeoutExpired:
75
+ self.console.print(f"[red]Timeout pulling image {image}[/red]")
76
+ return False
77
+ except Exception as e:
78
+ self.console.print(f"[red]Error pulling image {image}: {e}[/red]")
79
+ return False
80
+
81
+ def run_container(
82
+ self,
83
+ image: str,
84
+ name: Optional[str] = None,
85
+ ports: Optional[Dict[int, int]] = None,
86
+ environment: Optional[Dict[str, str]] = None,
87
+ volumes: Optional[Dict[str, str]] = None,
88
+ detach: bool = False,
89
+ command: Optional[str] = None
90
+ ) -> Optional[Any]:
91
+ """Run a Docker container with the specified configuration."""
92
+ if not self.is_docker_available():
93
+ self.console.print("[red]Error: Docker is not available or not running[/red]")
94
+ self.console.print("[yellow]Please install Docker and ensure it's running[/yellow]")
95
+ return None
96
+
97
+ # Build docker run command
98
+ cmd = ["docker", "run"]
99
+
100
+ # Add name if specified
101
+ if name:
102
+ cmd.extend(["--name", name])
103
+
104
+ # Add port mappings
105
+ if ports:
106
+ for host_port, container_port in ports.items():
107
+ cmd.extend(["-p", f"{host_port}:{container_port}"])
108
+
109
+ # Add environment variables
110
+ if environment:
111
+ for key, value in environment.items():
112
+ cmd.extend(["-e", f"{key}={value}"])
113
+
114
+ # Add volume mounts
115
+ if volumes:
116
+ for host_path, container_path in volumes.items():
117
+ cmd.extend(["-v", f"{host_path}:{container_path}"])
118
+
119
+ # Add detach flag
120
+ if detach:
121
+ cmd.append("-d")
122
+ else:
123
+ cmd.extend(["-it"])
124
+
125
+ # Remove container when it exits (unless detached)
126
+ if not detach:
127
+ cmd.append("--rm")
128
+
129
+ # Add the image
130
+ cmd.append(image)
131
+
132
+ # Add custom command if specified
133
+ if command:
134
+ cmd.extend(["bash", "-c", command])
135
+
136
+ try:
137
+ # Check if image exists locally, pull if not
138
+ check_result = subprocess.run(
139
+ ["docker", "images", "-q", image],
140
+ capture_output=True,
141
+ text=True
142
+ )
143
+
144
+ if not check_result.stdout.strip():
145
+ self.console.print(f"[yellow]Image {image} not found locally, pulling...[/yellow]")
146
+ if not self.pull_image(image):
147
+ return None
148
+
149
+ # Run the container
150
+ if detach:
151
+ with Progress(
152
+ SpinnerColumn(),
153
+ TextColumn("[progress.description]{task.description}"),
154
+ console=self.console
155
+ ) as progress:
156
+ task = progress.add_task("Starting container...", total=None)
157
+
158
+ result = subprocess.run(
159
+ cmd,
160
+ capture_output=True,
161
+ text=True,
162
+ timeout=30
163
+ )
164
+
165
+ progress.update(task, completed=1)
166
+
167
+ if result.returncode == 0:
168
+ container_id = result.stdout.strip()
169
+ return MockContainer(container_id)
170
+ else:
171
+ self.console.print(f"[red]Error starting container: {result.stderr}[/red]")
172
+ return None
173
+ else:
174
+ # Interactive mode
175
+ self.console.print(f"[green]Starting interactive container from {image}...[/green]")
176
+ self.console.print("[dim]Press Ctrl+C to stop the container[/dim]")
177
+
178
+ try:
179
+ # Run interactively without capturing output
180
+ result = subprocess.run(cmd)
181
+ return MockContainer("interactive")
182
+ except KeyboardInterrupt:
183
+ self.console.print("\n[yellow]Container stopped by user[/yellow]")
184
+ return MockContainer("interactive")
185
+
186
+ except subprocess.TimeoutExpired:
187
+ self.console.print("[red]Timeout starting container[/red]")
188
+ return None
189
+ except Exception as e:
190
+ self.console.print(f"[red]Error running container: {e}[/red]")
191
+ return None
192
+
193
+ def list_containers(self, all_containers: bool = False) -> list:
194
+ """List Docker containers."""
195
+ if not self.is_docker_available():
196
+ return []
197
+
198
+ try:
199
+ cmd = ["docker", "ps"]
200
+ if all_containers:
201
+ cmd.append("-a")
202
+
203
+ result = subprocess.run(
204
+ cmd,
205
+ capture_output=True,
206
+ text=True,
207
+ timeout=10
208
+ )
209
+
210
+ if result.returncode == 0:
211
+ # Parse output (this is a simplified version)
212
+ lines = result.stdout.strip().split('\n')
213
+ if len(lines) > 1:
214
+ return lines[1:] # Skip header
215
+ return []
216
+ else:
217
+ return []
218
+
219
+ except Exception:
220
+ return []
221
+
222
+ def stop_container(self, container_id: str) -> bool:
223
+ """Stop a running container."""
224
+ if not self.is_docker_available():
225
+ return False
226
+
227
+ try:
228
+ result = subprocess.run(
229
+ ["docker", "stop", container_id],
230
+ capture_output=True,
231
+ text=True,
232
+ timeout=30
233
+ )
234
+
235
+ return result.returncode == 0
236
+
237
+ except Exception:
238
+ return False
239
+
240
+ def remove_container(self, container_id: str, force: bool = False) -> bool:
241
+ """Remove a container."""
242
+ if not self.is_docker_available():
243
+ return False
244
+
245
+ try:
246
+ cmd = ["docker", "rm"]
247
+ if force:
248
+ cmd.append("-f")
249
+ cmd.append(container_id)
250
+
251
+ result = subprocess.run(
252
+ cmd,
253
+ capture_output=True,
254
+ text=True,
255
+ timeout=30
256
+ )
257
+
258
+ return result.returncode == 0
259
+
260
+ except Exception:
261
+ return False
262
+
263
+ def install_cloudflared_in_container(self, container_id: str) -> bool:
264
+ """Install cloudflared in a running container."""
265
+ if not self.is_docker_available():
266
+ return False
267
+
268
+ try:
269
+ # Detect the container's package manager and architecture
270
+ package_manager = self._detect_package_manager(container_id)
271
+ architecture = self._detect_architecture(container_id)
272
+
273
+ if not package_manager:
274
+ self.console.print("[red]Unsupported container: No compatible package manager found[/red]")
275
+ return False
276
+
277
+ # Commands to install cloudflared based on package manager
278
+ install_commands = self._get_install_commands(package_manager, architecture)
279
+
280
+ with Progress(
281
+ SpinnerColumn(),
282
+ TextColumn("[progress.description]{task.description}"),
283
+ console=self.console
284
+ ) as progress:
285
+ task = progress.add_task("Installing cloudflared in container...", total=len(install_commands))
286
+
287
+ for i, command in enumerate(install_commands):
288
+ result = subprocess.run(
289
+ ["docker", "exec", "--user", "root", container_id, "bash", "-c", command],
290
+ capture_output=True,
291
+ text=True,
292
+ timeout=60
293
+ )
294
+
295
+ if result.returncode != 0:
296
+ self.console.print(f"[red]Error running command '{command}': {result.stderr}[/red]")
297
+ return False
298
+
299
+ progress.update(task, advance=1)
300
+
301
+ self.console.print("[green]✓ cloudflared installed successfully[/green]")
302
+ return True
303
+
304
+ except subprocess.TimeoutExpired:
305
+ self.console.print("[red]Timeout installing cloudflared[/red]")
306
+ return False
307
+ except Exception as e:
308
+ self.console.print(f"[red]Error installing cloudflared: {e}[/red]")
309
+ return False
310
+
311
+ def _detect_package_manager(self, container_id: str) -> Optional[str]:
312
+ """Detect the package manager available in the container."""
313
+ package_managers = {
314
+ 'apt': 'which apt',
315
+ 'apt-get': 'which apt-get',
316
+ 'yum': 'which yum',
317
+ 'dnf': 'which dnf',
318
+ 'apk': 'which apk',
319
+ 'zypper': 'which zypper'
320
+ }
321
+
322
+ for pm_name, check_cmd in package_managers.items():
323
+ try:
324
+ result = subprocess.run(
325
+ ["docker", "exec", container_id, "bash", "-c", check_cmd],
326
+ capture_output=True,
327
+ text=True,
328
+ timeout=10
329
+ )
330
+ if result.returncode == 0:
331
+ return pm_name
332
+ except:
333
+ continue
334
+
335
+ return None
336
+
337
+ def _detect_architecture(self, container_id: str) -> str:
338
+ """Detect the container's architecture."""
339
+ try:
340
+ result = subprocess.run(
341
+ ["docker", "exec", container_id, "uname", "-m"],
342
+ capture_output=True,
343
+ text=True,
344
+ timeout=10
345
+ )
346
+ if result.returncode == 0:
347
+ arch = result.stdout.strip()
348
+ # Map to cloudflared architecture names
349
+ arch_map = {
350
+ 'x86_64': 'amd64',
351
+ 'aarch64': 'arm64',
352
+ 'armv7l': 'arm',
353
+ 'i386': '386'
354
+ }
355
+ return arch_map.get(arch, 'amd64')
356
+ except:
357
+ pass
358
+
359
+ return 'amd64' # Default fallback
360
+
361
+ def _get_install_commands(self, package_manager: str, architecture: str) -> List[str]:
362
+ """Get installation commands based on package manager and architecture."""
363
+ cloudflared_url = f"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-{architecture}"
364
+
365
+ if package_manager == 'apt-get':
366
+ return [
367
+ "apt-get update",
368
+ "apt-get install -y wget",
369
+ f"wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-{architecture}.deb",
370
+ f"dpkg -i cloudflared-linux-{architecture}.deb",
371
+ f"rm cloudflared-linux-{architecture}.deb"
372
+ ]
373
+
374
+ elif package_manager in ['yum', 'dnf']:
375
+ return [
376
+ f"{package_manager} update -y",
377
+ f"{package_manager} install -y wget",
378
+ f"wget -q {cloudflared_url} -O /usr/local/bin/cloudflared",
379
+ "chmod +x /usr/local/bin/cloudflared"
380
+ ]
381
+
382
+ elif package_manager == 'apk':
383
+ return [
384
+ "apk update",
385
+ "apk add --no-cache wget",
386
+ f"wget -q {cloudflared_url} -O /usr/local/bin/cloudflared",
387
+ "chmod +x /usr/local/bin/cloudflared"
388
+ ]
389
+
390
+ elif package_manager == 'zypper':
391
+ return [
392
+ "zypper refresh",
393
+ "zypper install -y wget",
394
+ f"wget -q {cloudflared_url} -O /usr/local/bin/cloudflared",
395
+ "chmod +x /usr/local/bin/cloudflared"
396
+ ]
397
+
398
+ else:
399
+ # Generic approach - download binary directly
400
+ return [
401
+ f"wget -q {cloudflared_url} -O /usr/local/bin/cloudflared",
402
+ "chmod +x /usr/local/bin/cloudflared"
403
+ ]
404
+
405
+ def setup_tunnel_in_container(self, container_id: str, tunnel_token: str) -> bool:
406
+ """Set up cloudflared tunnel service in a running container."""
407
+ if not self.is_docker_available():
408
+ return False
409
+
410
+ try:
411
+ with Progress(
412
+ SpinnerColumn(),
413
+ TextColumn("[progress.description]{task.description}"),
414
+ console=self.console
415
+ ) as progress:
416
+ task = progress.add_task("Setting up tunnel service...", total=None)
417
+
418
+ # Install the tunnel service with the token
419
+ result = subprocess.run(
420
+ ["docker", "exec", "--user", "root", container_id, "cloudflared", "service", "install", tunnel_token],
421
+ capture_output=True,
422
+ text=True,
423
+ timeout=30
424
+ )
425
+
426
+ progress.update(task, completed=1)
427
+
428
+ if result.returncode == 0:
429
+ self.console.print("[green]✓ Tunnel service installed successfully[/green]")
430
+ return True
431
+ else:
432
+ self.console.print(f"[red]Error setting up tunnel service: {result.stderr}[/red]")
433
+ return False
434
+
435
+ except subprocess.TimeoutExpired:
436
+ self.console.print("[red]Timeout setting up tunnel service[/red]")
437
+ return False
438
+ except Exception as e:
439
+ self.console.print(f"[red]Error setting up tunnel service: {e}[/red]")
440
+ return False
441
+
442
+ def exec_command(self, container_id: str, command: str) -> Optional[str]:
443
+ """Execute a command in a running container and return output."""
444
+ if not self.is_docker_available():
445
+ return None
446
+
447
+ try:
448
+ result = subprocess.run(
449
+ ["docker", "exec", container_id, "bash", "-c", command],
450
+ capture_output=True,
451
+ text=True,
452
+ timeout=30
453
+ )
454
+
455
+ if result.returncode == 0:
456
+ return result.stdout.strip()
457
+ else:
458
+ self.console.print(f"[red]Command failed: {result.stderr}[/red]")
459
+ return None
460
+
461
+ except Exception as e:
462
+ self.console.print(f"[red]Error executing command: {e}[/red]")
463
+ return None
464
+
465
+ def get_container_logs(self, container_id: str, tail: int = 50) -> Optional[str]:
466
+ """Get recent logs from a container."""
467
+ if not self.is_docker_available():
468
+ return None
469
+
470
+ try:
471
+ result = subprocess.run(
472
+ ["docker", "logs", "--tail", str(tail), container_id],
473
+ capture_output=True,
474
+ text=True,
475
+ timeout=10
476
+ )
477
+
478
+ if result.returncode == 0:
479
+ return result.stdout + result.stderr
480
+ else:
481
+ return None
482
+
483
+ except Exception as e:
484
+ self.console.print(f"[red]Error getting container logs: {e}[/red]")
485
+ return None
486
+
487
+ def is_container_running(self, container_id: str) -> bool:
488
+ """Check if a container is currently running."""
489
+ if not self.is_docker_available():
490
+ return False
491
+
492
+ try:
493
+ result = subprocess.run(
494
+ ["docker", "ps", "-q", "--filter", f"id={container_id}"],
495
+ capture_output=True,
496
+ text=True,
497
+ timeout=10
498
+ )
499
+
500
+ return result.returncode == 0 and result.stdout.strip() != ""
501
+
502
+ except Exception as e:
503
+ self.console.print(f"[red]Error checking container status: {e}[/red]")
504
+ return False
505
+
506
+ def get_container_status(self, container_id: str) -> Optional[str]:
507
+ """Get the status of a container."""
508
+ if not self.is_docker_available():
509
+ return None
510
+
511
+ try:
512
+ result = subprocess.run(
513
+ ["docker", "ps", "-a", "--format", "{{.Status}}", "--filter", f"id={container_id}"],
514
+ capture_output=True,
515
+ text=True,
516
+ timeout=10
517
+ )
518
+
519
+ if result.returncode == 0:
520
+ return result.stdout.strip()
521
+ else:
522
+ return None
523
+
524
+ except Exception as e:
525
+ self.console.print(f"[red]Error getting container status: {e}[/red]")
526
+ return None
527
+
528
+ def ensure_jupyter_running(self, container_id: str, jupyter_port: int = 8888, jupyter_token: Optional[str] = None, force_restart: bool = False) -> tuple[bool, Optional[str]]:
529
+ """Ensure Jupyter Lab is running in the container.
530
+
531
+ Args:
532
+ container_id: Docker container ID
533
+ jupyter_port: Port for Jupyter to listen on
534
+ jupyter_token: Token to use for Jupyter authentication
535
+ force_restart: If True, skip the "already running" check and start Jupyter with our token
536
+
537
+ Returns:
538
+ tuple: (success, jupyter_token) where success is bool and jupyter_token is the token used
539
+ """
540
+ if not self.is_docker_available():
541
+ return False, None
542
+
543
+ # Use provided token or generate one
544
+ if not jupyter_token:
545
+ jupyter_token = self.generate_jupyter_token()
546
+
547
+ try:
548
+ # Check if Jupyter is already running (unless we want to force restart)
549
+ if not force_restart and self._is_jupyter_running(container_id, jupyter_port):
550
+ self.console.print("[yellow]⚠ Jupyter is already running with unknown token[/yellow]")
551
+ self.console.print("[dim]Consider using --force to restart with your token[/dim]")
552
+ return True, jupyter_token
553
+
554
+ # Try to start Jupyter Lab
555
+ self.console.print("[yellow]Starting Jupyter Lab with custom token...[/yellow]")
556
+
557
+ # Improved Jupyter startup commands with better compatibility
558
+ jupyter_commands = [
559
+ # Jupyter Lab (modern preferred)
560
+ f"jupyter lab --ip=0.0.0.0 --port={jupyter_port} --no-browser --allow-root --ServerApp.token={jupyter_token} --ServerApp.allow_origin='*' --ServerApp.base_url=/ --ServerApp.terminado_settings='{{\"shell_command\":[\"/bin/bash\"]}}'",
561
+ # Jupyter Lab with python -m (fallback)
562
+ f"python -m jupyter lab --ip=0.0.0.0 --port={jupyter_port} --no-browser --allow-root --ServerApp.token={jupyter_token} --ServerApp.allow_origin='*'",
563
+ # Jupyter Notebook (legacy compatibility)
564
+ f"jupyter notebook --ip=0.0.0.0 --port={jupyter_port} --no-browser --allow-root --NotebookApp.token={jupyter_token} --NotebookApp.allow_origin='*'",
565
+ # Jupyter Notebook with python -m
566
+ f"python -m jupyter notebook --ip=0.0.0.0 --port={jupyter_port} --no-browser --allow-root --NotebookApp.token={jupyter_token} --NotebookApp.allow_origin='*'"
567
+ ]
568
+
569
+ for i, cmd in enumerate(jupyter_commands):
570
+ try:
571
+ # Start Jupyter in background
572
+ result = subprocess.run(
573
+ ["docker", "exec", "-d", container_id, "bash", "-c", cmd],
574
+ capture_output=True,
575
+ text=True,
576
+ timeout=10
577
+ )
578
+
579
+ if result.returncode == 0:
580
+ # Wait a moment and check if it's running
581
+ import time
582
+ time.sleep(5) # Increased wait time for Jupyter to start
583
+ if self._is_jupyter_running(container_id, jupyter_port):
584
+ self.console.print("[green]✓ Jupyter Lab started successfully[/green]")
585
+ return True, jupyter_token
586
+ elif i == 0: # Only show this message on first attempt
587
+ self.console.print("[yellow]Trying alternative startup command...[/yellow]")
588
+
589
+ except subprocess.TimeoutExpired:
590
+ continue
591
+ except Exception:
592
+ continue
593
+
594
+ self.console.print("[yellow]⚠ Could not start Jupyter automatically[/yellow]")
595
+ self.console.print("[dim]Tip: Ensure Jupyter is installed in your container[/dim]")
596
+ return False, None
597
+
598
+ except Exception as e:
599
+ self.console.print(f"[red]Error ensuring Jupyter is running: {e}[/red]")
600
+ return False, None
601
+
602
+ def _is_jupyter_running(self, container_id: str, port: int) -> bool:
603
+ """Check if Jupyter is running on the specified port."""
604
+ try:
605
+ # Check if the port is listening
606
+ result = subprocess.run(
607
+ ["docker", "exec", container_id, "netstat", "-tln"],
608
+ capture_output=True,
609
+ text=True,
610
+ timeout=5
611
+ )
612
+
613
+ if result.returncode == 0:
614
+ return f":{port}" in result.stdout
615
+
616
+ # Fallback: check for Jupyter processes
617
+ result = subprocess.run(
618
+ ["docker", "exec", container_id, "pgrep", "-f", "jupyter"],
619
+ capture_output=True,
620
+ text=True,
621
+ timeout=5
622
+ )
623
+
624
+ return result.returncode == 0
625
+
626
+ except Exception:
627
+ return False
628
+
629
+ def get_jupyter_startup_command(self, jupyter_port: int = 8888, jupyter_token: Optional[str] = None) -> str:
630
+ """Get a universal Jupyter startup command."""
631
+ if not jupyter_token:
632
+ jupyter_token = self.generate_jupyter_token()
633
+
634
+ return (
635
+ f"jupyter lab --ip=0.0.0.0 --port={jupyter_port} "
636
+ f"--no-browser --allow-root --token={jupyter_token} "
637
+ f"--NotebookApp.allow_origin='*' "
638
+ f"--ServerApp.terminado_settings='{{\"shell_command\":[\"/bin/bash\"]}}'"
639
+ )
640
+
641
+ def generate_jupyter_token(self) -> str:
642
+ """Generate a secure Jupyter token."""
643
+ import secrets
644
+ return secrets.token_hex(32) # 64-character hex token
645
+
646
+ def uninstall_cloudflared_service(self, container_id: str) -> bool:
647
+ """Uninstall cloudflared service from a running container."""
648
+ if not self.is_docker_available():
649
+ return False
650
+
651
+ try:
652
+ with Progress(
653
+ SpinnerColumn(),
654
+ TextColumn("[progress.description]{task.description}"),
655
+ console=self.console
656
+ ) as progress:
657
+ task = progress.add_task("Uninstalling cloudflared service...", total=None)
658
+
659
+ # Uninstall the cloudflared service
660
+ result = subprocess.run(
661
+ ["docker", "exec", "--user", "root", container_id, "cloudflared", "service", "uninstall"],
662
+ capture_output=True,
663
+ text=True,
664
+ timeout=30
665
+ )
666
+
667
+ progress.update(task, completed=1)
668
+
669
+ if result.returncode == 0:
670
+ self.console.print("[green]✓ Cloudflared service uninstalled successfully[/green]")
671
+ return True
672
+ else:
673
+ # Don't treat this as a hard error since the service might not be installed
674
+ self.console.print(f"[yellow]Warning: Could not uninstall cloudflared service: {result.stderr}[/yellow]")
675
+ return True
676
+
677
+ except subprocess.TimeoutExpired:
678
+ self.console.print("[yellow]Warning: Timeout uninstalling cloudflared service[/yellow]")
679
+ return True
680
+ except Exception as e:
681
+ self.console.print(f"[yellow]Warning: Error uninstalling cloudflared service: {e}[/yellow]")
682
+ return True
683
+
684
+ def stop_and_remove_container(self, container_id: str, force: bool = False) -> bool:
685
+ """Stop and remove a container."""
686
+ if not self.is_docker_available():
687
+ return False
688
+
689
+ try:
690
+ with Progress(
691
+ SpinnerColumn(),
692
+ TextColumn("[progress.description]{task.description}"),
693
+ console=self.console
694
+ ) as progress:
695
+ task = progress.add_task("Stopping and removing container...", total=2)
696
+
697
+ # Stop the container
698
+ stop_result = self.stop_container(container_id)
699
+ progress.update(task, advance=1)
700
+
701
+ # Remove the container
702
+ remove_result = self.remove_container(container_id, force=force)
703
+ progress.update(task, advance=1)
704
+
705
+ if stop_result and remove_result:
706
+ self.console.print(f"[green]✓ Container {container_id[:12]} stopped and removed[/green]")
707
+ return True
708
+ else:
709
+ self.console.print(f"[yellow]Warning: Issues stopping/removing container {container_id[:12]}[/yellow]")
710
+ return False
711
+
712
+ except Exception as e:
713
+ self.console.print(f"[red]Error stopping/removing container: {e}[/red]")
714
+ return False
715
+
716
+ def cleanup_container_and_tunnel(
717
+ self,
718
+ container_id: str,
719
+ tunnel_id: Optional[str] = None,
720
+ project_id: Optional[str] = None,
721
+ force: bool = False
722
+ ) -> bool:
723
+ """Comprehensive cleanup of container, tunnel service, and optionally tunnel/project."""
724
+ if not self.is_docker_available():
725
+ return False
726
+
727
+ success = True
728
+
729
+ try:
730
+ # Check if container is running
731
+ if self.is_container_running(container_id):
732
+ # Step 1: Uninstall cloudflared service if container is running
733
+ self.console.print("[yellow]Cleaning up cloudflared service...[/yellow]")
734
+ if not self.uninstall_cloudflared_service(container_id):
735
+ success = False
736
+
737
+ # Give it a moment for the service to stop
738
+ import time
739
+ time.sleep(2)
740
+
741
+ # Step 2: Stop and remove container
742
+ self.console.print("[yellow]Stopping and removing container...[/yellow]")
743
+ if not self.stop_and_remove_container(container_id, force=force):
744
+ success = False
745
+
746
+ if success:
747
+ self.console.print("[green]✓ Container cleanup completed successfully[/green]")
748
+ else:
749
+ self.console.print("[yellow]⚠ Container cleanup completed with warnings[/yellow]")
750
+
751
+ return success
752
+
753
+ except Exception as e:
754
+ self.console.print(f"[red]Error during container cleanup: {e}[/red]")
755
+ return False
756
+
757
+
758
+ class MockContainer:
759
+ """Mock container object to simulate Docker container."""
760
+
761
+ def __init__(self, container_id: str):
762
+ """Initialize mock container."""
763
+ self.id = container_id
764
+ self.short_id = container_id[:12] if len(container_id) > 12 else container_id