diff --git a/decnet/cli.py b/decnet/cli.py index 3dab2c4..da91070 100644 --- a/decnet/cli.py +++ b/decnet/cli.py @@ -142,11 +142,7 @@ def _build_deckies_from_ini( # Resolve archetype (if any) — explicit services/distro override it arch: Archetype | None = None if spec.archetype: - try: - arch = get_archetype(spec.archetype) - except ValueError as e: - console.print(f"[red]{e}[/]") - raise typer.Exit(1) + arch = get_archetype(spec.archetype) # Distro: archetype preferred list → random → global cycle distro_pool = arch.preferred_distros if arch else list(all_distros().keys()) @@ -155,19 +151,16 @@ def _build_deckies_from_ini( ip = spec.ip or next(auto_pool, None) if ip is None: - raise RuntimeError( - f"Not enough free IPs in {subnet_cidr} while assigning IP for '{spec.name}'." - ) + raise ValueError(f"Not enough free IPs in {subnet_cidr} while assigning IP for '{spec.name}'.") if spec.services: known = set(_all_service_names()) unknown = [s for s in spec.services if s not in known] if unknown: - console.print( - f"[red]Unknown service(s) in [{spec.name}]: {unknown}. " - f"Available: {_all_service_names()}[/]" + raise ValueError( + f"Unknown service(s) in [{spec.name}]: {unknown}. " + f"Available: {_all_service_names()}" ) - raise typer.Exit(1) svc_list = spec.services elif arch: svc_list = list(arch.services) @@ -176,11 +169,10 @@ def _build_deckies_from_ini( count = random.randint(1, min(3, len(svc_pool))) svc_list = random.sample(svc_pool, count) else: - console.print( - f"[red]Decky '[{spec.name}]' has no services= in config. " - "Add services=, archetype=, or use --randomize-services.[/]" + raise ValueError( + f"Decky '[{spec.name}]' has no services= in config. " + "Add services=, archetype=, or use --randomize-services." ) - raise typer.Exit(1) # nmap_os priority: explicit INI key > archetype default > "linux" resolved_nmap_os = spec.nmap_os or (arch.nmap_os if arch else "linux") @@ -207,6 +199,30 @@ def _build_deckies_from_ini( return deckies +@app.command() +def api( + port: int = typer.Option(8000, "--port", help="Port for the backend API"), + log_file: str = typer.Option("/var/log/decnet/decnet.log", "--log-file", help="Path to the DECNET log file to monitor"), +) -> None: + """Run the DECNET API and Web Dashboard in standalone mode.""" + import subprocess + import sys + import os + + console.print(f"[green]Starting DECNET API on port {port}...[/]") + _env: dict[str, str] = os.environ.copy() + _env["DECNET_INGEST_LOG_FILE"] = str(log_file) + try: + subprocess.run( + [sys.executable, "-m", "uvicorn", "decnet.web.api:app", "--host", "0.0.0.0", "--port", str(port)], + env=_env + ) + except KeyboardInterrupt: + pass + except (FileNotFoundError, subprocess.SubprocessError): + console.print("[red]Failed to start API. Ensure 'uvicorn' is installed in the current environment.[/]") + + @app.command() def deploy( mode: str = typer.Option("unihost", "--mode", "-m", help="Deployment mode: unihost | swarm"), @@ -275,9 +291,13 @@ def deploy( effective_log_target = log_target or ini.log_target effective_log_file = log_file - decky_configs = _build_deckies_from_ini( - ini, subnet_cidr, effective_gateway, host_ip, randomize_services, cli_mutate_interval=mutate_interval - ) + try: + decky_configs = _build_deckies_from_ini( + ini, subnet_cidr, effective_gateway, host_ip, randomize_services, cli_mutate_interval=mutate_interval + ) + except ValueError as e: + console.print(f"[red]{e}[/]") + raise typer.Exit(1) # ------------------------------------------------------------------ # # Classic CLI path # # ------------------------------------------------------------------ # diff --git a/decnet/ini_loader.py b/decnet/ini_loader.py index de8fd71..91002c9 100644 --- a/decnet/ini_loader.py +++ b/decnet/ini_loader.py @@ -83,7 +83,17 @@ def load_ini(path: str | Path) -> IniConfig: read = cp.read(str(path)) if not read: raise FileNotFoundError(f"Config file not found: {path}") + return _parse_configparser(cp) + +def load_ini_from_string(content: str) -> IniConfig: + """Parse a DECNET INI string and return an IniConfig.""" + cp = configparser.ConfigParser() + cp.read_string(content) + return _parse_configparser(cp) + + +def _parse_configparser(cp: configparser.ConfigParser) -> IniConfig: cfg = IniConfig() if cp.has_section("general"): diff --git a/decnet/web/api.py b/decnet/web/api.py index fbfe834..8a22897 100644 --- a/decnet/web/api.py +++ b/decnet/web/api.py @@ -265,3 +265,74 @@ async def stream_events( await asyncio.sleep(1) return StreamingResponse(event_generator(), media_type="text/event-stream") + + +class DeployIniRequest(BaseModel): + ini_content: str + +@app.post("/api/v1/deckies/deploy") +async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]: + from decnet.ini_loader import load_ini_from_string + from decnet.cli import _build_deckies_from_ini + from decnet.config import load_state, DecnetConfig, DEFAULT_MUTATE_INTERVAL + from decnet.network import detect_interface, detect_subnet, get_host_ip + from decnet.deployer import deploy as _deploy + import logging + + try: + ini = load_ini_from_string(req.ini_content) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to parse INI: {e}") + + state = load_state() + + if state: + config, _ = state + subnet_cidr = ini.subnet or config.subnet + gateway = ini.gateway or config.gateway + host_ip = get_host_ip(config.interface) + randomize_services = False + else: + # If no state exists, we need to infer network details + iface = ini.interface or detect_interface() + subnet_cidr, gateway = ini.subnet, ini.gateway + if not subnet_cidr or not gateway: + detected_subnet, detected_gateway = detect_subnet(iface) + subnet_cidr = subnet_cidr or detected_subnet + gateway = gateway or detected_gateway + host_ip = get_host_ip(iface) + randomize_services = False + config = DecnetConfig( + mode="unihost", + interface=iface, + subnet=subnet_cidr, + gateway=gateway, + deckies=[], + log_target=ini.log_target, + log_file=None, # In API mode, uvicorn usually handles this + ipvlan=False, + mutate_interval=ini.mutate_interval or DEFAULT_MUTATE_INTERVAL + ) + + try: + new_decky_configs = _build_deckies_from_ini( + ini, subnet_cidr, gateway, host_ip, randomize_services, cli_mutate_interval=None + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Merge deckies + existing_deckies_map = {d.name: d for d in config.deckies} + for new_decky in new_decky_configs: + existing_deckies_map[new_decky.name] = new_decky + + config.deckies = list(existing_deckies_map.values()) + + # We call deploy(config) which regenerates docker-compose and runs `up -d --remove-orphans`. + try: + _deploy(config) + except Exception as e: + logging.getLogger("decnet.web.api").error(f"Deployment failed: {e}") + raise HTTPException(status_code=500, detail=f"Deployment failed: {e}") + + return {"message": "Deckies deployed successfully"} diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 96f7e8a..43bcce2 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -19,6 +19,9 @@ const DeckyFleet: React.FC = () => { const [deckies, setDeckies] = useState([]); const [loading, setLoading] = useState(true); const [mutating, setMutating] = useState(null); + const [showDeploy, setShowDeploy] = useState(false); + const [iniContent, setIniContent] = useState(''); + const [deploying, setDeploying] = useState(false); const fetchDeckies = async () => { try { @@ -61,6 +64,22 @@ const DeckyFleet: React.FC = () => { } }; + const handleDeploy = async () => { + if (!iniContent.trim()) return; + setDeploying(true); + try { + await api.post('/deckies/deploy', { ini_content: iniContent }, { timeout: 120000 }); + setIniContent(''); + setShowDeploy(false); + fetchDeckies(); + } catch (err: any) { + console.error('Deploy failed', err); + alert(`Deploy failed: ${err.response?.data?.detail || err.message}`); + } finally { + setDeploying(false); + } + }; + useEffect(() => { fetchDeckies(); const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs @@ -71,11 +90,38 @@ const DeckyFleet: React.FC = () => { return (
-
- -

DECOY FLEET ASSET INVENTORY

+
+
+ +

DECOY FLEET ASSET INVENTORY

+
+
+ {showDeploy && ( +
+

Deploy via INI Configuration

+