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
|
# 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 #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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] archetype=linux-server 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 => (
|
||||||
|
|||||||
Reference in New Issue
Block a user