forked from aegis/pyserveX
pyservectl init
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
from .config import config_cmd
|
||||
from .down import down_cmd
|
||||
from .health import health_cmd
|
||||
from .init import init_cmd
|
||||
from .logs import logs_cmd
|
||||
from .scale import scale_cmd
|
||||
from .service import restart_cmd, start_cmd, stop_cmd
|
||||
from .status import ps_cmd
|
||||
from .top import top_cmd
|
||||
from .up import up_cmd
|
||||
|
||||
__all__ = [
|
||||
"init_cmd",
|
||||
"config_cmd",
|
||||
"up_cmd",
|
||||
"down_cmd",
|
||||
"start_cmd",
|
||||
"stop_cmd",
|
||||
"restart_cmd",
|
||||
"ps_cmd",
|
||||
"logs_cmd",
|
||||
"top_cmd",
|
||||
"health_cmd",
|
||||
"scale_cmd",
|
||||
]
|
||||
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
pyserve config - Configuration management commands
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
|
||||
@click.group("config")
|
||||
def config_cmd():
|
||||
"""
|
||||
Configuration management commands.
|
||||
|
||||
\b
|
||||
Commands:
|
||||
validate Validate configuration file
|
||||
show Display current configuration
|
||||
get Get a specific configuration value
|
||||
set Set a configuration value
|
||||
diff Compare two configuration files
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@config_cmd.command("validate")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_file",
|
||||
default=None,
|
||||
help="Path to configuration file",
|
||||
)
|
||||
@click.option(
|
||||
"--strict",
|
||||
is_flag=True,
|
||||
help="Enable strict validation (warn on unknown fields)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def validate_cmd(ctx, config_file: Optional[str], strict: bool):
|
||||
"""
|
||||
Validate a configuration file.
|
||||
|
||||
Checks for syntax errors, missing required fields, and invalid values.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve config validate
|
||||
pyserve config validate -c production.yaml
|
||||
pyserve config validate --strict
|
||||
"""
|
||||
from ..output import console, print_error, print_success, print_warning
|
||||
|
||||
config_path = Path(config_file or ctx.config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
console.print(f"Validating [cyan]{config_path}[/cyan]...")
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if data is None:
|
||||
print_error("Configuration file is empty")
|
||||
raise click.Abort()
|
||||
|
||||
from ...config import Config
|
||||
|
||||
config = Config.from_yaml(str(config_path))
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
if not (1 <= config.server.port <= 65535):
|
||||
errors.append(f"Invalid server port: {config.server.port}")
|
||||
|
||||
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if config.logging.level.upper() not in valid_levels:
|
||||
errors.append(f"Invalid logging level: {config.logging.level}")
|
||||
|
||||
if config.ssl.enabled:
|
||||
if not Path(config.ssl.cert_file).exists():
|
||||
warnings.append(f"SSL cert file not found: {config.ssl.cert_file}")
|
||||
if not Path(config.ssl.key_file).exists():
|
||||
warnings.append(f"SSL key file not found: {config.ssl.key_file}")
|
||||
|
||||
valid_extension_types = [
|
||||
"routing",
|
||||
"process_orchestration",
|
||||
"asgi_mount",
|
||||
]
|
||||
for ext in config.extensions:
|
||||
if ext.type not in valid_extension_types:
|
||||
warnings.append(f"Unknown extension type: {ext.type}")
|
||||
|
||||
if strict:
|
||||
known_top_level = {"http", "server", "ssl", "logging", "extensions"}
|
||||
for key in data.keys():
|
||||
if key not in known_top_level:
|
||||
warnings.append(f"Unknown top-level field: {key}")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print_error(error)
|
||||
raise click.Abort()
|
||||
|
||||
if warnings:
|
||||
for warning in warnings:
|
||||
print_warning(warning)
|
||||
|
||||
print_success("Configuration is valid!")
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
print_error(f"YAML syntax error: {e}")
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
print_error(f"Validation error: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@config_cmd.command("show")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_file",
|
||||
default=None,
|
||||
help="Path to configuration file",
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
"output_format",
|
||||
type=click.Choice(["yaml", "json", "table"]),
|
||||
default="yaml",
|
||||
help="Output format",
|
||||
)
|
||||
@click.option(
|
||||
"--section",
|
||||
"section",
|
||||
default=None,
|
||||
help="Show only a specific section (e.g., server, logging)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def show_cmd(ctx, config_file: Optional[str], output_format: str, section: Optional[str]):
|
||||
"""
|
||||
Display current configuration.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve config show
|
||||
pyserve config show --format json
|
||||
pyserve config show --section server
|
||||
"""
|
||||
from ..output import console, print_error
|
||||
|
||||
config_path = Path(config_file or ctx.config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if section:
|
||||
if section in data:
|
||||
data = {section: data[section]}
|
||||
else:
|
||||
print_error(f"Section '{section}' not found in configuration")
|
||||
raise click.Abort()
|
||||
|
||||
if output_format == "yaml":
|
||||
from rich.syntax import Syntax
|
||||
|
||||
yaml_str = yaml.dump(data, default_flow_style=False, sort_keys=False)
|
||||
syntax = Syntax(yaml_str, "yaml", theme="monokai", line_numbers=False)
|
||||
console.print(syntax)
|
||||
|
||||
elif output_format == "json":
|
||||
from rich.syntax import Syntax
|
||||
|
||||
json_str = json.dumps(data, indent=2)
|
||||
syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False)
|
||||
console.print(syntax)
|
||||
|
||||
elif output_format == "table":
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
def build_tree(data, tree):
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
branch = tree.add(f"[cyan]{key}[/cyan]")
|
||||
build_tree(value, branch)
|
||||
else:
|
||||
tree.add(f"[cyan]{key}[/cyan]: [green]{value}[/green]")
|
||||
elif isinstance(data, list):
|
||||
for i, item in enumerate(data):
|
||||
if isinstance(item, (dict, list)):
|
||||
branch = tree.add(f"[dim][{i}][/dim]")
|
||||
build_tree(item, branch)
|
||||
else:
|
||||
tree.add(f"[dim][{i}][/dim] [green]{item}[/green]")
|
||||
|
||||
tree = Tree(f"[bold]Configuration: {config_path}[/bold]")
|
||||
build_tree(data, tree)
|
||||
console.print(tree)
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Error reading configuration: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@config_cmd.command("get")
|
||||
@click.argument("key")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_file",
|
||||
default=None,
|
||||
help="Path to configuration file",
|
||||
)
|
||||
@click.pass_obj
|
||||
def get_cmd(ctx, key: str, config_file: Optional[str]):
|
||||
"""
|
||||
Get a specific configuration value.
|
||||
|
||||
Use dot notation to access nested values.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve config get server.port
|
||||
pyserve config get logging.level
|
||||
pyserve config get extensions.0.type
|
||||
"""
|
||||
from ..output import console, print_error
|
||||
|
||||
config_path = Path(config_file or ctx.config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
value = data
|
||||
for part in key.split("."):
|
||||
if isinstance(value, dict):
|
||||
if part in value:
|
||||
value = value[part]
|
||||
else:
|
||||
print_error(f"Key '{key}' not found")
|
||||
raise click.Abort()
|
||||
elif isinstance(value, list):
|
||||
try:
|
||||
index = int(part)
|
||||
value = value[index]
|
||||
except (ValueError, IndexError):
|
||||
print_error(f"Invalid index '{part}' in key '{key}'")
|
||||
raise click.Abort()
|
||||
else:
|
||||
print_error(f"Cannot access '{part}' in {type(value).__name__}")
|
||||
raise click.Abort()
|
||||
|
||||
if isinstance(value, (dict, list)):
|
||||
console.print(yaml.dump(value, default_flow_style=False))
|
||||
else:
|
||||
console.print(str(value))
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@config_cmd.command("set")
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_file",
|
||||
default=None,
|
||||
help="Path to configuration file",
|
||||
)
|
||||
@click.pass_obj
|
||||
def set_cmd(ctx, key: str, value: str, config_file: Optional[str]):
|
||||
"""
|
||||
Set a configuration value.
|
||||
|
||||
Use dot notation to access nested values.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve config set server.port 8080
|
||||
pyserve config set logging.level DEBUG
|
||||
"""
|
||||
from ..output import print_error, print_success
|
||||
|
||||
config_path = Path(config_file or ctx.config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
parsed_value: Any
|
||||
if value.lower() == "true":
|
||||
parsed_value = True
|
||||
elif value.lower() == "false":
|
||||
parsed_value = False
|
||||
elif value.isdigit():
|
||||
parsed_value = int(value)
|
||||
else:
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except ValueError:
|
||||
parsed_value = value
|
||||
|
||||
parts = key.split(".")
|
||||
current = data
|
||||
for part in parts[:-1]:
|
||||
if isinstance(current, dict):
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
elif isinstance(current, list):
|
||||
index = int(part)
|
||||
current = current[index]
|
||||
|
||||
final_key = parts[-1]
|
||||
if isinstance(current, dict):
|
||||
current[final_key] = parsed_value
|
||||
elif isinstance(current, list):
|
||||
current[int(final_key)] = parsed_value
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
print_success(f"Set {key} = {parsed_value}")
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@config_cmd.command("diff")
|
||||
@click.argument("file1", type=click.Path(exists=True))
|
||||
@click.argument("file2", type=click.Path(exists=True))
|
||||
def diff_cmd(file1: str, file2: str):
|
||||
"""
|
||||
Compare two configuration files.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve config diff config.yaml production.yaml
|
||||
"""
|
||||
from ..output import console, print_error
|
||||
|
||||
try:
|
||||
with open(file1) as f:
|
||||
data1 = yaml.safe_load(f)
|
||||
with open(file2) as f:
|
||||
data2 = yaml.safe_load(f)
|
||||
|
||||
def compare_dicts(d1, d2, path=""):
|
||||
differences = []
|
||||
|
||||
all_keys = set(d1.keys() if d1 else []) | set(d2.keys() if d2 else [])
|
||||
|
||||
for key in sorted(all_keys):
|
||||
current_path = f"{path}.{key}" if path else key
|
||||
v1 = d1.get(key) if d1 else None
|
||||
v2 = d2.get(key) if d2 else None
|
||||
|
||||
if key not in (d1 or {}):
|
||||
differences.append(("added", current_path, None, v2))
|
||||
elif key not in (d2 or {}):
|
||||
differences.append(("removed", current_path, v1, None))
|
||||
elif isinstance(v1, dict) and isinstance(v2, dict):
|
||||
differences.extend(compare_dicts(v1, v2, current_path))
|
||||
elif v1 != v2:
|
||||
differences.append(("changed", current_path, v1, v2))
|
||||
|
||||
return differences
|
||||
|
||||
differences = compare_dicts(data1, data2)
|
||||
|
||||
if not differences:
|
||||
console.print("[green]Files are identical[/green]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold]Differences between {file1} and {file2}:[/bold]\n")
|
||||
|
||||
for diff_type, path, v1, v2 in differences:
|
||||
if diff_type == "added":
|
||||
console.print(f" [green]+ {path}: {v2}[/green]")
|
||||
elif diff_type == "removed":
|
||||
console.print(f" [red]- {path}: {v1}[/red]")
|
||||
elif diff_type == "changed":
|
||||
console.print(f" [yellow]~ {path}:[/yellow]")
|
||||
console.print(f" [red]- {v1}[/red]")
|
||||
console.print(f" [green]+ {v2}[/green]")
|
||||
|
||||
console.print()
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
pyserve down - Stop all services
|
||||
"""
|
||||
|
||||
import signal
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, cast
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("down")
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=30,
|
||||
type=int,
|
||||
help="Timeout in seconds for graceful shutdown",
|
||||
)
|
||||
@click.option(
|
||||
"-v",
|
||||
"--volumes",
|
||||
is_flag=True,
|
||||
help="Remove volumes/data",
|
||||
)
|
||||
@click.option(
|
||||
"--remove-orphans",
|
||||
is_flag=True,
|
||||
help="Remove orphaned services",
|
||||
)
|
||||
@click.pass_obj
|
||||
def down_cmd(
|
||||
ctx,
|
||||
timeout: int,
|
||||
volumes: bool,
|
||||
remove_orphans: bool,
|
||||
):
|
||||
"""
|
||||
Stop and remove all services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve down # Stop all services
|
||||
pyserve down --timeout 60 # Extended shutdown timeout
|
||||
pyserve down -v # Remove volumes too
|
||||
"""
|
||||
from ..output import console, print_error, print_info, print_success, print_warning
|
||||
from ..state import StateManager
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
|
||||
if state_manager.is_daemon_running():
|
||||
daemon_pid = state_manager.get_daemon_pid()
|
||||
console.print(f"[bold]Stopping PyServe daemon (PID: {daemon_pid})...[/bold]")
|
||||
|
||||
try:
|
||||
import os
|
||||
# FIXME: Please fix the cast usage here
|
||||
os.kill(cast(int, daemon_pid), signal.SIGTERM)
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
# FIXME: Please fix the cast usage here
|
||||
os.kill(cast(int, daemon_pid), 0)
|
||||
time.sleep(0.5)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
else:
|
||||
print_warning("Graceful shutdown timed out, forcing...")
|
||||
try:
|
||||
# FIXME: Please fix the cast usage here
|
||||
os.kill(cast(int, daemon_pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
state_manager.clear_daemon_pid()
|
||||
print_success("PyServe daemon stopped")
|
||||
|
||||
except ProcessLookupError:
|
||||
print_info("Daemon was not running")
|
||||
state_manager.clear_daemon_pid()
|
||||
except PermissionError:
|
||||
print_error("Permission denied to stop daemon")
|
||||
raise click.Abort()
|
||||
else:
|
||||
services = state_manager.get_all_services()
|
||||
|
||||
if not services:
|
||||
print_info("No services are running")
|
||||
return
|
||||
|
||||
console.print("[bold]Stopping services...[/bold]")
|
||||
|
||||
from .._runner import ServiceRunner
|
||||
from ...config import Config
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
if config_path.exists():
|
||||
config = Config.from_yaml(str(config_path))
|
||||
else:
|
||||
config = Config()
|
||||
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.run(runner.stop_all(timeout=timeout))
|
||||
print_success("All services stopped")
|
||||
except Exception as e:
|
||||
print_error(f"Error stopping services: {e}")
|
||||
|
||||
if volumes:
|
||||
console.print("Cleaning up state...")
|
||||
state_manager.clear()
|
||||
print_info("State cleared")
|
||||
|
||||
if remove_orphans:
|
||||
# This would remove services that are in state but not in config
|
||||
pass
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
pyserve health - Check health of services
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("health")
|
||||
@click.argument("services", nargs=-1)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=5,
|
||||
type=int,
|
||||
help="Health check timeout in seconds",
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
"output_format",
|
||||
type=click.Choice(["table", "json"]),
|
||||
default="table",
|
||||
help="Output format",
|
||||
)
|
||||
@click.pass_obj
|
||||
def health_cmd(ctx, services: tuple, timeout: int, output_format: str):
|
||||
"""
|
||||
Check health of services.
|
||||
|
||||
Performs active health checks on running services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve health # Check all services
|
||||
pyserve health api admin # Check specific services
|
||||
pyserve health --format json # JSON output
|
||||
"""
|
||||
from ..output import console, print_error, print_info
|
||||
from ..state import StateManager
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
all_services = state_manager.get_all_services()
|
||||
|
||||
if services:
|
||||
all_services = {k: v for k, v in all_services.items() if k in services}
|
||||
|
||||
if not all_services:
|
||||
print_info("No services to check")
|
||||
return
|
||||
|
||||
results = asyncio.run(_check_health(all_services, timeout))
|
||||
|
||||
if output_format == "json":
|
||||
import json
|
||||
|
||||
console.print(json.dumps(results, indent=2))
|
||||
return
|
||||
|
||||
from rich.table import Table
|
||||
from ..output import format_health
|
||||
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("SERVICE", style="cyan")
|
||||
table.add_column("HEALTH")
|
||||
table.add_column("CHECKS", justify="right")
|
||||
table.add_column("LAST CHECK", style="dim")
|
||||
table.add_column("RESPONSE TIME", justify="right")
|
||||
|
||||
for name, result in results.items():
|
||||
health_str = format_health(result["status"])
|
||||
checks = f"{result['successes']}/{result['total']}"
|
||||
last_check = result.get("last_check", "-")
|
||||
response_time = f"{result['response_time_ms']:.0f}ms" if result.get("response_time_ms") else "-"
|
||||
|
||||
table.add_row(name, health_str, checks, last_check, response_time)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
healthy = sum(1 for r in results.values() if r["status"] == "healthy")
|
||||
unhealthy = sum(1 for r in results.values() if r["status"] == "unhealthy")
|
||||
|
||||
if unhealthy:
|
||||
print_error(f"{unhealthy} service(s) unhealthy")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
from ..output import print_success
|
||||
|
||||
print_success(f"All {healthy} service(s) healthy")
|
||||
|
||||
|
||||
async def _check_health(services: dict, timeout: int) -> dict:
|
||||
import time
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
return {name: {"status": "unknown", "error": "httpx not installed"} for name in services}
|
||||
|
||||
results = {}
|
||||
|
||||
for name, service in services.items():
|
||||
if service.state != "running" or not service.port:
|
||||
results[name] = {
|
||||
"status": "unknown",
|
||||
"successes": 0,
|
||||
"total": 0,
|
||||
"error": "Service not running",
|
||||
}
|
||||
continue
|
||||
|
||||
health_path = "/health"
|
||||
url = f"http://127.0.0.1:{service.port}{health_path}"
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(url)
|
||||
response_time = (time.time() - start_time) * 1000
|
||||
|
||||
if resp.status_code < 500:
|
||||
results[name] = {
|
||||
"status": "healthy",
|
||||
"successes": 1,
|
||||
"total": 1,
|
||||
"response_time_ms": response_time,
|
||||
"last_check": "just now",
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
else:
|
||||
results[name] = {
|
||||
"status": "unhealthy",
|
||||
"successes": 0,
|
||||
"total": 1,
|
||||
"response_time_ms": response_time,
|
||||
"last_check": "just now",
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
results[name] = {
|
||||
"status": "unhealthy",
|
||||
"successes": 0,
|
||||
"total": 1,
|
||||
"error": "timeout",
|
||||
"last_check": "just now",
|
||||
}
|
||||
except Exception as e:
|
||||
results[name] = {
|
||||
"status": "unhealthy",
|
||||
"successes": 0,
|
||||
"total": 1,
|
||||
"error": str(e),
|
||||
"last_check": "just now",
|
||||
}
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,433 @@
|
||||
"""
|
||||
pyserve init - Initialize a new pyserve project
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
TEMPLATES = {
|
||||
"basic": {
|
||||
"description": "Basic configuration with static files and routing",
|
||||
"filename": "config.yaml",
|
||||
},
|
||||
"orchestration": {
|
||||
"description": "Process orchestration with multiple ASGI/WSGI apps",
|
||||
"filename": "config.yaml",
|
||||
},
|
||||
"asgi": {
|
||||
"description": "ASGI mount configuration for in-process apps",
|
||||
"filename": "config.yaml",
|
||||
},
|
||||
"full": {
|
||||
"description": "Full configuration with all features",
|
||||
"filename": "config.yaml",
|
||||
},
|
||||
}
|
||||
|
||||
BASIC_TEMPLATE = """\
|
||||
# PyServe Configuration
|
||||
# Generated by: pyserve init
|
||||
|
||||
http:
|
||||
static_dir: ./static
|
||||
templates_dir: ./templates
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
backlog: 100
|
||||
proxy_timeout: 30.0
|
||||
|
||||
ssl:
|
||||
enabled: false
|
||||
cert_file: ./ssl/cert.pem
|
||||
key_file: ./ssl/key.pem
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
console_output: true
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
show_module: true
|
||||
timestamp_format: "%Y-%m-%d %H:%M:%S"
|
||||
console:
|
||||
level: INFO
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
files:
|
||||
- path: ./logs/pyserve.log
|
||||
level: INFO
|
||||
format:
|
||||
type: standard
|
||||
use_colors: false
|
||||
|
||||
extensions:
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
# Health check endpoint
|
||||
"=/health":
|
||||
return: "200 OK"
|
||||
content_type: "text/plain"
|
||||
|
||||
# Static files
|
||||
"^/static/":
|
||||
root: "./static"
|
||||
strip_prefix: "/static"
|
||||
|
||||
# Default fallback
|
||||
"__default__":
|
||||
spa_fallback: true
|
||||
root: "./static"
|
||||
index_file: "index.html"
|
||||
"""
|
||||
|
||||
ORCHESTRATION_TEMPLATE = """\
|
||||
# PyServe Process Orchestration Configuration
|
||||
# Generated by: pyserve init --template orchestration
|
||||
#
|
||||
# This configuration runs multiple ASGI/WSGI apps as isolated processes
|
||||
# with automatic health monitoring and restart.
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
backlog: 2048
|
||||
proxy_timeout: 60.0
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
console_output: true
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
files:
|
||||
- path: ./logs/pyserve.log
|
||||
level: DEBUG
|
||||
format:
|
||||
type: standard
|
||||
use_colors: false
|
||||
|
||||
extensions:
|
||||
# Process Orchestration - runs each app in its own process
|
||||
- type: process_orchestration
|
||||
config:
|
||||
port_range: [9000, 9999]
|
||||
health_check_enabled: true
|
||||
proxy_timeout: 60.0
|
||||
|
||||
apps:
|
||||
# Example: FastAPI application
|
||||
- name: api
|
||||
path: /api
|
||||
app_path: myapp.api:app
|
||||
module_path: "."
|
||||
workers: 2
|
||||
health_check_path: /health
|
||||
health_check_interval: 10.0
|
||||
health_check_timeout: 5.0
|
||||
health_check_retries: 3
|
||||
max_restart_count: 5
|
||||
restart_delay: 1.0
|
||||
strip_path: true
|
||||
env:
|
||||
APP_ENV: "production"
|
||||
|
||||
# Example: Flask application (WSGI)
|
||||
# - name: admin
|
||||
# path: /admin
|
||||
# app_path: myapp.admin:app
|
||||
# app_type: wsgi
|
||||
# module_path: "."
|
||||
# workers: 1
|
||||
# health_check_path: /health
|
||||
# strip_path: true
|
||||
|
||||
# Static files routing
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
"=/health":
|
||||
return: "200 OK"
|
||||
content_type: "text/plain"
|
||||
|
||||
"^/static/":
|
||||
root: "./static"
|
||||
strip_prefix: "/static"
|
||||
"""
|
||||
|
||||
ASGI_TEMPLATE = """\
|
||||
# PyServe ASGI Mount Configuration
|
||||
# Generated by: pyserve init --template asgi
|
||||
#
|
||||
# This configuration mounts ASGI apps in-process (like ASGI Lifespan).
|
||||
# More efficient but apps share the same process.
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
backlog: 100
|
||||
proxy_timeout: 30.0
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
console_output: true
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
files:
|
||||
- path: ./logs/pyserve.log
|
||||
level: DEBUG
|
||||
|
||||
extensions:
|
||||
- type: asgi_mount
|
||||
config:
|
||||
mounts:
|
||||
# FastAPI app mounted at /api
|
||||
- path: /api
|
||||
app: myapp.api:app
|
||||
# factory: false # Set to true if app is a factory function
|
||||
|
||||
# Starlette app mounted at /web
|
||||
# - path: /web
|
||||
# app: myapp.web:app
|
||||
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
"=/health":
|
||||
return: "200 OK"
|
||||
content_type: "text/plain"
|
||||
|
||||
"^/static/":
|
||||
root: "./static"
|
||||
strip_prefix: "/static"
|
||||
|
||||
"__default__":
|
||||
spa_fallback: true
|
||||
root: "./static"
|
||||
index_file: "index.html"
|
||||
"""
|
||||
|
||||
FULL_TEMPLATE = """\
|
||||
# PyServe Full Configuration
|
||||
# Generated by: pyserve init --template full
|
||||
#
|
||||
# Comprehensive configuration showcasing all PyServe features.
|
||||
|
||||
http:
|
||||
static_dir: ./static
|
||||
templates_dir: ./templates
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
backlog: 2048
|
||||
default_root: false
|
||||
proxy_timeout: 60.0
|
||||
redirect_instructions:
|
||||
"/old-path": "/new-path"
|
||||
|
||||
ssl:
|
||||
enabled: false
|
||||
cert_file: ./ssl/cert.pem
|
||||
key_file: ./ssl/key.pem
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
console_output: true
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
show_module: true
|
||||
timestamp_format: "%Y-%m-%d %H:%M:%S"
|
||||
console:
|
||||
level: DEBUG
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
files:
|
||||
# Main log file
|
||||
- path: ./logs/pyserve.log
|
||||
level: DEBUG
|
||||
format:
|
||||
type: standard
|
||||
use_colors: false
|
||||
|
||||
# JSON logs for log aggregation
|
||||
- path: ./logs/pyserve.json
|
||||
level: INFO
|
||||
format:
|
||||
type: json
|
||||
|
||||
# Access logs
|
||||
- path: ./logs/access.log
|
||||
level: INFO
|
||||
loggers: ["pyserve.access"]
|
||||
max_bytes: 10485760 # 10MB
|
||||
backup_count: 10
|
||||
|
||||
extensions:
|
||||
# Process Orchestration for background services
|
||||
- type: process_orchestration
|
||||
config:
|
||||
port_range: [9000, 9999]
|
||||
health_check_enabled: true
|
||||
proxy_timeout: 60.0
|
||||
|
||||
apps:
|
||||
- name: api
|
||||
path: /api
|
||||
app_path: myapp.api:app
|
||||
module_path: "."
|
||||
workers: 2
|
||||
health_check_path: /health
|
||||
strip_path: true
|
||||
env:
|
||||
APP_ENV: "production"
|
||||
|
||||
# Advanced routing with regex
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
# API versioning
|
||||
"~^/api/v(?P<version>\\\\d+)/":
|
||||
proxy_pass: "http://localhost:9001"
|
||||
headers:
|
||||
- "API-Version: {version}"
|
||||
- "X-Forwarded-For: $remote_addr"
|
||||
|
||||
# Static files with caching
|
||||
"~*\\\\.(js|css|png|jpg|gif|ico|svg|woff2?)$":
|
||||
root: "./static"
|
||||
cache_control: "public, max-age=31536000"
|
||||
headers:
|
||||
- "Access-Control-Allow-Origin: *"
|
||||
|
||||
# Health check
|
||||
"=/health":
|
||||
return: "200 OK"
|
||||
content_type: "text/plain"
|
||||
|
||||
# Static files
|
||||
"^/static/":
|
||||
root: "./static"
|
||||
strip_prefix: "/static"
|
||||
|
||||
# SPA fallback
|
||||
"__default__":
|
||||
spa_fallback: true
|
||||
root: "./static"
|
||||
index_file: "index.html"
|
||||
"""
|
||||
|
||||
|
||||
def get_template_content(template: str) -> str:
|
||||
templates = {
|
||||
"basic": BASIC_TEMPLATE,
|
||||
"orchestration": ORCHESTRATION_TEMPLATE,
|
||||
"asgi": ASGI_TEMPLATE,
|
||||
"full": FULL_TEMPLATE,
|
||||
}
|
||||
return templates.get(template, BASIC_TEMPLATE)
|
||||
|
||||
|
||||
@click.command("init")
|
||||
@click.option(
|
||||
"-t",
|
||||
"--template",
|
||||
"template",
|
||||
type=click.Choice(list(TEMPLATES.keys())),
|
||||
default="basic",
|
||||
help="Configuration template to use",
|
||||
)
|
||||
@click.option(
|
||||
"-o",
|
||||
"--output",
|
||||
"output_file",
|
||||
default="config.yaml",
|
||||
help="Output file path (default: config.yaml)",
|
||||
)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Overwrite existing configuration",
|
||||
)
|
||||
@click.option(
|
||||
"--list-templates",
|
||||
is_flag=True,
|
||||
help="List available templates",
|
||||
)
|
||||
@click.pass_context
|
||||
def init_cmd(
|
||||
ctx,
|
||||
template: str,
|
||||
output_file: str,
|
||||
force: bool,
|
||||
list_templates: bool,
|
||||
):
|
||||
"""
|
||||
Initialize a new pyserve project.
|
||||
|
||||
Creates a configuration file with sensible defaults and directory structure.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve init # Basic configuration
|
||||
pyserve init -t orchestration # Process orchestration setup
|
||||
pyserve init -t asgi # ASGI mount setup
|
||||
pyserve init -t full # All features
|
||||
pyserve init -o production.yaml # Custom output file
|
||||
"""
|
||||
from ..output import console, print_success, print_warning, print_info
|
||||
|
||||
if list_templates:
|
||||
console.print("\n[bold]Available Templates:[/bold]\n")
|
||||
for name, info in TEMPLATES.items():
|
||||
console.print(f" [cyan]{name:15}[/cyan] - {info['description']}")
|
||||
console.print()
|
||||
return
|
||||
|
||||
output_path = Path(output_file)
|
||||
|
||||
if output_path.exists() and not force:
|
||||
print_warning(f"Configuration file '{output_file}' already exists.")
|
||||
if not click.confirm("Do you want to overwrite it?"):
|
||||
raise click.Abort()
|
||||
|
||||
dirs_to_create = ["static", "templates", "logs"]
|
||||
if template == "orchestration":
|
||||
dirs_to_create.append("apps")
|
||||
|
||||
for dir_name in dirs_to_create:
|
||||
dir_path = Path(dir_name)
|
||||
if not dir_path.exists():
|
||||
dir_path.mkdir(parents=True)
|
||||
print_info(f"Created directory: {dir_name}/")
|
||||
|
||||
state_dir = Path(".pyserve")
|
||||
if not state_dir.exists():
|
||||
state_dir.mkdir()
|
||||
print_info("Created directory: .pyserve/")
|
||||
|
||||
content = get_template_content(template)
|
||||
output_path.write_text(content)
|
||||
|
||||
print_success(f"Created configuration file: {output_file}")
|
||||
print_info(f"Template: {template}")
|
||||
|
||||
gitignore_path = Path(".pyserve/.gitignore")
|
||||
if not gitignore_path.exists():
|
||||
gitignore_path.write_text("*\n!.gitignore\n")
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Next steps:[/bold]")
|
||||
console.print(f" 1. Edit [cyan]{output_file}[/cyan] to configure your services")
|
||||
console.print(" 2. Run [cyan]pyserve config validate[/cyan] to check configuration")
|
||||
console.print(" 3. Run [cyan]pyserve up[/cyan] to start services")
|
||||
console.print()
|
||||
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
pyserve logs - View service logs
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("logs")
|
||||
@click.argument("services", nargs=-1)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--follow",
|
||||
is_flag=True,
|
||||
help="Follow log output",
|
||||
)
|
||||
@click.option(
|
||||
"--tail",
|
||||
"tail",
|
||||
default=100,
|
||||
type=int,
|
||||
help="Number of lines to show from the end",
|
||||
)
|
||||
@click.option(
|
||||
"--since",
|
||||
"since",
|
||||
default=None,
|
||||
help="Show logs since timestamp (e.g., '10m', '1h', '2024-01-01')",
|
||||
)
|
||||
@click.option(
|
||||
"--until",
|
||||
"until_time",
|
||||
default=None,
|
||||
help="Show logs until timestamp",
|
||||
)
|
||||
@click.option(
|
||||
"-t",
|
||||
"--timestamps",
|
||||
is_flag=True,
|
||||
help="Show timestamps",
|
||||
)
|
||||
@click.option(
|
||||
"--no-color",
|
||||
is_flag=True,
|
||||
help="Disable colored output",
|
||||
)
|
||||
@click.option(
|
||||
"--filter",
|
||||
"filter_pattern",
|
||||
default=None,
|
||||
help="Filter logs by pattern",
|
||||
)
|
||||
@click.pass_obj
|
||||
def logs_cmd(
|
||||
ctx,
|
||||
services: tuple,
|
||||
follow: bool,
|
||||
tail: int,
|
||||
since: Optional[str],
|
||||
until_time: Optional[str],
|
||||
timestamps: bool,
|
||||
no_color: bool,
|
||||
filter_pattern: Optional[str],
|
||||
):
|
||||
"""
|
||||
View service logs.
|
||||
|
||||
If no services are specified, shows logs from all services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve logs # All logs
|
||||
pyserve logs api # Logs from api service
|
||||
pyserve logs api admin # Logs from multiple services
|
||||
pyserve logs -f # Follow logs
|
||||
pyserve logs --tail 50 # Last 50 lines
|
||||
pyserve logs --since "10m" # Logs from last 10 minutes
|
||||
"""
|
||||
from ..output import console, print_error, print_info
|
||||
from ..state import StateManager
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
|
||||
if services:
|
||||
log_files = [
|
||||
(name, state_manager.get_service_log_file(name)) for name in services
|
||||
]
|
||||
else:
|
||||
all_services = state_manager.get_all_services()
|
||||
if not all_services:
|
||||
main_log = Path("logs/pyserve.log")
|
||||
if main_log.exists():
|
||||
log_files = [("pyserve", main_log)]
|
||||
else:
|
||||
print_info("No logs available. Start services with 'pyserve up'")
|
||||
return
|
||||
else:
|
||||
log_files = [
|
||||
(name, state_manager.get_service_log_file(name))
|
||||
for name in all_services
|
||||
]
|
||||
|
||||
existing_logs = [(name, path) for name, path in log_files if path.exists()]
|
||||
|
||||
if not existing_logs:
|
||||
print_info("No log files found")
|
||||
return
|
||||
|
||||
since_time = _parse_time(since) if since else None
|
||||
until_timestamp = _parse_time(until_time) if until_time else None
|
||||
|
||||
colors = ["cyan", "green", "yellow", "blue", "magenta"]
|
||||
service_colors = {
|
||||
name: colors[i % len(colors)] for i, (name, _) in enumerate(existing_logs)
|
||||
}
|
||||
|
||||
if follow:
|
||||
asyncio.run(
|
||||
_follow_logs(
|
||||
existing_logs,
|
||||
service_colors,
|
||||
timestamps,
|
||||
no_color,
|
||||
filter_pattern,
|
||||
)
|
||||
)
|
||||
else:
|
||||
_read_logs(
|
||||
existing_logs,
|
||||
service_colors,
|
||||
tail,
|
||||
since_time,
|
||||
until_timestamp,
|
||||
timestamps,
|
||||
no_color,
|
||||
filter_pattern,
|
||||
)
|
||||
|
||||
|
||||
def _parse_time(time_str: str) -> Optional[float]:
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Relative time (e.g., "10m", "1h", "2d")
|
||||
match = re.match(r"^(\d+)([smhd])$", time_str)
|
||||
if match:
|
||||
value = int(match.group(1))
|
||||
unit = match.group(2)
|
||||
units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
||||
return time.time() - (value * units[unit])
|
||||
|
||||
# Relative phrase (e.g., "10m ago")
|
||||
match = re.match(r"^(\d+)([smhd])\s+ago$", time_str)
|
||||
if match:
|
||||
value = int(match.group(1))
|
||||
unit = match.group(2)
|
||||
units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
||||
return time.time() - (value * units[unit])
|
||||
|
||||
# ISO format
|
||||
try:
|
||||
dt = datetime.fromisoformat(time_str)
|
||||
return dt.timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_logs(
|
||||
log_files,
|
||||
service_colors,
|
||||
tail: int,
|
||||
since_time: Optional[float],
|
||||
until_time: Optional[float],
|
||||
timestamps: bool,
|
||||
no_color: bool,
|
||||
filter_pattern: Optional[str],
|
||||
):
|
||||
from ..output import console
|
||||
|
||||
import re
|
||||
|
||||
all_lines = []
|
||||
|
||||
for service_name, log_path in log_files:
|
||||
try:
|
||||
with open(log_path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Take last N lines
|
||||
lines = lines[-tail:] if tail else lines
|
||||
|
||||
for line in lines:
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if filter_pattern and filter_pattern not in line:
|
||||
continue
|
||||
|
||||
line_time = None
|
||||
timestamp_match = re.match(r"^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})", line)
|
||||
if timestamp_match:
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
line_time = datetime.fromisoformat(
|
||||
timestamp_match.group(1).replace(" ", "T")
|
||||
).timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if since_time and line_time and line_time < since_time:
|
||||
continue
|
||||
if until_time and line_time and line_time > until_time:
|
||||
continue
|
||||
|
||||
all_lines.append((line_time or 0, service_name, line))
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error reading {log_path}: {e}[/red]")
|
||||
|
||||
all_lines.sort(key=lambda x: x[0])
|
||||
|
||||
for _, service_name, line in all_lines:
|
||||
if len(log_files) > 1:
|
||||
# Multiple services - prefix with service name
|
||||
if no_color:
|
||||
console.print(f"{service_name} | {line}")
|
||||
else:
|
||||
color = service_colors.get(service_name, "white")
|
||||
console.print(f"[{color}]{service_name}[/{color}] | {line}")
|
||||
else:
|
||||
console.print(line)
|
||||
|
||||
|
||||
async def _follow_logs(
|
||||
log_files,
|
||||
service_colors,
|
||||
timestamps: bool,
|
||||
no_color: bool,
|
||||
filter_pattern: Optional[str],
|
||||
):
|
||||
from ..output import console
|
||||
|
||||
positions = {}
|
||||
for service_name, log_path in log_files:
|
||||
if log_path.exists():
|
||||
positions[service_name] = log_path.stat().st_size
|
||||
else:
|
||||
positions[service_name] = 0
|
||||
|
||||
console.print("[dim]Following logs... Press Ctrl+C to stop[/dim]\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
for service_name, log_path in log_files:
|
||||
if not log_path.exists():
|
||||
continue
|
||||
|
||||
current_size = log_path.stat().st_size
|
||||
if current_size > positions[service_name]:
|
||||
with open(log_path) as f:
|
||||
f.seek(positions[service_name])
|
||||
new_content = f.read()
|
||||
positions[service_name] = f.tell()
|
||||
|
||||
for line in new_content.splitlines():
|
||||
if filter_pattern and filter_pattern not in line:
|
||||
continue
|
||||
|
||||
if len(log_files) > 1:
|
||||
if no_color:
|
||||
console.print(f"{service_name} | {line}")
|
||||
else:
|
||||
color = service_colors.get(service_name, "white")
|
||||
console.print(f"[{color}]{service_name}[/{color}] | {line}")
|
||||
else:
|
||||
console.print(line)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Stopped following logs[/dim]")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
pyserve scale - Scale services
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("scale")
|
||||
@click.argument("scales", nargs=-1, required=True)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=60,
|
||||
type=int,
|
||||
help="Timeout in seconds for scaling operation",
|
||||
)
|
||||
@click.option(
|
||||
"--no-wait",
|
||||
is_flag=True,
|
||||
help="Don't wait for services to be ready",
|
||||
)
|
||||
@click.pass_obj
|
||||
def scale_cmd(ctx, scales: tuple, timeout: int, no_wait: bool):
|
||||
"""
|
||||
Scale services to specified number of workers.
|
||||
|
||||
Use SERVICE=NUM format to specify scaling.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve scale api=4 # Scale api to 4 workers
|
||||
pyserve scale api=4 admin=2 # Scale multiple services
|
||||
"""
|
||||
from ..output import console, print_error, print_info, print_success
|
||||
from ..state import StateManager
|
||||
from .._runner import ServiceRunner
|
||||
from ...config import Config
|
||||
|
||||
scale_map = {}
|
||||
for scale in scales:
|
||||
try:
|
||||
service, num = scale.split("=")
|
||||
scale_map[service] = int(num)
|
||||
except ValueError:
|
||||
print_error(f"Invalid scale format: {scale}. Use SERVICE=NUM")
|
||||
raise click.Abort()
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
config = Config.from_yaml(str(config_path))
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
|
||||
all_services = state_manager.get_all_services()
|
||||
for service in scale_map:
|
||||
if service not in all_services:
|
||||
print_error(f"Service '{service}' not found")
|
||||
raise click.Abort()
|
||||
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
console.print("[bold]Scaling services...[/bold]")
|
||||
|
||||
async def do_scale():
|
||||
for service, workers in scale_map.items():
|
||||
current = all_services[service].workers or 1
|
||||
print_info(f"Scaling {service}: {current} → {workers} workers")
|
||||
|
||||
try:
|
||||
success = await runner.scale_service(
|
||||
service, workers, timeout=timeout, wait=not no_wait
|
||||
)
|
||||
if success:
|
||||
print_success(f"Scaled {service} to {workers} workers")
|
||||
else:
|
||||
print_error(f"Failed to scale {service}")
|
||||
except Exception as e:
|
||||
print_error(f"Error scaling {service}: {e}")
|
||||
|
||||
try:
|
||||
asyncio.run(do_scale())
|
||||
except Exception as e:
|
||||
print_error(f"Scaling failed: {e}")
|
||||
raise click.Abort()
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
pyserve start/stop/restart - Service management commands
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("start")
|
||||
@click.argument("services", nargs=-1, required=True)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=60,
|
||||
type=int,
|
||||
help="Timeout in seconds for service startup",
|
||||
)
|
||||
@click.pass_obj
|
||||
def start_cmd(ctx, services: tuple, timeout: int):
|
||||
"""
|
||||
Start one or more services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve start api # Start api service
|
||||
pyserve start api admin # Start multiple services
|
||||
"""
|
||||
from ..output import console, print_error, print_info, print_success
|
||||
from ..state import StateManager
|
||||
from .._runner import ServiceRunner
|
||||
from ...config import Config
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
config = Config.from_yaml(str(config_path))
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
console.print(f"[bold]Starting services: {', '.join(services)}[/bold]")
|
||||
|
||||
async def do_start():
|
||||
results = {}
|
||||
for service in services:
|
||||
try:
|
||||
success = await runner.start_service(service, timeout=timeout)
|
||||
results[service] = success
|
||||
if success:
|
||||
print_success(f"Started {service}")
|
||||
else:
|
||||
print_error(f"Failed to start {service}")
|
||||
except Exception as e:
|
||||
print_error(f"Error starting {service}: {e}")
|
||||
results[service] = False
|
||||
return results
|
||||
|
||||
try:
|
||||
results = asyncio.run(do_start())
|
||||
if not all(results.values()):
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@click.command("stop")
|
||||
@click.argument("services", nargs=-1, required=True)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=30,
|
||||
type=int,
|
||||
help="Timeout in seconds for graceful shutdown",
|
||||
)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Force stop (SIGKILL)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def stop_cmd(ctx, services: tuple, timeout: int, force: bool):
|
||||
"""
|
||||
Stop one or more services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve stop api # Stop api service
|
||||
pyserve stop api admin # Stop multiple services
|
||||
pyserve stop api --force # Force stop
|
||||
"""
|
||||
from ..output import console, print_error, print_success
|
||||
from ..state import StateManager
|
||||
from .._runner import ServiceRunner
|
||||
from ...config import Config
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
config = Config.from_yaml(str(config_path)) if config_path.exists() else Config()
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
console.print(f"[bold]Stopping services: {', '.join(services)}[/bold]")
|
||||
|
||||
async def do_stop():
|
||||
results = {}
|
||||
for service in services:
|
||||
try:
|
||||
success = await runner.stop_service(service, timeout=timeout, force=force)
|
||||
results[service] = success
|
||||
if success:
|
||||
print_success(f"Stopped {service}")
|
||||
else:
|
||||
print_error(f"Failed to stop {service}")
|
||||
except Exception as e:
|
||||
print_error(f"Error stopping {service}: {e}")
|
||||
results[service] = False
|
||||
return results
|
||||
|
||||
try:
|
||||
results = asyncio.run(do_stop())
|
||||
if not all(results.values()):
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@click.command("restart")
|
||||
@click.argument("services", nargs=-1, required=True)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=60,
|
||||
type=int,
|
||||
help="Timeout in seconds for restart",
|
||||
)
|
||||
@click.pass_obj
|
||||
def restart_cmd(ctx, services: tuple, timeout: int):
|
||||
"""
|
||||
Restart one or more services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve restart api # Restart api service
|
||||
pyserve restart api admin # Restart multiple services
|
||||
"""
|
||||
from ..output import console, print_error, print_success
|
||||
from ..state import StateManager
|
||||
from .._runner import ServiceRunner
|
||||
from ...config import Config
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
config = Config.from_yaml(str(config_path))
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
console.print(f"[bold]Restarting services: {', '.join(services)}[/bold]")
|
||||
|
||||
async def do_restart():
|
||||
results = {}
|
||||
for service in services:
|
||||
try:
|
||||
success = await runner.restart_service(service, timeout=timeout)
|
||||
results[service] = success
|
||||
if success:
|
||||
print_success(f"Restarted {service}")
|
||||
else:
|
||||
print_error(f"Failed to restart {service}")
|
||||
except Exception as e:
|
||||
print_error(f"Error restarting {service}: {e}")
|
||||
results[service] = False
|
||||
return results
|
||||
|
||||
try:
|
||||
results = asyncio.run(do_restart())
|
||||
if not all(results.values()):
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
pyserve ps / status - Show service status
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("ps")
|
||||
@click.argument("services", nargs=-1)
|
||||
@click.option(
|
||||
"-a",
|
||||
"--all",
|
||||
"show_all",
|
||||
is_flag=True,
|
||||
help="Show all services (including stopped)",
|
||||
)
|
||||
@click.option(
|
||||
"-q",
|
||||
"--quiet",
|
||||
is_flag=True,
|
||||
help="Only show service names",
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
"output_format",
|
||||
type=click.Choice(["table", "json", "yaml"]),
|
||||
default="table",
|
||||
help="Output format",
|
||||
)
|
||||
@click.option(
|
||||
"--filter",
|
||||
"filter_status",
|
||||
default=None,
|
||||
help="Filter by status (running, stopped, failed)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def ps_cmd(
|
||||
ctx,
|
||||
services: tuple,
|
||||
show_all: bool,
|
||||
quiet: bool,
|
||||
output_format: str,
|
||||
filter_status: Optional[str],
|
||||
):
|
||||
"""
|
||||
Show status of services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve ps # Show running services
|
||||
pyserve ps -a # Show all services
|
||||
pyserve ps api admin # Show specific services
|
||||
pyserve ps --format json # JSON output
|
||||
pyserve ps --filter running # Filter by status
|
||||
"""
|
||||
from ..output import (
|
||||
console,
|
||||
create_services_table,
|
||||
format_health,
|
||||
format_status,
|
||||
format_uptime,
|
||||
print_info,
|
||||
)
|
||||
from ..state import StateManager
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
all_services = state_manager.get_all_services()
|
||||
|
||||
# Check if daemon is running
|
||||
daemon_running = state_manager.is_daemon_running()
|
||||
|
||||
# Filter services
|
||||
if services:
|
||||
all_services = {k: v for k, v in all_services.items() if k in services}
|
||||
|
||||
if filter_status:
|
||||
all_services = {
|
||||
k: v for k, v in all_services.items() if v.state.lower() == filter_status.lower()
|
||||
}
|
||||
|
||||
if not show_all:
|
||||
# By default, show only running/starting/failed services
|
||||
all_services = {
|
||||
k: v
|
||||
for k, v in all_services.items()
|
||||
if v.state.lower() in ("running", "starting", "stopping", "failed", "restarting")
|
||||
}
|
||||
|
||||
if not all_services:
|
||||
if daemon_running:
|
||||
print_info("No services found. Daemon is running but no services are configured.")
|
||||
else:
|
||||
print_info("No services running. Use 'pyserve up' to start services.")
|
||||
return
|
||||
|
||||
if quiet:
|
||||
for name in all_services:
|
||||
click.echo(name)
|
||||
return
|
||||
|
||||
if output_format == "json":
|
||||
data = {name: svc.to_dict() for name, svc in all_services.items()}
|
||||
console.print(json.dumps(data, indent=2))
|
||||
return
|
||||
|
||||
if output_format == "yaml":
|
||||
import yaml
|
||||
|
||||
data = {name: svc.to_dict() for name, svc in all_services.items()}
|
||||
console.print(yaml.dump(data, default_flow_style=False))
|
||||
return
|
||||
|
||||
table = create_services_table()
|
||||
|
||||
for name, service in sorted(all_services.items()):
|
||||
ports = f"{service.port}" if service.port else "-"
|
||||
uptime = format_uptime(service.uptime) if service.state == "running" else "-"
|
||||
health = format_health(service.health.status if service.state == "running" else "-")
|
||||
pid = str(service.pid) if service.pid else "-"
|
||||
workers = f"{service.workers}" if service.workers else "-"
|
||||
|
||||
table.add_row(
|
||||
name,
|
||||
format_status(service.state),
|
||||
ports,
|
||||
uptime,
|
||||
health,
|
||||
pid,
|
||||
workers,
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
total = len(all_services)
|
||||
running = sum(1 for s in all_services.values() if s.state == "running")
|
||||
failed = sum(1 for s in all_services.values() if s.state == "failed")
|
||||
|
||||
summary_parts = [f"[bold]{total}[/bold] service(s)"]
|
||||
if running:
|
||||
summary_parts.append(f"[green]{running} running[/green]")
|
||||
if failed:
|
||||
summary_parts.append(f"[red]{failed} failed[/red]")
|
||||
if total - running - failed > 0:
|
||||
summary_parts.append(f"[dim]{total - running - failed} stopped[/dim]")
|
||||
|
||||
console.print(" | ".join(summary_parts))
|
||||
console.print()
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
pyserve top - Live monitoring dashboard
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("top")
|
||||
@click.argument("services", nargs=-1)
|
||||
@click.option(
|
||||
"--refresh",
|
||||
"refresh_interval",
|
||||
default=2,
|
||||
type=float,
|
||||
help="Refresh interval in seconds",
|
||||
)
|
||||
@click.option(
|
||||
"--no-color",
|
||||
is_flag=True,
|
||||
help="Disable colored output",
|
||||
)
|
||||
@click.pass_obj
|
||||
def top_cmd(ctx, services: tuple, refresh_interval: float, no_color: bool):
|
||||
"""
|
||||
Live monitoring dashboard for services.
|
||||
|
||||
Shows real-time CPU, memory usage, and request metrics.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve top # Monitor all services
|
||||
pyserve top api admin # Monitor specific services
|
||||
pyserve top --refresh 5 # Slower refresh rate
|
||||
"""
|
||||
from ..output import console, print_info
|
||||
from ..state import StateManager
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
|
||||
if not state_manager.is_daemon_running():
|
||||
print_info("No services running. Start with 'pyserve up -d'")
|
||||
return
|
||||
|
||||
try:
|
||||
asyncio.run(
|
||||
_run_dashboard(
|
||||
state_manager,
|
||||
list(services) if services else None,
|
||||
refresh_interval,
|
||||
no_color,
|
||||
)
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n")
|
||||
|
||||
|
||||
async def _run_dashboard(
|
||||
state_manager,
|
||||
filter_services: Optional[list],
|
||||
refresh_interval: float,
|
||||
no_color: bool,
|
||||
):
|
||||
from rich.live import Live
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.layout import Layout
|
||||
from rich.text import Text
|
||||
from ..output import console, format_uptime, format_bytes
|
||||
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
console.print("[yellow]psutil not installed. Install with: pip install psutil[/yellow]")
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
def make_dashboard():
|
||||
all_services = state_manager.get_all_services()
|
||||
|
||||
if filter_services:
|
||||
all_services = {k: v for k, v in all_services.items() if k in filter_services}
|
||||
|
||||
table = Table(
|
||||
title=None,
|
||||
show_header=True,
|
||||
header_style="bold",
|
||||
border_style="dim",
|
||||
expand=True,
|
||||
)
|
||||
table.add_column("SERVICE", style="cyan", no_wrap=True)
|
||||
table.add_column("STATUS", no_wrap=True)
|
||||
table.add_column("CPU%", justify="right")
|
||||
table.add_column("MEM", justify="right")
|
||||
table.add_column("PID", style="dim")
|
||||
table.add_column("UPTIME", style="dim")
|
||||
table.add_column("HEALTH", no_wrap=True)
|
||||
|
||||
total_cpu = 0.0
|
||||
total_mem = 0
|
||||
running_count = 0
|
||||
total_count = len(all_services)
|
||||
|
||||
for name, service in sorted(all_services.items()):
|
||||
status_style = {
|
||||
"running": "[green]● RUN[/green]",
|
||||
"stopped": "[dim]○ STOP[/dim]",
|
||||
"failed": "[red]✗ FAIL[/red]",
|
||||
"starting": "[yellow]◐ START[/yellow]",
|
||||
"stopping": "[yellow]◑ STOP[/yellow]",
|
||||
}.get(service.state, service.state)
|
||||
|
||||
cpu_str = "-"
|
||||
mem_str = "-"
|
||||
|
||||
if service.pid and service.state == "running":
|
||||
try:
|
||||
proc = psutil.Process(service.pid)
|
||||
cpu = proc.cpu_percent(interval=0.1)
|
||||
mem = proc.memory_info().rss
|
||||
cpu_str = f"{cpu:.1f}%"
|
||||
mem_str = format_bytes(mem)
|
||||
total_cpu += cpu
|
||||
total_mem += mem
|
||||
running_count += 1
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
health_style = {
|
||||
"healthy": "[green]✓[/green]",
|
||||
"unhealthy": "[red]✗[/red]",
|
||||
"degraded": "[yellow]⚠[/yellow]",
|
||||
"unknown": "[dim]?[/dim]",
|
||||
}.get(service.health.status, "[dim]-[/dim]")
|
||||
|
||||
uptime = format_uptime(service.uptime) if service.state == "running" else "-"
|
||||
pid = str(service.pid) if service.pid else "-"
|
||||
|
||||
table.add_row(
|
||||
name,
|
||||
status_style,
|
||||
cpu_str,
|
||||
mem_str,
|
||||
pid,
|
||||
uptime,
|
||||
health_style,
|
||||
)
|
||||
|
||||
elapsed = format_uptime(time.time() - start_time)
|
||||
summary = Text()
|
||||
summary.append(f"Running: {running_count}/{total_count}", style="bold")
|
||||
summary.append(" | ")
|
||||
summary.append(f"CPU: {total_cpu:.1f}%", style="cyan")
|
||||
summary.append(" | ")
|
||||
summary.append(f"MEM: {format_bytes(total_mem)}", style="cyan")
|
||||
summary.append(" | ")
|
||||
summary.append(f"Session: {elapsed}", style="dim")
|
||||
|
||||
layout = Layout()
|
||||
layout.split_column(
|
||||
Layout(
|
||||
Panel(
|
||||
Text("PyServe Dashboard", style="bold cyan", justify="center"),
|
||||
border_style="cyan",
|
||||
),
|
||||
size=3,
|
||||
),
|
||||
Layout(table),
|
||||
Layout(Panel(summary, border_style="dim"), size=3),
|
||||
)
|
||||
|
||||
return layout
|
||||
|
||||
with Live(make_dashboard(), refresh_per_second=1 / refresh_interval, console=console) as live:
|
||||
while True:
|
||||
await asyncio.sleep(refresh_interval)
|
||||
live.update(make_dashboard())
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
pyserve up - Start all services
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("up")
|
||||
@click.argument("services", nargs=-1)
|
||||
@click.option(
|
||||
"-d",
|
||||
"--detach",
|
||||
is_flag=True,
|
||||
help="Run in background (detached mode)",
|
||||
)
|
||||
@click.option(
|
||||
"--build",
|
||||
is_flag=True,
|
||||
help="Build/reload applications before starting",
|
||||
)
|
||||
@click.option(
|
||||
"--force-recreate",
|
||||
is_flag=True,
|
||||
help="Recreate services even if configuration hasn't changed",
|
||||
)
|
||||
@click.option(
|
||||
"--scale",
|
||||
"scales",
|
||||
multiple=True,
|
||||
help="Scale SERVICE to NUM workers (e.g., --scale api=4)",
|
||||
)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=60,
|
||||
type=int,
|
||||
help="Timeout in seconds for service startup",
|
||||
)
|
||||
@click.option(
|
||||
"--wait",
|
||||
is_flag=True,
|
||||
help="Wait for services to be healthy before returning",
|
||||
)
|
||||
@click.option(
|
||||
"--remove-orphans",
|
||||
is_flag=True,
|
||||
help="Remove services not defined in configuration",
|
||||
)
|
||||
@click.pass_obj
|
||||
def up_cmd(
|
||||
ctx,
|
||||
services: tuple,
|
||||
detach: bool,
|
||||
build: bool,
|
||||
force_recreate: bool,
|
||||
scales: tuple,
|
||||
timeout: int,
|
||||
wait: bool,
|
||||
remove_orphans: bool,
|
||||
):
|
||||
"""
|
||||
Start services defined in configuration.
|
||||
|
||||
If no services are specified, all services will be started.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve up # Start all services
|
||||
pyserve up -d # Start in background
|
||||
pyserve up api admin # Start specific services
|
||||
pyserve up --scale api=4 # Scale api to 4 workers
|
||||
pyserve up --wait # Wait for healthy status
|
||||
"""
|
||||
from ..output import console, print_error, print_info, print_success, print_warning
|
||||
from ..state import StateManager
|
||||
from .._runner import ServiceRunner
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
print_info("Run 'pyserve init' to create a configuration file")
|
||||
raise click.Abort()
|
||||
|
||||
scale_map = {}
|
||||
for scale in scales:
|
||||
try:
|
||||
service, num = scale.split("=")
|
||||
scale_map[service] = int(num)
|
||||
except ValueError:
|
||||
print_error(f"Invalid scale format: {scale}. Use SERVICE=NUM")
|
||||
raise click.Abort()
|
||||
|
||||
try:
|
||||
from ...config import Config
|
||||
|
||||
config = Config.from_yaml(str(config_path))
|
||||
except Exception as e:
|
||||
print_error(f"Failed to load configuration: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
|
||||
if state_manager.is_daemon_running():
|
||||
daemon_pid = state_manager.get_daemon_pid()
|
||||
print_warning(f"PyServe daemon is already running (PID: {daemon_pid})")
|
||||
if not click.confirm("Do you want to restart it?"):
|
||||
raise click.Abort()
|
||||
try:
|
||||
import os
|
||||
from typing import cast
|
||||
# FIXME: Please fix the cast usage here
|
||||
os.kill(cast(int, daemon_pid), signal.SIGTERM)
|
||||
time.sleep(2)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
state_manager.clear_daemon_pid()
|
||||
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
service_list = list(services) if services else None
|
||||
|
||||
if detach:
|
||||
console.print("[bold]Starting PyServe in background...[/bold]")
|
||||
|
||||
try:
|
||||
pid = runner.start_daemon(
|
||||
service_list,
|
||||
scale_map=scale_map,
|
||||
force_recreate=force_recreate,
|
||||
)
|
||||
state_manager.set_daemon_pid(pid)
|
||||
print_success(f"PyServe started in background (PID: {pid})")
|
||||
print_info("Use 'pyserve ps' to see service status")
|
||||
print_info("Use 'pyserve logs -f' to follow logs")
|
||||
print_info("Use 'pyserve down' to stop")
|
||||
except Exception as e:
|
||||
print_error(f"Failed to start daemon: {e}")
|
||||
raise click.Abort()
|
||||
else:
|
||||
console.print("[bold]Starting PyServe...[/bold]")
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
console.print("\n[yellow]Received shutdown signal...[/yellow]")
|
||||
runner.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
try:
|
||||
asyncio.run(
|
||||
runner.start(
|
||||
service_list,
|
||||
scale_map=scale_map,
|
||||
force_recreate=force_recreate,
|
||||
wait_healthy=wait,
|
||||
timeout=timeout,
|
||||
)
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Shutting down...[/yellow]")
|
||||
except Exception as e:
|
||||
print_error(f"Failed to start services: {e}")
|
||||
if ctx.debug:
|
||||
raise
|
||||
raise click.Abort()
|
||||
Reference in New Issue
Block a user