feat: add API-only mode and web-based INI deployment

This commit is contained in:
2026-04-08 00:56:25 -04:00
parent db9a2699b9
commit 168ecf14ab
4 changed files with 169 additions and 22 deletions

View File

@@ -142,11 +142,7 @@ def _build_deckies_from_ini(
# Resolve archetype (if any) — explicit services/distro override it # Resolve archetype (if any) — explicit services/distro override it
arch: Archetype | None = None arch: Archetype | None = None
if spec.archetype: if spec.archetype:
try:
arch = get_archetype(spec.archetype) arch = get_archetype(spec.archetype)
except ValueError as e:
console.print(f"[red]{e}[/]")
raise typer.Exit(1)
# Distro: archetype preferred list → random → global cycle # Distro: archetype preferred list → random → global cycle
distro_pool = arch.preferred_distros if arch else list(all_distros().keys()) 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) ip = spec.ip or next(auto_pool, None)
if ip is None: if ip is None:
raise RuntimeError( raise ValueError(f"Not enough free IPs in {subnet_cidr} while assigning IP for '{spec.name}'.")
f"Not enough free IPs in {subnet_cidr} while assigning IP for '{spec.name}'."
)
if spec.services: if spec.services:
known = set(_all_service_names()) known = set(_all_service_names())
unknown = [s for s in spec.services if s not in known] unknown = [s for s in spec.services if s not in known]
if unknown: if unknown:
console.print( raise ValueError(
f"[red]Unknown service(s) in [{spec.name}]: {unknown}. " f"Unknown service(s) in [{spec.name}]: {unknown}. "
f"Available: {_all_service_names()}[/]" f"Available: {_all_service_names()}"
) )
raise typer.Exit(1)
svc_list = spec.services svc_list = spec.services
elif arch: elif arch:
svc_list = list(arch.services) svc_list = list(arch.services)
@@ -176,11 +169,10 @@ def _build_deckies_from_ini(
count = random.randint(1, min(3, len(svc_pool))) count = random.randint(1, min(3, len(svc_pool)))
svc_list = random.sample(svc_pool, count) svc_list = random.sample(svc_pool, count)
else: else:
console.print( raise ValueError(
f"[red]Decky '[{spec.name}]' has no services= in config. " f"Decky '[{spec.name}]' has no services= in config. "
"Add services=, archetype=, or use --randomize-services.[/]" "Add services=, archetype=, or use --randomize-services."
) )
raise typer.Exit(1)
# nmap_os priority: explicit INI key > archetype default > "linux" # nmap_os priority: explicit INI key > archetype default > "linux"
resolved_nmap_os = spec.nmap_os or (arch.nmap_os if arch else "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 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() @app.command()
def deploy( def deploy(
mode: str = typer.Option("unihost", "--mode", "-m", help="Deployment mode: unihost | swarm"), 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_target = log_target or ini.log_target
effective_log_file = log_file effective_log_file = log_file
try:
decky_configs = _build_deckies_from_ini( decky_configs = _build_deckies_from_ini(
ini, subnet_cidr, effective_gateway, host_ip, randomize_services, cli_mutate_interval=mutate_interval 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 # # Classic CLI path #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -83,7 +83,17 @@ def load_ini(path: str | Path) -> IniConfig:
read = cp.read(str(path)) read = cp.read(str(path))
if not read: if not read:
raise FileNotFoundError(f"Config file not found: {path}") 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() cfg = IniConfig()
if cp.has_section("general"): if cp.has_section("general"):

View File

@@ -265,3 +265,74 @@ async def stream_events(
await asyncio.sleep(1) await asyncio.sleep(1)
return StreamingResponse(event_generator(), media_type="text/event-stream") 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"}

View File

@@ -19,6 +19,9 @@ const DeckyFleet: React.FC = () => {
const [deckies, setDeckies] = useState<Decky[]>([]); const [deckies, setDeckies] = useState<Decky[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [mutating, setMutating] = useState<string | null>(null); const [mutating, setMutating] = useState<string | null>(null);
const [showDeploy, setShowDeploy] = useState(false);
const [iniContent, setIniContent] = useState('');
const [deploying, setDeploying] = useState(false);
const fetchDeckies = async () => { const fetchDeckies = async () => {
try { 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(() => { useEffect(() => {
fetchDeckies(); fetchDeckies();
const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs
@@ -71,10 +90,37 @@ const DeckyFleet: React.FC = () => {
return ( return (
<div className="dashboard"> <div className="dashboard">
<div className="section-header" style={{ border: '1px solid var(--border-color)', backgroundColor: 'var(--secondary-color)', marginBottom: '24px' }}> <div className="section-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', border: '1px solid var(--border-color)', backgroundColor: 'var(--secondary-color)', marginBottom: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Server size={20} /> <Server size={20} />
<h2 style={{ margin: 0 }}>DECOY FLEET ASSET INVENTORY</h2> <h2 style={{ margin: 0 }}>DECOY FLEET ASSET INVENTORY</h2>
</div> </div>
<button
onClick={() => setShowDeploy(!showDeploy)}
style={{ display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid var(--accent-color)', color: 'var(--accent-color)' }}
>
+ DEPLOY DECKIES
</button>
</div>
{showDeploy && (
<div style={{ marginBottom: '24px', padding: '24px', backgroundColor: 'var(--secondary-color)', border: '1px solid var(--accent-color)', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<h3 style={{ fontSize: '1rem', color: 'var(--text-color)' }}>Deploy via INI Configuration</h3>
<textarea
value={iniContent}
onChange={(e) => setIniContent(e.target.value)}
placeholder="[decky-01]&#10;archetype=linux-server&#10;services=ssh,http"
style={{ width: '100%', height: '200px', backgroundColor: '#000', color: 'var(--text-color)', border: '1px solid var(--border-color)', padding: '12px', fontFamily: 'monospace' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button onClick={() => setShowDeploy(false)} style={{ border: '1px solid var(--border-color)', color: 'var(--dim-color)' }}>CANCEL</button>
<button onClick={handleDeploy} disabled={deploying} style={{ background: 'var(--accent-color)', color: '#000', border: 'none', display: 'flex', alignItems: 'center', gap: '8px' }}>
{deploying && <RefreshCw size={14} className="spin" />}
{deploying ? 'DEPLOYING...' : 'DEPLOY'}
</button>
</div>
</div>
)}
<div className="deckies-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '24px' }}> <div className="deckies-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '24px' }}>
{deckies.length > 0 ? deckies.map(decky => ( {deckies.length > 0 ? deckies.map(decky => (