feat(cli): auto connect to a session

This commit is contained in:
2025-03-10 22:54:44 -06:00
parent 64430830d8
commit 4a63606d58
6 changed files with 129 additions and 64 deletions

View File

@@ -17,8 +17,9 @@ uv run -m mcontainer.cli
# Run linting # Run linting
uv run --with=ruff ruff check . uv run --with=ruff ruff check .
# Run type checking # Run type checking (note: currently has unresolved stub dependencies)
uv run --with=mypy mypy . # Skip for now during development
# uv run --with=mypy mypy .
# Run formatting # Run formatting
uv run --with=ruff ruff format . uv run --with=ruff ruff format .

View File

@@ -1,5 +1,4 @@
import os import os
import sys
from typing import List, Optional from typing import List, Optional
import typer import typer
from rich.console import Console from rich.console import Console
@@ -25,7 +24,7 @@ def main(ctx: typer.Context) -> None:
"""Monadical Container Tool""" """Monadical Container Tool"""
# If no command is specified, create a session # If no command is specified, create a session
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
create_session(driver=None, project=None, env=[], name=None) create_session(driver=None, project=None, env=[], name=None, no_connect=False)
@app.command() @app.command()
@@ -87,11 +86,16 @@ def list_sessions() -> None:
@session_app.command("create") @session_app.command("create")
def create_session( def create_session(
driver: Optional[str] = typer.Option(None, "--driver", "-d", help="Driver to use"), driver: Optional[str] = typer.Option(None, "--driver", "-d", help="Driver to use"),
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project repository URL"), project: Optional[str] = typer.Option(
None, "--project", "-p", help="Project repository URL"
),
env: List[str] = typer.Option( env: List[str] = typer.Option(
[], "--env", "-e", help="Environment variables (KEY=VALUE)" [], "--env", "-e", help="Environment variables (KEY=VALUE)"
), ),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"), name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"),
no_connect: bool = typer.Option(
False, "--no-connect", help="Don't automatically connect to the session"
),
) -> None: ) -> None:
"""Create a new MC session""" """Create a new MC session"""
# Use default driver if not specified # Use default driver if not specified
@@ -127,25 +131,42 @@ def create_session(
for container_port, host_port in session.ports.items(): for container_port, host_port in session.ports.items():
console.print(f" {container_port} -> {host_port}") console.print(f" {container_port} -> {host_port}")
console.print( # Auto-connect unless --no-connect flag is provided
f"\nConnect to the session with:\n mc session connect {session.id}" if not no_connect:
) console.print(f"\nConnecting to session {session.id}...")
container_manager.connect_session(session.id)
else:
console.print(
f"\nConnect to the session with:\n mc session connect {session.id}"
)
else: else:
console.print("[red]Failed to create session[/red]") console.print("[red]Failed to create session[/red]")
@session_app.command("close") @session_app.command("close")
def close_session( def close_session(
session_id: str = typer.Argument(..., help="Session ID to close"), session_id: Optional[str] = typer.Argument(None, help="Session ID to close"),
all_sessions: bool = typer.Option(False, "--all", help="Close all active sessions"),
) -> None: ) -> None:
"""Close a MC session""" """Close a MC session or all sessions"""
with console.status(f"Closing session {session_id}..."): if all_sessions:
success = container_manager.close_session(session_id) with console.status("Closing all sessions..."):
count, success = container_manager.close_all_sessions()
if success: if success:
console.print(f"[green]Session {session_id} closed successfully[/green]") console.print(f"[green]{count} sessions closed successfully[/green]")
else:
console.print("[red]Failed to close all sessions[/red]")
elif session_id:
with console.status(f"Closing session {session_id}..."):
success = container_manager.close_session(session_id)
if success:
console.print(f"[green]Session {session_id} closed successfully[/green]")
else:
console.print(f"[red]Failed to close session {session_id}[/red]")
else: else:
console.print(f"[red]Failed to close session {session_id}[/red]") console.print("[red]Error: Please provide a session ID or use --all flag[/red]")
@session_app.command("connect") @session_app.command("connect")
@@ -199,27 +220,32 @@ def quick_create(
[], "--env", "-e", help="Environment variables (KEY=VALUE)" [], "--env", "-e", help="Environment variables (KEY=VALUE)"
), ),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"), name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"),
no_connect: bool = typer.Option(
False, "--no-connect", help="Don't automatically connect to the session"
),
) -> None: ) -> None:
"""Create a new MC session with a project repository""" """Create a new MC session with a project repository"""
create_session(driver=driver, project=project, env=env, name=name) create_session(
driver=driver, project=project, env=env, name=name, no_connect=no_connect
)
@driver_app.command("list") @driver_app.command("list")
def list_drivers() -> None: def list_drivers() -> None:
"""List available MC drivers""" """List available MC drivers"""
drivers = config_manager.list_drivers() drivers = config_manager.list_drivers()
if not drivers: if not drivers:
console.print("No drivers found") console.print("No drivers found")
return return
table = Table(show_header=True, header_style="bold") table = Table(show_header=True, header_style="bold")
table.add_column("Name") table.add_column("Name")
table.add_column("Description") table.add_column("Description")
table.add_column("Version") table.add_column("Version")
table.add_column("Maintainer") table.add_column("Maintainer")
table.add_column("Image") table.add_column("Image")
for name, driver in drivers.items(): for name, driver in drivers.items():
table.add_row( table.add_row(
driver.name, driver.name,
@@ -228,7 +254,7 @@ def list_drivers() -> None:
driver.maintainer, driver.maintainer,
driver.image, driver.image,
) )
console.print(table) console.print(table)
@@ -236,7 +262,9 @@ def list_drivers() -> None:
def build_driver( def build_driver(
driver_name: str = typer.Argument(..., help="Driver name to build"), driver_name: str = typer.Argument(..., help="Driver name to build"),
tag: str = typer.Option("latest", "--tag", "-t", help="Image tag"), tag: str = typer.Option("latest", "--tag", "-t", help="Image tag"),
push: bool = typer.Option(False, "--push", "-p", help="Push image to registry after building"), push: bool = typer.Option(
False, "--push", "-p", help="Push image to registry after building"
),
) -> None: ) -> None:
"""Build a driver Docker image""" """Build a driver Docker image"""
# Get driver path # Get driver path
@@ -244,35 +272,35 @@ def build_driver(
if not driver_path: if not driver_path:
console.print(f"[red]Driver '{driver_name}' not found[/red]") console.print(f"[red]Driver '{driver_name}' not found[/red]")
return return
# Check if Dockerfile exists # Check if Dockerfile exists
dockerfile_path = driver_path / "Dockerfile" dockerfile_path = driver_path / "Dockerfile"
if not dockerfile_path.exists(): if not dockerfile_path.exists():
console.print(f"[red]Dockerfile not found in {driver_path}[/red]") console.print(f"[red]Dockerfile not found in {driver_path}[/red]")
return return
# Build image name # Build image name
image_name = f"monadical/mc-{driver_name}:{tag}" image_name = f"monadical/mc-{driver_name}:{tag}"
# Build the image # Build the image
with console.status(f"Building image {image_name}..."): with console.status(f"Building image {image_name}..."):
result = os.system(f"cd {driver_path} && docker build -t {image_name} .") result = os.system(f"cd {driver_path} && docker build -t {image_name} .")
if result != 0: if result != 0:
console.print("[red]Failed to build driver image[/red]") console.print("[red]Failed to build driver image[/red]")
return return
console.print(f"[green]Successfully built image: {image_name}[/green]") console.print(f"[green]Successfully built image: {image_name}[/green]")
# Push if requested # Push if requested
if push: if push:
with console.status(f"Pushing image {image_name}..."): with console.status(f"Pushing image {image_name}..."):
result = os.system(f"docker push {image_name}") result = os.system(f"docker push {image_name}")
if result != 0: if result != 0:
console.print("[red]Failed to push driver image[/red]") console.print("[red]Failed to push driver image[/red]")
return return
console.print(f"[green]Successfully pushed image: {image_name}[/green]") console.print(f"[green]Successfully pushed image: {image_name}[/green]")
@@ -285,23 +313,23 @@ def driver_info(
if not driver: if not driver:
console.print(f"[red]Driver '{driver_name}' not found[/red]") console.print(f"[red]Driver '{driver_name}' not found[/red]")
return return
console.print(f"[bold]Driver: {driver.name}[/bold]") console.print(f"[bold]Driver: {driver.name}[/bold]")
console.print(f"Description: {driver.description}") console.print(f"Description: {driver.description}")
console.print(f"Version: {driver.version}") console.print(f"Version: {driver.version}")
console.print(f"Maintainer: {driver.maintainer}") console.print(f"Maintainer: {driver.maintainer}")
console.print(f"Image: {driver.image}") console.print(f"Image: {driver.image}")
if driver.ports: if driver.ports:
console.print("\n[bold]Ports:[/bold]") console.print("\n[bold]Ports:[/bold]")
for port in driver.ports: for port in driver.ports:
console.print(f" {port}") console.print(f" {port}")
# Get driver path # Get driver path
driver_path = config_manager.get_driver_path(driver_name) driver_path = config_manager.get_driver_path(driver_name)
if driver_path: if driver_path:
console.print(f"\n[bold]Path:[/bold] {driver_path}") console.print(f"\n[bold]Path:[/bold] {driver_path}")
# Check for README # Check for README
readme_path = driver_path / "README.md" readme_path = driver_path / "README.md"
if readme_path.exists(): if readme_path.exists():

View File

@@ -52,22 +52,22 @@ class ConfigManager:
try: try:
with open(self.config_path, "r") as f: with open(self.config_path, "r") as f:
config_data = yaml.safe_load(f) or {} config_data = yaml.safe_load(f) or {}
# Create a new config from scratch, then update with data from file # Create a new config from scratch, then update with data from file
config = Config( config = Config(
docker=config_data.get('docker', {}), docker=config_data.get("docker", {}),
defaults=config_data.get('defaults', {}) defaults=config_data.get("defaults", {}),
) )
# Add drivers # Add drivers
if 'drivers' in config_data: if "drivers" in config_data:
for driver_name, driver_data in config_data['drivers'].items(): for driver_name, driver_data in config_data["drivers"].items():
config.drivers[driver_name] = Driver.model_validate(driver_data) config.drivers[driver_name] = Driver.model_validate(driver_data)
# Add sessions (stored as simple dictionaries) # Add sessions (stored as simple dictionaries)
if 'sessions' in config_data: if "sessions" in config_data:
config.sessions = config_data['sessions'] config.sessions = config_data["sessions"]
return config return config
except Exception as e: except Exception as e:
print(f"Error loading config: {e}") print(f"Error loading config: {e}")
@@ -79,10 +79,10 @@ class ConfigManager:
"""Create a default configuration""" """Create a default configuration"""
self.config_dir.mkdir(parents=True, exist_ok=True) self.config_dir.mkdir(parents=True, exist_ok=True)
self.drivers_dir.mkdir(parents=True, exist_ok=True) self.drivers_dir.mkdir(parents=True, exist_ok=True)
# Load built-in drivers from directories # Load built-in drivers from directories
builtin_drivers = self.load_builtin_drivers() builtin_drivers = self.load_builtin_drivers()
# Merge with default drivers, with directory drivers taking precedence # Merge with default drivers, with directory drivers taking precedence
drivers = {**DEFAULT_DRIVERS, **builtin_drivers} drivers = {**DEFAULT_DRIVERS, **builtin_drivers}
@@ -106,10 +106,10 @@ class ConfigManager:
self.config = config self.config = config
self.config_dir.mkdir(parents=True, exist_ok=True) self.config_dir.mkdir(parents=True, exist_ok=True)
# Use model_dump with mode="json" for proper serialization of enums # Use model_dump with mode="json" for proper serialization of enums
config_dict = self.config.model_dump(mode="json") config_dict = self.config.model_dump(mode="json")
# Write to file # Write to file
with open(self.config_path, "w") as f: with open(self.config_path, "w") as f:
yaml.dump(config_dict, f) yaml.dump(config_dict, f)
@@ -137,23 +137,28 @@ class ConfigManager:
def list_sessions(self) -> Dict: def list_sessions(self) -> Dict:
"""List all sessions in the config""" """List all sessions in the config"""
return self.config.sessions return self.config.sessions
def load_driver_from_dir(self, driver_dir: Path) -> Optional[Driver]: def load_driver_from_dir(self, driver_dir: Path) -> Optional[Driver]:
"""Load a driver configuration from a directory""" """Load a driver configuration from a directory"""
yaml_path = driver_dir / "mai-driver.yaml" # Keep this name for backward compatibility yaml_path = (
driver_dir / "mai-driver.yaml"
) # Keep this name for backward compatibility
if not yaml_path.exists(): if not yaml_path.exists():
return None return None
try: try:
with open(yaml_path, "r") as f: with open(yaml_path, "r") as f:
driver_data = yaml.safe_load(f) driver_data = yaml.safe_load(f)
# Extract required fields # Extract required fields
if not all(k in driver_data for k in ["name", "description", "version", "maintainer"]): if not all(
k in driver_data
for k in ["name", "description", "version", "maintainer"]
):
print(f"Driver config {yaml_path} missing required fields") print(f"Driver config {yaml_path} missing required fields")
return None return None
# Create driver object # Create driver object
driver = Driver( driver = Driver(
name=driver_data["name"], name=driver_data["name"],
@@ -163,37 +168,37 @@ class ConfigManager:
image=f"monadical/mc-{driver_data['name']}:latest", image=f"monadical/mc-{driver_data['name']}:latest",
ports=driver_data.get("ports", []), ports=driver_data.get("ports", []),
) )
return driver return driver
except Exception as e: except Exception as e:
print(f"Error loading driver from {yaml_path}: {e}") print(f"Error loading driver from {yaml_path}: {e}")
return None return None
def load_builtin_drivers(self) -> Dict[str, Driver]: def load_builtin_drivers(self) -> Dict[str, Driver]:
"""Load all built-in drivers from the drivers directory""" """Load all built-in drivers from the drivers directory"""
drivers = {} drivers = {}
if not BUILTIN_DRIVERS_DIR.exists(): if not BUILTIN_DRIVERS_DIR.exists():
return drivers return drivers
for driver_dir in BUILTIN_DRIVERS_DIR.iterdir(): for driver_dir in BUILTIN_DRIVERS_DIR.iterdir():
if driver_dir.is_dir(): if driver_dir.is_dir():
driver = self.load_driver_from_dir(driver_dir) driver = self.load_driver_from_dir(driver_dir)
if driver: if driver:
drivers[driver.name] = driver drivers[driver.name] = driver
return drivers return drivers
def get_driver_path(self, driver_name: str) -> Optional[Path]: def get_driver_path(self, driver_name: str) -> Optional[Path]:
"""Get the directory path for a driver""" """Get the directory path for a driver"""
# Check built-in drivers first # Check built-in drivers first
builtin_path = BUILTIN_DRIVERS_DIR / driver_name builtin_path = BUILTIN_DRIVERS_DIR / driver_name
if builtin_path.exists() and builtin_path.is_dir(): if builtin_path.exists() and builtin_path.is_dir():
return builtin_path return builtin_path
# Then check user drivers # Then check user drivers
user_path = self.drivers_dir / driver_name user_path = self.drivers_dir / driver_name
if user_path.exists() and user_path.is_dir(): if user_path.exists() and user_path.is_dir():
return user_path return user_path
return None return None

View File

@@ -213,6 +213,35 @@ class ContainerManager:
print(f"Error connecting to session: {e}") print(f"Error connecting to session: {e}")
return False return False
def close_all_sessions(self) -> tuple[int, bool]:
"""Close all MC sessions
Returns:
tuple: (number of sessions closed, success)
"""
try:
sessions = self.list_sessions()
if not sessions:
return 0, True
count = 0
for session in sessions:
if session.container_id:
try:
container = self.client.containers.get(session.container_id)
container.stop()
container.remove()
self.config_manager.remove_session(session.id)
count += 1
except DockerException as e:
print(f"Error closing session {session.id}: {e}")
return count, count > 0
except DockerException as e:
print(f"Error closing all sessions: {e}")
return 0, False
def get_session_logs(self, session_id: str, follow: bool = False) -> Optional[str]: def get_session_logs(self, session_id: str, follow: bool = False) -> Optional[str]:
"""Get logs from a MC session""" """Get logs from a MC session"""
try: try:

View File

@@ -44,5 +44,7 @@ class Session(BaseModel):
class Config(BaseModel): class Config(BaseModel):
docker: Dict[str, str] = Field(default_factory=dict) docker: Dict[str, str] = Field(default_factory=dict)
drivers: Dict[str, Driver] = Field(default_factory=dict) drivers: Dict[str, Driver] = Field(default_factory=dict)
sessions: Dict[str, dict] = Field(default_factory=dict) # Store as dict to avoid serialization issues sessions: Dict[str, dict] = Field(
default_factory=dict
) # Store as dict to avoid serialization issues
defaults: Dict[str, str] = Field(default_factory=dict) defaults: Dict[str, str] = Field(default_factory=dict)

View File

@@ -24,4 +24,4 @@ def test_help() -> None:
result = runner.invoke(app, ["--help"]) result = runner.invoke(app, ["--help"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Usage" in result.stdout assert "Usage" in result.stdout
assert "Monadical Container Tool" in result.stdout assert "Monadical Container Tool" in result.stdout