feat: add API-only mode and web-based INI deployment
This commit is contained in:
@@ -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 #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user