feat: manage.py cross-platform product manager (closes #6)
- circuitforge_core.manage module — replaces bash-only manage.sh
- config.py: ManageConfig from manage.toml (TOML via tomllib/tomli)
app name, default_url, docker compose_file/project, native services
Falls back to directory name when no manage.toml present
- docker_mode.py: DockerManager wrapping 'docker compose' (v2 plugin)
or 'docker-compose' (v1 fallback); docker_available() probe
Commands: start, stop, restart, status, logs, build
- native_mode.py: NativeManager with PID file process management
platformdirs for platform-appropriate PID/log paths
Windows-compatible log tailing (polling, no tail -f)
Cross-platform kill: SIGTERM→SIGKILL on Unix, taskkill /F on Windows
- cli.py: typer CLI — start/stop/restart/status/logs/build/open/install-shims
Mode auto-detection: Docker available + compose file → docker; else native
--mode docker|native|auto override
- templates/manage.sh: bash shim (conda, venv, python3 detection)
- templates/manage.ps1: PowerShell shim (same detection, Windows)
- templates/manage.toml.example: annotated config template
- __main__.py: python -m circuitforge_core.manage entry point
- pyproject.toml: manage extras group (platformdirs, typer)
cf-manage console script; version bumped to 0.5.0
- 36 tests: config (6), docker_mode (9), native_mode (21)
This commit is contained in:
parent
6e3474b97b
commit
8d87ed4c9f
15 changed files with 1199 additions and 1 deletions
12
circuitforge_core/manage/__init__.py
Normal file
12
circuitforge_core/manage/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
"""circuitforge_core.manage — cross-platform product process manager."""
|
||||||
|
from .config import ManageConfig, NativeService
|
||||||
|
from .docker_mode import DockerManager, docker_available
|
||||||
|
from .native_mode import NativeManager
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ManageConfig",
|
||||||
|
"NativeService",
|
||||||
|
"DockerManager",
|
||||||
|
"docker_available",
|
||||||
|
"NativeManager",
|
||||||
|
]
|
||||||
4
circuitforge_core/manage/__main__.py
Normal file
4
circuitforge_core/manage/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
"""Entry point for `python -m circuitforge_core.manage`."""
|
||||||
|
from .cli import app
|
||||||
|
|
||||||
|
app()
|
||||||
237
circuitforge_core/manage/cli.py
Normal file
237
circuitforge_core/manage/cli.py
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
"""
|
||||||
|
circuitforge_core.manage.cli — cross-platform product manager CLI.
|
||||||
|
|
||||||
|
Usage (from any product directory):
|
||||||
|
python -m circuitforge_core.manage start
|
||||||
|
python -m circuitforge_core.manage stop
|
||||||
|
python -m circuitforge_core.manage restart
|
||||||
|
python -m circuitforge_core.manage status
|
||||||
|
python -m circuitforge_core.manage logs [SERVICE]
|
||||||
|
python -m circuitforge_core.manage open
|
||||||
|
python -m circuitforge_core.manage build
|
||||||
|
python -m circuitforge_core.manage install-shims
|
||||||
|
|
||||||
|
Products shim into this via a thin manage.sh / manage.ps1 that finds Python
|
||||||
|
and delegates: exec python -m circuitforge_core.manage "$@"
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import webbrowser
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .config import ManageConfig
|
||||||
|
from .docker_mode import DockerManager, docker_available
|
||||||
|
from .native_mode import NativeManager
|
||||||
|
|
||||||
|
app = typer.Typer(
|
||||||
|
name="manage",
|
||||||
|
help="CircuitForge cross-platform product manager",
|
||||||
|
no_args_is_help=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Mode(str, Enum):
|
||||||
|
auto = "auto"
|
||||||
|
docker = "docker"
|
||||||
|
native = "native"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(
|
||||||
|
mode: Mode,
|
||||||
|
root: Path,
|
||||||
|
config: ManageConfig,
|
||||||
|
) -> tuple[str, DockerManager | NativeManager]:
|
||||||
|
"""Return (mode_name, manager) based on mode flag and environment."""
|
||||||
|
if mode == Mode.docker or (
|
||||||
|
mode == Mode.auto
|
||||||
|
and docker_available()
|
||||||
|
and (root / config.docker.compose_file).exists()
|
||||||
|
):
|
||||||
|
return "docker", DockerManager(config, root)
|
||||||
|
return "native", NativeManager(config, root)
|
||||||
|
|
||||||
|
|
||||||
|
def _load(root: Path) -> ManageConfig:
|
||||||
|
return ManageConfig.from_cwd(root)
|
||||||
|
|
||||||
|
|
||||||
|
# ── commands ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def start(
|
||||||
|
service: Annotated[Optional[str], typer.Argument(help="Service name (omit for all)")] = None,
|
||||||
|
mode: Mode = Mode.auto,
|
||||||
|
root: Path = Path("."),
|
||||||
|
) -> None:
|
||||||
|
"""Start services."""
|
||||||
|
config = _load(root.resolve())
|
||||||
|
mode_name, mgr = _resolve(mode, root.resolve(), config)
|
||||||
|
typer.echo(f"[{config.app_name}] Starting ({mode_name} mode)…")
|
||||||
|
if isinstance(mgr, DockerManager):
|
||||||
|
mgr.start(service or "")
|
||||||
|
else:
|
||||||
|
started = mgr.start(service)
|
||||||
|
if started:
|
||||||
|
typer.echo(f"[{config.app_name}] Started: {', '.join(started)}")
|
||||||
|
else:
|
||||||
|
typer.echo(f"[{config.app_name}] All services already running")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def stop(
|
||||||
|
service: Annotated[Optional[str], typer.Argument(help="Service name (omit for all)")] = None,
|
||||||
|
mode: Mode = Mode.auto,
|
||||||
|
root: Path = Path("."),
|
||||||
|
) -> None:
|
||||||
|
"""Stop services."""
|
||||||
|
config = _load(root.resolve())
|
||||||
|
mode_name, mgr = _resolve(mode, root.resolve(), config)
|
||||||
|
typer.echo(f"[{config.app_name}] Stopping ({mode_name} mode)…")
|
||||||
|
if isinstance(mgr, DockerManager):
|
||||||
|
mgr.stop(service or "")
|
||||||
|
else:
|
||||||
|
stopped = mgr.stop(service)
|
||||||
|
if stopped:
|
||||||
|
typer.echo(f"[{config.app_name}] Stopped: {', '.join(stopped)}")
|
||||||
|
else:
|
||||||
|
typer.echo(f"[{config.app_name}] No running services to stop")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def restart(
|
||||||
|
service: Annotated[Optional[str], typer.Argument(help="Service name (omit for all)")] = None,
|
||||||
|
mode: Mode = Mode.auto,
|
||||||
|
root: Path = Path("."),
|
||||||
|
) -> None:
|
||||||
|
"""Restart services."""
|
||||||
|
config = _load(root.resolve())
|
||||||
|
mode_name, mgr = _resolve(mode, root.resolve(), config)
|
||||||
|
typer.echo(f"[{config.app_name}] Restarting ({mode_name} mode)…")
|
||||||
|
if isinstance(mgr, DockerManager):
|
||||||
|
mgr.restart(service or "")
|
||||||
|
else:
|
||||||
|
mgr.stop(service)
|
||||||
|
mgr.start(service)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def status(
|
||||||
|
mode: Mode = Mode.auto,
|
||||||
|
root: Path = Path("."),
|
||||||
|
) -> None:
|
||||||
|
"""Show service status."""
|
||||||
|
config = _load(root.resolve())
|
||||||
|
mode_name, mgr = _resolve(mode, root.resolve(), config)
|
||||||
|
if isinstance(mgr, DockerManager):
|
||||||
|
mgr.status()
|
||||||
|
else:
|
||||||
|
rows = mgr.status()
|
||||||
|
if not rows:
|
||||||
|
typer.echo(f"[{config.app_name}] No native services defined in manage.toml")
|
||||||
|
return
|
||||||
|
typer.echo(f"\n {config.app_name} — native services\n")
|
||||||
|
for svc in rows:
|
||||||
|
indicator = typer.style("●", fg=typer.colors.GREEN) if svc.running \
|
||||||
|
else typer.style("○", fg=typer.colors.RED)
|
||||||
|
pid_str = f" pid={svc.pid}" if svc.pid else ""
|
||||||
|
port_str = f" port={svc.port}" if svc.port else ""
|
||||||
|
typer.echo(f" {indicator} {svc.name:<20}{pid_str}{port_str}")
|
||||||
|
typer.echo("")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def logs(
|
||||||
|
service: Annotated[Optional[str], typer.Argument(help="Service name")] = None,
|
||||||
|
follow: bool = typer.Option(True, "--follow/--no-follow", "-f/-F"),
|
||||||
|
mode: Mode = Mode.auto,
|
||||||
|
root: Path = Path("."),
|
||||||
|
) -> None:
|
||||||
|
"""Tail service logs."""
|
||||||
|
config = _load(root.resolve())
|
||||||
|
mode_name, mgr = _resolve(mode, root.resolve(), config)
|
||||||
|
if isinstance(mgr, DockerManager):
|
||||||
|
mgr.logs(service or "", follow=follow)
|
||||||
|
else:
|
||||||
|
if not service:
|
||||||
|
# Default to first service when none specified
|
||||||
|
if not config.services:
|
||||||
|
typer.echo("No native services defined", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
service = config.services[0].name
|
||||||
|
mgr.logs(service, follow=follow)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def build(
|
||||||
|
no_cache: bool = False,
|
||||||
|
mode: Mode = Mode.auto,
|
||||||
|
root: Path = Path("."),
|
||||||
|
) -> None:
|
||||||
|
"""Build/rebuild service images (Docker mode only)."""
|
||||||
|
config = _load(root.resolve())
|
||||||
|
mode_name, mgr = _resolve(mode, root.resolve(), config)
|
||||||
|
if isinstance(mgr, NativeManager):
|
||||||
|
typer.echo("build is only available in Docker mode", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
typer.echo(f"[{config.app_name}] Building images…")
|
||||||
|
mgr.build(no_cache=no_cache)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("open")
|
||||||
|
def open_browser(
|
||||||
|
url: Annotated[Optional[str], typer.Option(help="Override URL")] = None,
|
||||||
|
root: Path = Path("."),
|
||||||
|
) -> None:
|
||||||
|
"""Open the product web UI in the default browser."""
|
||||||
|
config = _load(root.resolve())
|
||||||
|
target = url or config.default_url
|
||||||
|
if not target:
|
||||||
|
typer.echo("No URL configured. Set default_url in manage.toml or pass --url.", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
typer.echo(f"Opening {target}")
|
||||||
|
webbrowser.open(target)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("install-shims")
|
||||||
|
def install_shims(
|
||||||
|
root: Path = Path("."),
|
||||||
|
force: bool = typer.Option(False, "--force", help="Overwrite existing shims"),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Write manage.sh and manage.ps1 shims into the product directory.
|
||||||
|
|
||||||
|
The shims auto-detect the Python environment (conda, venv, or system Python)
|
||||||
|
and delegate all arguments to `python -m circuitforge_core.manage`.
|
||||||
|
"""
|
||||||
|
from importlib.resources import files as _res_files
|
||||||
|
|
||||||
|
target = root.resolve()
|
||||||
|
templates_pkg = "circuitforge_core.manage.templates"
|
||||||
|
|
||||||
|
for filename in ("manage.sh", "manage.ps1"):
|
||||||
|
dest = target / filename
|
||||||
|
if dest.exists() and not force:
|
||||||
|
typer.echo(f" skipped {filename} (already exists — use --force to overwrite)")
|
||||||
|
continue
|
||||||
|
content = (_res_files(templates_pkg) / filename).read_text()
|
||||||
|
dest.write_text(content)
|
||||||
|
if filename.endswith(".sh"):
|
||||||
|
dest.chmod(dest.stat().st_mode | 0o111) # make executable
|
||||||
|
typer.echo(f" wrote {dest}")
|
||||||
|
|
||||||
|
toml_example = target / "manage.toml.example"
|
||||||
|
if not toml_example.exists() or force:
|
||||||
|
content = (_res_files(templates_pkg) / "manage.toml.example").read_text()
|
||||||
|
toml_example.write_text(content)
|
||||||
|
typer.echo(f" wrote {toml_example}")
|
||||||
|
|
||||||
|
typer.echo("\nDone. Rename manage.toml.example → manage.toml and edit for your services.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
119
circuitforge_core/manage/config.py
Normal file
119
circuitforge_core/manage/config.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""
|
||||||
|
circuitforge_core.manage.config — ManageConfig parsed from manage.toml.
|
||||||
|
|
||||||
|
Products drop a manage.toml in their root directory. manage.py reads it to
|
||||||
|
discover the app name, compose file, and native service definitions.
|
||||||
|
|
||||||
|
Minimal manage.toml (Docker-only):
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
[app]
|
||||||
|
name = "kiwi"
|
||||||
|
default_url = "http://localhost:8511"
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
Full manage.toml (Docker + native services):
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
[app]
|
||||||
|
name = "kiwi"
|
||||||
|
default_url = "http://localhost:8511"
|
||||||
|
|
||||||
|
[docker]
|
||||||
|
compose_file = "compose.yml" # default
|
||||||
|
project = "kiwi" # defaults to app.name
|
||||||
|
|
||||||
|
[[native.services]]
|
||||||
|
name = "api"
|
||||||
|
command = "uvicorn app.main:app --host 0.0.0.0 --port 8512"
|
||||||
|
port = 8512
|
||||||
|
|
||||||
|
[[native.services]]
|
||||||
|
name = "frontend"
|
||||||
|
command = "npm run preview -- --host --port 8511"
|
||||||
|
port = 8511
|
||||||
|
cwd = "frontend"
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 11):
|
||||||
|
import tomllib
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import tomllib # type: ignore[no-redef]
|
||||||
|
except ImportError:
|
||||||
|
import tomli as tomllib # type: ignore[no-redef]
|
||||||
|
|
||||||
|
_DEFAULT_COMPOSE_FILE = "compose.yml"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NativeService:
|
||||||
|
"""One process to manage in native mode."""
|
||||||
|
name: str
|
||||||
|
command: str # shell command string
|
||||||
|
port: int = 0 # for status / open URL
|
||||||
|
cwd: str = "" # relative to project root; "" = root
|
||||||
|
env: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DockerConfig:
|
||||||
|
compose_file: str = _DEFAULT_COMPOSE_FILE
|
||||||
|
project: str = "" # docker compose -p; defaults to app name
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ManageConfig:
|
||||||
|
app_name: str
|
||||||
|
default_url: str = ""
|
||||||
|
docker: DockerConfig = field(default_factory=DockerConfig)
|
||||||
|
services: list[NativeService] = field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path) -> "ManageConfig":
|
||||||
|
"""Load from a manage.toml file."""
|
||||||
|
raw = tomllib.loads(path.read_text())
|
||||||
|
app = raw.get("app", {})
|
||||||
|
name = app.get("name") or path.parent.name # fallback to directory name
|
||||||
|
default_url = app.get("default_url", "")
|
||||||
|
|
||||||
|
docker_raw = raw.get("docker", {})
|
||||||
|
docker = DockerConfig(
|
||||||
|
compose_file=docker_raw.get("compose_file", _DEFAULT_COMPOSE_FILE),
|
||||||
|
project=docker_raw.get("project", name),
|
||||||
|
)
|
||||||
|
|
||||||
|
services: list[NativeService] = []
|
||||||
|
for svc in raw.get("native", {}).get("services", []):
|
||||||
|
services.append(NativeService(
|
||||||
|
name=svc["name"],
|
||||||
|
command=svc["command"],
|
||||||
|
port=svc.get("port", 0),
|
||||||
|
cwd=svc.get("cwd", ""),
|
||||||
|
env=svc.get("env", {}),
|
||||||
|
))
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
app_name=name,
|
||||||
|
default_url=default_url,
|
||||||
|
docker=docker,
|
||||||
|
services=services,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_cwd(cls, cwd: Path | None = None) -> "ManageConfig":
|
||||||
|
"""
|
||||||
|
Load from manage.toml in cwd, or return a minimal config derived from
|
||||||
|
the directory name if no manage.toml exists (Docker-only products work
|
||||||
|
without one).
|
||||||
|
"""
|
||||||
|
root = cwd or Path.cwd()
|
||||||
|
toml_path = root / "manage.toml"
|
||||||
|
if toml_path.exists():
|
||||||
|
return cls.load(toml_path)
|
||||||
|
# Fallback: infer from directory name, look for compose.yml
|
||||||
|
return cls(app_name=root.name)
|
||||||
115
circuitforge_core/manage/docker_mode.py
Normal file
115
circuitforge_core/manage/docker_mode.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""
|
||||||
|
circuitforge_core.manage.docker_mode — Docker Compose wrapper.
|
||||||
|
|
||||||
|
All commands delegate to `docker compose` (v2 plugin syntax).
|
||||||
|
Falls back to `docker-compose` (v1 standalone) if the plugin is unavailable.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import ManageConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_bin() -> list[str]:
|
||||||
|
"""Return the docker compose command as a list (handles v1/v2 difference)."""
|
||||||
|
# Docker Compose v2: `docker compose` (space, built-in plugin)
|
||||||
|
# Docker Compose v1: `docker-compose` (hyphen, standalone binary)
|
||||||
|
if shutil.which("docker"):
|
||||||
|
return ["docker", "compose"]
|
||||||
|
if shutil.which("docker-compose"):
|
||||||
|
return ["docker-compose"]
|
||||||
|
raise RuntimeError("Neither 'docker' nor 'docker-compose' found on PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def docker_available() -> bool:
|
||||||
|
"""Return True if Docker is reachable (docker info succeeds)."""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "info"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class DockerManager:
|
||||||
|
"""
|
||||||
|
Wraps `docker compose` for a single product directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: ManageConfig for the current product.
|
||||||
|
root: Product root directory (where compose file lives).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: ManageConfig, root: Path) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.root = root
|
||||||
|
self._compose_file = root / config.docker.compose_file
|
||||||
|
|
||||||
|
def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess: # type: ignore[type-arg]
|
||||||
|
cmd = [
|
||||||
|
*_compose_bin(),
|
||||||
|
"-f", str(self._compose_file),
|
||||||
|
"-p", self.config.docker.project or self.config.app_name,
|
||||||
|
*args,
|
||||||
|
]
|
||||||
|
return subprocess.run(cmd, cwd=self.root, check=check)
|
||||||
|
|
||||||
|
def _stream(self, *args: str) -> None:
|
||||||
|
"""Run a compose command, streaming output directly to the terminal."""
|
||||||
|
cmd = [
|
||||||
|
*_compose_bin(),
|
||||||
|
"-f", str(self._compose_file),
|
||||||
|
"-p", self.config.docker.project or self.config.app_name,
|
||||||
|
*args,
|
||||||
|
]
|
||||||
|
with subprocess.Popen(cmd, cwd=self.root) as proc:
|
||||||
|
try:
|
||||||
|
proc.wait()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
proc.terminate()
|
||||||
|
|
||||||
|
def compose_file_exists(self) -> bool:
|
||||||
|
return self._compose_file.exists()
|
||||||
|
|
||||||
|
def start(self, service: str = "") -> None:
|
||||||
|
args = ["up", "-d", "--build"]
|
||||||
|
if service:
|
||||||
|
args.append(service)
|
||||||
|
self._run(*args)
|
||||||
|
|
||||||
|
def stop(self, service: str = "") -> None:
|
||||||
|
if service:
|
||||||
|
self._run("stop", service)
|
||||||
|
else:
|
||||||
|
self._run("down")
|
||||||
|
|
||||||
|
def restart(self, service: str = "") -> None:
|
||||||
|
args = ["restart"]
|
||||||
|
if service:
|
||||||
|
args.append(service)
|
||||||
|
self._run(*args)
|
||||||
|
|
||||||
|
def status(self) -> None:
|
||||||
|
self._run("ps", check=False)
|
||||||
|
|
||||||
|
def logs(self, service: str = "", follow: bool = True) -> None:
|
||||||
|
args = ["logs"]
|
||||||
|
if follow:
|
||||||
|
args.append("-f")
|
||||||
|
if service:
|
||||||
|
args.append(service)
|
||||||
|
self._stream(*args)
|
||||||
|
|
||||||
|
def build(self, no_cache: bool = False) -> None:
|
||||||
|
args = ["build"]
|
||||||
|
if no_cache:
|
||||||
|
args.append("--no-cache")
|
||||||
|
self._run(*args)
|
||||||
217
circuitforge_core/manage/native_mode.py
Normal file
217
circuitforge_core/manage/native_mode.py
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
"""
|
||||||
|
circuitforge_core.manage.native_mode — PID-file process manager.
|
||||||
|
|
||||||
|
Manages processes directly without Docker. Designed for Windows (no WSL2,
|
||||||
|
no Docker), but works identically on Linux/macOS.
|
||||||
|
|
||||||
|
Platform conventions (via platformdirs):
|
||||||
|
PID files : user_runtime_dir(app_name) / <service>.pid
|
||||||
|
Log files : user_log_dir(app_name) / <service>.log
|
||||||
|
|
||||||
|
PID file format (one line each):
|
||||||
|
<pid>
|
||||||
|
<command_fingerprint> (first 80 chars of command — used to sanity-check
|
||||||
|
that the PID belongs to our process, not a recycled one)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from platformdirs import user_log_dir, user_runtime_dir
|
||||||
|
|
||||||
|
from .config import ManageConfig, NativeService
|
||||||
|
|
||||||
|
_IS_WINDOWS = platform.system() == "Windows"
|
||||||
|
_LOG_TAIL_LINES = 50
|
||||||
|
_FOLLOW_POLL_S = 0.25
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceStatus:
|
||||||
|
name: str
|
||||||
|
running: bool
|
||||||
|
pid: int | None
|
||||||
|
port: int
|
||||||
|
log_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
class NativeManager:
|
||||||
|
"""
|
||||||
|
Start, stop, and monitor native processes for a product.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: ManageConfig for the current product.
|
||||||
|
root: Product root directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: ManageConfig, root: Path) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.root = root
|
||||||
|
self._pid_dir = Path(user_runtime_dir(config.app_name, ensure_exists=True))
|
||||||
|
self._log_dir = Path(user_log_dir(config.app_name, ensure_exists=True))
|
||||||
|
|
||||||
|
# ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _pid_path(self, name: str) -> Path:
|
||||||
|
return self._pid_dir / f"{name}.pid"
|
||||||
|
|
||||||
|
def _log_path(self, name: str) -> Path:
|
||||||
|
return self._log_dir / f"{name}.log"
|
||||||
|
|
||||||
|
def _write_pid(self, name: str, pid: int, command: str) -> None:
|
||||||
|
self._pid_path(name).write_text(f"{pid}\n{command[:80]}\n")
|
||||||
|
|
||||||
|
def _read_pid(self, name: str) -> int | None:
|
||||||
|
p = self._pid_path(name)
|
||||||
|
if not p.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(p.read_text().splitlines()[0].strip())
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _pid_alive(self, pid: int) -> bool:
|
||||||
|
"""Return True if a process with this PID is currently running."""
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return str(pid) in result.stdout
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0) # signal 0 = existence check only
|
||||||
|
return True
|
||||||
|
except (OSError, ProcessLookupError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _kill(self, pid: int) -> None:
|
||||||
|
"""Terminate a process gracefully, then force-kill if needed."""
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
subprocess.run(["taskkill", "/F", "/PID", str(pid)],
|
||||||
|
capture_output=True)
|
||||||
|
else:
|
||||||
|
import signal
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
for _ in range(30): # wait up to 3 s
|
||||||
|
time.sleep(0.1)
|
||||||
|
if not self._pid_alive(pid):
|
||||||
|
return
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
except (OSError, ProcessLookupError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _svc(self, name: str) -> NativeService | None:
|
||||||
|
return next((s for s in self.config.services if s.name == name), None)
|
||||||
|
|
||||||
|
# ── public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_running(self, name: str) -> bool:
|
||||||
|
pid = self._read_pid(name)
|
||||||
|
return pid is not None and self._pid_alive(pid)
|
||||||
|
|
||||||
|
def status(self) -> list[ServiceStatus]:
|
||||||
|
result = []
|
||||||
|
for svc in self.config.services:
|
||||||
|
pid = self._read_pid(svc.name)
|
||||||
|
running = pid is not None and self._pid_alive(pid)
|
||||||
|
result.append(ServiceStatus(
|
||||||
|
name=svc.name,
|
||||||
|
running=running,
|
||||||
|
pid=pid if running else None,
|
||||||
|
port=svc.port,
|
||||||
|
log_path=self._log_path(svc.name),
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def start(self, name: str | None = None) -> list[str]:
|
||||||
|
"""Start one or all services. Returns list of started service names."""
|
||||||
|
targets = [self._svc(name)] if name else self.config.services
|
||||||
|
started: list[str] = []
|
||||||
|
for svc in targets:
|
||||||
|
if svc is None:
|
||||||
|
raise ValueError(f"Unknown service: {name!r}")
|
||||||
|
if self.is_running(svc.name):
|
||||||
|
continue
|
||||||
|
cwd = (self.root / svc.cwd) if svc.cwd else self.root
|
||||||
|
log_file = open(self._log_path(svc.name), "a") # noqa: WPS515
|
||||||
|
env = {**os.environ, **svc.env}
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
cmd = svc.command # Windows: pass as string to shell
|
||||||
|
shell = True
|
||||||
|
else:
|
||||||
|
cmd = shlex.split(svc.command)
|
||||||
|
shell = False
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
shell=shell,
|
||||||
|
stdout=log_file,
|
||||||
|
stderr=log_file,
|
||||||
|
start_new_session=True, # detach from terminal (Unix)
|
||||||
|
)
|
||||||
|
self._write_pid(svc.name, proc.pid, svc.command)
|
||||||
|
started.append(svc.name)
|
||||||
|
return started
|
||||||
|
|
||||||
|
def stop(self, name: str | None = None) -> list[str]:
|
||||||
|
"""Stop one or all services. Returns list of stopped service names."""
|
||||||
|
names = [name] if name else [s.name for s in self.config.services]
|
||||||
|
stopped: list[str] = []
|
||||||
|
for n in names:
|
||||||
|
pid = self._read_pid(n)
|
||||||
|
if pid and self._pid_alive(pid):
|
||||||
|
self._kill(pid)
|
||||||
|
stopped.append(n)
|
||||||
|
pid_path = self._pid_path(n)
|
||||||
|
if pid_path.exists():
|
||||||
|
pid_path.unlink()
|
||||||
|
return stopped
|
||||||
|
|
||||||
|
def logs(self, name: str, follow: bool = True, lines: int = _LOG_TAIL_LINES) -> None:
|
||||||
|
"""
|
||||||
|
Print the last N lines of a service log, then optionally follow.
|
||||||
|
|
||||||
|
Uses polling rather than `tail -f` so it works on Windows.
|
||||||
|
"""
|
||||||
|
log_path = self._log_path(name)
|
||||||
|
if not log_path.exists():
|
||||||
|
print(f"[{name}] No log file found at {log_path}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Print last N lines
|
||||||
|
content = log_path.read_bytes()
|
||||||
|
lines_data = content.splitlines()[-lines:]
|
||||||
|
for line in lines_data:
|
||||||
|
print(line.decode("utf-8", errors="replace"))
|
||||||
|
|
||||||
|
if not follow:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Poll for new content
|
||||||
|
offset = len(content)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(_FOLLOW_POLL_S)
|
||||||
|
new_size = log_path.stat().st_size
|
||||||
|
if new_size > offset:
|
||||||
|
with open(log_path, "rb") as f:
|
||||||
|
f.seek(offset)
|
||||||
|
chunk = f.read()
|
||||||
|
offset = new_size
|
||||||
|
for line in chunk.splitlines():
|
||||||
|
print(line.decode("utf-8", errors="replace"))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
0
circuitforge_core/manage/templates/__init__.py
Normal file
0
circuitforge_core/manage/templates/__init__.py
Normal file
30
circuitforge_core/manage/templates/manage.ps1
Normal file
30
circuitforge_core/manage/templates/manage.ps1
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# manage.ps1 — CircuitForge cross-platform product manager shim (Windows)
|
||||||
|
#
|
||||||
|
# Auto-detects the Python environment and delegates to
|
||||||
|
# `python -m circuitforge_core.manage`.
|
||||||
|
#
|
||||||
|
# Generated by: python -m circuitforge_core.manage install-shims
|
||||||
|
# Do not edit the logic here; edit manage.toml for product configuration.
|
||||||
|
|
||||||
|
# ── Python detection ──────────────────────────────────────────────────────────
|
||||||
|
$Python = $null
|
||||||
|
|
||||||
|
if (Test-Path ".venv\Scripts\python.exe") {
|
||||||
|
$Python = ".venv\Scripts\python.exe"
|
||||||
|
} elseif (Test-Path "venv\Scripts\python.exe") {
|
||||||
|
$Python = "venv\Scripts\python.exe"
|
||||||
|
} elseif ($env:CONDA_DEFAULT_ENV -and (Get-Command conda -ErrorAction SilentlyContinue)) {
|
||||||
|
# Conda: run via `conda run` so the env is activated correctly
|
||||||
|
& conda run -n $env:CONDA_DEFAULT_ENV python -m circuitforge_core.manage @args
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
} elseif (Get-Command python -ErrorAction SilentlyContinue) {
|
||||||
|
$Python = "python"
|
||||||
|
} elseif (Get-Command python3 -ErrorAction SilentlyContinue) {
|
||||||
|
$Python = "python3"
|
||||||
|
} else {
|
||||||
|
Write-Error "No Python interpreter found. Install Python 3.11+, activate a venv, or activate a conda environment."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
& $Python -m circuitforge_core.manage @args
|
||||||
|
exit $LASTEXITCODE
|
||||||
28
circuitforge_core/manage/templates/manage.sh
Normal file
28
circuitforge_core/manage/templates/manage.sh
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# manage.sh — CircuitForge cross-platform product manager shim
|
||||||
|
#
|
||||||
|
# Auto-detects the Python environment and delegates to
|
||||||
|
# `python -m circuitforge_core.manage`.
|
||||||
|
#
|
||||||
|
# Generated by: python -m circuitforge_core.manage install-shims
|
||||||
|
# Do not edit the logic here; edit manage.toml for product configuration.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Python detection ──────────────────────────────────────────────────────────
|
||||||
|
if [ -f ".venv/bin/python" ]; then
|
||||||
|
PYTHON=".venv/bin/python"
|
||||||
|
elif [ -f "venv/bin/python" ]; then
|
||||||
|
PYTHON="venv/bin/python"
|
||||||
|
elif command -v conda &>/dev/null && [ -n "${CONDA_DEFAULT_ENV:-}" ]; then
|
||||||
|
PYTHON="conda run -n ${CONDA_DEFAULT_ENV} python"
|
||||||
|
elif command -v python3 &>/dev/null; then
|
||||||
|
PYTHON="python3"
|
||||||
|
elif command -v python &>/dev/null; then
|
||||||
|
PYTHON="python"
|
||||||
|
else
|
||||||
|
echo "ERROR: No Python interpreter found." >&2
|
||||||
|
echo "Install Python 3.11+, activate a venv, or activate a conda environment." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec $PYTHON -m circuitforge_core.manage "$@"
|
||||||
38
circuitforge_core/manage/templates/manage.toml.example
Normal file
38
circuitforge_core/manage/templates/manage.toml.example
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# manage.toml — CircuitForge product manager configuration
|
||||||
|
#
|
||||||
|
# Drop this file (renamed from manage.toml.example) in your product root.
|
||||||
|
# Docker-only products only need [app]; native services require [[native.services]].
|
||||||
|
|
||||||
|
[app]
|
||||||
|
# Product name — used for log/PID directory names and display.
|
||||||
|
name = "myproduct"
|
||||||
|
|
||||||
|
# URL opened by `manage.py open`. Typically the frontend port.
|
||||||
|
default_url = "http://localhost:8511"
|
||||||
|
|
||||||
|
[docker]
|
||||||
|
# Path to the Docker Compose file, relative to this directory.
|
||||||
|
compose_file = "compose.yml"
|
||||||
|
|
||||||
|
# Docker Compose project name (defaults to app.name).
|
||||||
|
# project = "myproduct"
|
||||||
|
|
||||||
|
# ── Native mode services ───────────────────────────────────────────────────────
|
||||||
|
# Define one [[native.services]] block per process.
|
||||||
|
# Used when Docker is unavailable (Windows without Docker, or --mode native).
|
||||||
|
|
||||||
|
[[native.services]]
|
||||||
|
name = "api"
|
||||||
|
# Full shell command to launch the backend.
|
||||||
|
command = "uvicorn app.main:app --host 0.0.0.0 --port 8512 --reload"
|
||||||
|
port = 8512
|
||||||
|
# cwd is relative to the project root. Leave empty for the project root itself.
|
||||||
|
cwd = ""
|
||||||
|
# Optional extra environment variables.
|
||||||
|
# env = { PYTHONPATH = ".", DEBUG = "1" }
|
||||||
|
|
||||||
|
[[native.services]]
|
||||||
|
name = "frontend"
|
||||||
|
command = "npm run preview -- --host 0.0.0.0 --port 8511"
|
||||||
|
port = 8511
|
||||||
|
cwd = "frontend"
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "circuitforge-core"
|
name = "circuitforge-core"
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
description = "Shared scaffold for CircuitForge products"
|
description = "Shared scaffold for CircuitForge products"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
@ -25,9 +25,14 @@ orch = [
|
||||||
tasks = [
|
tasks = [
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
]
|
]
|
||||||
|
manage = [
|
||||||
|
"platformdirs>=4.0",
|
||||||
|
"typer[all]>=0.12",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"circuitforge-core[orch]",
|
"circuitforge-core[orch]",
|
||||||
"circuitforge-core[tasks]",
|
"circuitforge-core[tasks]",
|
||||||
|
"circuitforge-core[manage]",
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pytest-asyncio>=0.23",
|
"pytest-asyncio>=0.23",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
|
@ -35,6 +40,7 @@ dev = [
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
cf-orch = "circuitforge_core.resources.cli:app"
|
cf-orch = "circuitforge_core.resources.cli:app"
|
||||||
|
cf-manage = "circuitforge_core.manage.cli:app"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
|
|
|
||||||
0
tests/test_manage/__init__.py
Normal file
0
tests/test_manage/__init__.py
Normal file
98
tests/test_manage/test_config.py
Normal file
98
tests/test_manage/test_config.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# tests/test_manage/test_config.py
|
||||||
|
"""Unit tests for ManageConfig TOML parsing."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from circuitforge_core.manage.config import DockerConfig, ManageConfig, NativeService
|
||||||
|
|
||||||
|
|
||||||
|
def _write_toml(tmp_path: Path, content: str) -> Path:
|
||||||
|
p = tmp_path / "manage.toml"
|
||||||
|
p.write_text(content)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimal_config(tmp_path: Path) -> None:
|
||||||
|
_write_toml(tmp_path, """
|
||||||
|
[app]
|
||||||
|
name = "kiwi"
|
||||||
|
default_url = "http://localhost:8511"
|
||||||
|
""")
|
||||||
|
cfg = ManageConfig.from_cwd(tmp_path)
|
||||||
|
assert cfg.app_name == "kiwi"
|
||||||
|
assert cfg.default_url == "http://localhost:8511"
|
||||||
|
assert cfg.services == []
|
||||||
|
assert cfg.docker.compose_file == "compose.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_section(tmp_path: Path) -> None:
|
||||||
|
_write_toml(tmp_path, """
|
||||||
|
[app]
|
||||||
|
name = "peregrine"
|
||||||
|
|
||||||
|
[docker]
|
||||||
|
compose_file = "compose.prod.yml"
|
||||||
|
project = "prng"
|
||||||
|
""")
|
||||||
|
cfg = ManageConfig.from_cwd(tmp_path)
|
||||||
|
assert cfg.docker.compose_file == "compose.prod.yml"
|
||||||
|
assert cfg.docker.project == "prng"
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_services(tmp_path: Path) -> None:
|
||||||
|
_write_toml(tmp_path, """
|
||||||
|
[app]
|
||||||
|
name = "snipe"
|
||||||
|
default_url = "http://localhost:8509"
|
||||||
|
|
||||||
|
[[native.services]]
|
||||||
|
name = "api"
|
||||||
|
command = "uvicorn app.main:app --port 8510"
|
||||||
|
port = 8510
|
||||||
|
|
||||||
|
[[native.services]]
|
||||||
|
name = "frontend"
|
||||||
|
command = "npm run preview"
|
||||||
|
port = 8509
|
||||||
|
cwd = "frontend"
|
||||||
|
""")
|
||||||
|
cfg = ManageConfig.from_cwd(tmp_path)
|
||||||
|
assert len(cfg.services) == 2
|
||||||
|
api = cfg.services[0]
|
||||||
|
assert api.name == "api"
|
||||||
|
assert api.port == 8510
|
||||||
|
assert api.cwd == ""
|
||||||
|
fe = cfg.services[1]
|
||||||
|
assert fe.name == "frontend"
|
||||||
|
assert fe.cwd == "frontend"
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_service_env(tmp_path: Path) -> None:
|
||||||
|
_write_toml(tmp_path, """
|
||||||
|
[app]
|
||||||
|
name = "avocet"
|
||||||
|
|
||||||
|
[[native.services]]
|
||||||
|
name = "api"
|
||||||
|
command = "uvicorn app.main:app --port 8503"
|
||||||
|
port = 8503
|
||||||
|
env = { PYTHONPATH = ".", DEBUG = "1" }
|
||||||
|
""")
|
||||||
|
cfg = ManageConfig.from_cwd(tmp_path)
|
||||||
|
assert cfg.services[0].env == {"PYTHONPATH": ".", "DEBUG": "1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_cwd_no_toml_falls_back_to_dirname(tmp_path: Path) -> None:
|
||||||
|
"""No manage.toml → app_name inferred from directory name."""
|
||||||
|
cfg = ManageConfig.from_cwd(tmp_path)
|
||||||
|
assert cfg.app_name == tmp_path.name
|
||||||
|
assert cfg.services == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_project_defaults_to_app_name(tmp_path: Path) -> None:
|
||||||
|
_write_toml(tmp_path, "[app]\nname = \"osprey\"\n")
|
||||||
|
cfg = ManageConfig.from_cwd(tmp_path)
|
||||||
|
assert cfg.docker.project == "osprey"
|
||||||
118
tests/test_manage/test_docker_mode.py
Normal file
118
tests/test_manage/test_docker_mode.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
# tests/test_manage/test_docker_mode.py
|
||||||
|
"""Unit tests for DockerManager — compose wrapper."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from circuitforge_core.manage.config import DockerConfig, ManageConfig
|
||||||
|
from circuitforge_core.manage.docker_mode import DockerManager, docker_available
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(compose_file: str = "compose.yml", project: str = "testapp") -> ManageConfig:
|
||||||
|
return ManageConfig(
|
||||||
|
app_name="testapp",
|
||||||
|
docker=DockerConfig(compose_file=compose_file, project=project),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mgr(tmp_path: Path) -> DockerManager:
|
||||||
|
(tmp_path / "compose.yml").write_text("version: '3'")
|
||||||
|
return DockerManager(_cfg(), root=tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ── docker_available ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_docker_available_true() -> None:
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
assert docker_available() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_available_false_on_exception() -> None:
|
||||||
|
with patch("subprocess.run", side_effect=FileNotFoundError):
|
||||||
|
assert docker_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── compose_file_exists ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_compose_file_exists(mgr: DockerManager, tmp_path: Path) -> None:
|
||||||
|
assert mgr.compose_file_exists() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_file_missing(tmp_path: Path) -> None:
|
||||||
|
m = DockerManager(_cfg(compose_file="nope.yml"), root=tmp_path)
|
||||||
|
assert m.compose_file_exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── start / stop / restart / status ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_start_runs_up(mgr: DockerManager) -> None:
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mgr.start()
|
||||||
|
args = mock_run.call_args[0][0]
|
||||||
|
assert "up" in args
|
||||||
|
assert "-d" in args
|
||||||
|
assert "--build" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_specific_service(mgr: DockerManager) -> None:
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mgr.start("api")
|
||||||
|
args = mock_run.call_args[0][0]
|
||||||
|
assert "api" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_all_runs_down(mgr: DockerManager) -> None:
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mgr.stop()
|
||||||
|
args = mock_run.call_args[0][0]
|
||||||
|
assert "down" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_service_runs_stop(mgr: DockerManager) -> None:
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mgr.stop("frontend")
|
||||||
|
args = mock_run.call_args[0][0]
|
||||||
|
assert "stop" in args
|
||||||
|
assert "frontend" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_runs_restart(mgr: DockerManager) -> None:
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mgr.restart()
|
||||||
|
args = mock_run.call_args[0][0]
|
||||||
|
assert "restart" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_runs_ps(mgr: DockerManager) -> None:
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mgr.status()
|
||||||
|
args = mock_run.call_args[0][0]
|
||||||
|
assert "ps" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_no_cache(mgr: DockerManager) -> None:
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mgr.build(no_cache=True)
|
||||||
|
args = mock_run.call_args[0][0]
|
||||||
|
assert "build" in args
|
||||||
|
assert "--no-cache" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_project_flag_included(mgr: DockerManager) -> None:
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mgr.start()
|
||||||
|
args = mock_run.call_args[0][0]
|
||||||
|
assert "-p" in args
|
||||||
|
assert "testapp" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_file_flag_included(mgr: DockerManager, tmp_path: Path) -> None:
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mgr.start()
|
||||||
|
args = mock_run.call_args[0][0]
|
||||||
|
assert "-f" in args
|
||||||
176
tests/test_manage/test_native_mode.py
Normal file
176
tests/test_manage/test_native_mode.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
# tests/test_manage/test_native_mode.py
|
||||||
|
"""Unit tests for NativeManager — PID file process management."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from circuitforge_core.manage.config import ManageConfig, NativeService
|
||||||
|
from circuitforge_core.manage.native_mode import NativeManager
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(*services: NativeService) -> ManageConfig:
|
||||||
|
return ManageConfig(app_name="testapp", services=list(services))
|
||||||
|
|
||||||
|
|
||||||
|
def _svc(name: str = "api", port: int = 8000) -> NativeService:
|
||||||
|
return NativeService(name=name, command="python -c 'import time; time.sleep(999)'", port=port)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mgr(tmp_path: Path) -> NativeManager:
|
||||||
|
cfg = _cfg(_svc("api", 8000), _svc("frontend", 8001))
|
||||||
|
m = NativeManager(cfg, root=tmp_path)
|
||||||
|
# redirect pid/log dirs into tmp_path for test isolation
|
||||||
|
m._pid_dir = tmp_path / "pids"
|
||||||
|
m._log_dir = tmp_path / "logs"
|
||||||
|
m._pid_dir.mkdir()
|
||||||
|
m._log_dir.mkdir()
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
# ── PID file helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_write_and_read_pid(mgr: NativeManager, tmp_path: Path) -> None:
|
||||||
|
mgr._write_pid("api", 12345, "python server.py")
|
||||||
|
assert mgr._read_pid("api") == 12345
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_pid_missing_returns_none(mgr: NativeManager) -> None:
|
||||||
|
assert mgr._read_pid("nonexistent") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_pid_corrupt_returns_none(mgr: NativeManager, tmp_path: Path) -> None:
|
||||||
|
mgr._pid_dir.mkdir(exist_ok=True)
|
||||||
|
(mgr._pid_dir / "api.pid").write_text("notanumber\n")
|
||||||
|
assert mgr._read_pid("api") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── is_running ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_is_running_true_when_pid_alive(mgr: NativeManager) -> None:
|
||||||
|
mgr._write_pid("api", os.getpid(), "test") # use current PID — guaranteed alive
|
||||||
|
assert mgr.is_running("api") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_running_false_when_no_pid_file(mgr: NativeManager) -> None:
|
||||||
|
assert mgr.is_running("api") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_running_false_when_pid_dead(mgr: NativeManager) -> None:
|
||||||
|
mgr._write_pid("api", 999999999, "dead") # very unlikely PID
|
||||||
|
with patch.object(mgr, "_pid_alive", return_value=False):
|
||||||
|
assert mgr.is_running("api") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── start ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_start_spawns_process(mgr: NativeManager, tmp_path: Path) -> None:
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.pid = 42
|
||||||
|
|
||||||
|
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||||
|
started = mgr.start("api")
|
||||||
|
|
||||||
|
assert "api" in started
|
||||||
|
mock_popen.assert_called_once()
|
||||||
|
assert mgr._read_pid("api") == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_all_spawns_all_services(mgr: NativeManager) -> None:
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.pid = 99
|
||||||
|
|
||||||
|
with patch("subprocess.Popen", return_value=mock_proc):
|
||||||
|
started = mgr.start() # no name = all services
|
||||||
|
|
||||||
|
assert set(started) == {"api", "frontend"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_skips_already_running(mgr: NativeManager) -> None:
|
||||||
|
mgr._write_pid("api", os.getpid(), "test") # current PID = alive
|
||||||
|
|
||||||
|
with patch("subprocess.Popen") as mock_popen:
|
||||||
|
started = mgr.start("api")
|
||||||
|
|
||||||
|
mock_popen.assert_not_called()
|
||||||
|
assert "api" not in started
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_unknown_service_raises(mgr: NativeManager) -> None:
|
||||||
|
with pytest.raises(ValueError, match="Unknown service"):
|
||||||
|
mgr.start("doesnotexist")
|
||||||
|
|
||||||
|
|
||||||
|
# ── stop ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_stop_kills_and_removes_pid_file(mgr: NativeManager) -> None:
|
||||||
|
mgr._write_pid("api", 55555, "test")
|
||||||
|
|
||||||
|
with patch.object(mgr, "_pid_alive", return_value=True), \
|
||||||
|
patch.object(mgr, "_kill") as mock_kill:
|
||||||
|
stopped = mgr.stop("api")
|
||||||
|
|
||||||
|
assert "api" in stopped
|
||||||
|
mock_kill.assert_called_once_with(55555)
|
||||||
|
assert not mgr._pid_path("api").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_all(mgr: NativeManager) -> None:
|
||||||
|
mgr._write_pid("api", 1001, "test")
|
||||||
|
mgr._write_pid("frontend", 1002, "test")
|
||||||
|
|
||||||
|
with patch.object(mgr, "_pid_alive", return_value=True), \
|
||||||
|
patch.object(mgr, "_kill"):
|
||||||
|
stopped = mgr.stop()
|
||||||
|
|
||||||
|
assert set(stopped) == {"api", "frontend"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_not_running_returns_empty(mgr: NativeManager) -> None:
|
||||||
|
# No PID file → nothing to stop
|
||||||
|
stopped = mgr.stop("api")
|
||||||
|
assert stopped == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── status ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_status_running(mgr: NativeManager) -> None:
|
||||||
|
mgr._write_pid("api", os.getpid(), "test")
|
||||||
|
|
||||||
|
rows = mgr.status()
|
||||||
|
api_row = next(r for r in rows if r.name == "api")
|
||||||
|
assert api_row.running is True
|
||||||
|
assert api_row.pid == os.getpid()
|
||||||
|
assert api_row.port == 8000
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_stopped(mgr: NativeManager) -> None:
|
||||||
|
rows = mgr.status()
|
||||||
|
for row in rows:
|
||||||
|
assert row.running is False
|
||||||
|
assert row.pid is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── logs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_logs_prints_last_lines(mgr: NativeManager, capsys: pytest.CaptureFixture) -> None: # type: ignore[type-arg]
|
||||||
|
log = mgr._log_path("api")
|
||||||
|
log.write_text("\n".join(f"line {i}" for i in range(100)))
|
||||||
|
|
||||||
|
mgr.logs("api", follow=False, lines=10)
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "line 99" in out
|
||||||
|
assert "line 90" in out
|
||||||
|
assert "line 89" not in out # only last 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_logs_missing_file_prints_warning(mgr: NativeManager, capsys: pytest.CaptureFixture) -> None: # type: ignore[type-arg]
|
||||||
|
mgr.logs("api", follow=False)
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "No log file" in err
|
||||||
Loading…
Reference in a new issue