feat: VM lifecycle, TTS, inventory endpoints
This commit is contained in:
parent
dfb4e1a485
commit
c875e2b185
1 changed files with 241 additions and 0 deletions
241
app.py
241
app.py
|
|
@ -108,6 +108,10 @@ SERVICES = {
|
||||||
"vault_key": "uptime_api_key"},
|
"vault_key": "uptime_api_key"},
|
||||||
"waha": {"url": "http://10.4.1.110:3500", "auth": "apikey",
|
"waha": {"url": "http://10.4.1.110:3500", "auth": "apikey",
|
||||||
"key_file": "waha_api_key", "vault_key": "waha_api_key"},
|
"key_file": "waha_api_key", "vault_key": "waha_api_key"},
|
||||||
|
"forgejo": {"url": "http://10.4.1.116:3001", "auth": "bearer", "key_file": "forgejo",
|
||||||
|
"vault_key": "forgejo_token"},
|
||||||
|
"semaphore": {"url": "http://10.4.1.116:8090", "auth": "bearer", "key_file": "semaphore",
|
||||||
|
"vault_key": "semaphore_token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Dockhand session ---
|
# --- Dockhand session ---
|
||||||
|
|
@ -155,8 +159,242 @@ async def vault_reload(_=Depends(_verify)):
|
||||||
_load_vault_cache()
|
_load_vault_cache()
|
||||||
return {"reloaded": True, "items": len(_vault_cache)}
|
return {"reloaded": True, "items": len(_vault_cache)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- VM Lifecycle Endpoints ---
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import subprocess as _sp
|
||||||
|
|
||||||
|
AUTOMATION1 = "sascha@10.5.85.5"
|
||||||
|
ISO_BUILDER = "/app-config/ansible/iso-builder/build-iso.sh"
|
||||||
|
|
||||||
|
class VMCreate(BaseModel):
|
||||||
|
node: int
|
||||||
|
ip: str
|
||||||
|
hostname: str
|
||||||
|
cores: int = 2
|
||||||
|
memory: int = 4096
|
||||||
|
disk: int = 32
|
||||||
|
|
||||||
|
def _ssh(host, cmd, timeout=600):
|
||||||
|
r = _sp.run(["ssh","-o","ConnectTimeout=10","-o","StrictHostKeyChecking=accept-new",host,cmd],
|
||||||
|
capture_output=True, text=True, timeout=timeout)
|
||||||
|
return r.returncode, r.stdout, r.stderr
|
||||||
|
|
||||||
|
def _pve_auth():
|
||||||
|
pv = _parse_kv("proxmox")
|
||||||
|
return f"PVEAPIToken={pv.get('tokenid','')}={pv.get('secret','')}"
|
||||||
|
|
||||||
|
@app.get("/vm/list")
|
||||||
|
async def vm_list(_=Depends(_verify)):
|
||||||
|
auth = _pve_auth()
|
||||||
|
vms = []
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=15) as c:
|
||||||
|
nodes = await c.get("https://10.5.85.11:8006/api2/json/nodes", headers={"Authorization": auth})
|
||||||
|
for n in nodes.json().get("data", []):
|
||||||
|
r = await c.get(f"https://10.5.85.11:8006/api2/json/nodes/{n['node']}/qemu", headers={"Authorization": auth})
|
||||||
|
for vm in r.json().get("data", []):
|
||||||
|
vm["node"] = n["node"]
|
||||||
|
vms.append(vm)
|
||||||
|
return vms
|
||||||
|
|
||||||
|
@app.post("/vm/create")
|
||||||
|
async def vm_create(req: VMCreate, _=Depends(_verify)):
|
||||||
|
steps = []
|
||||||
|
# Step 1: Build ISO + create VM via iso-builder on automation1
|
||||||
|
cmd = f"{ISO_BUILDER} --node {req.node} --ip {req.ip} --hostname {req.hostname} --cores {req.cores} --memory {req.memory} --disk {req.disk} --password 'GT500r8' --create-vm"
|
||||||
|
rc, out, err = _ssh(AUTOMATION1, f"cd /app-config/ansible/iso-builder && {cmd}", timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return JSONResponse({"error": "iso-builder failed", "stderr": err[-500:], "stdout": out[-500:]}, status_code=500)
|
||||||
|
steps.append("iso-builder: ok")
|
||||||
|
|
||||||
|
# Step 2: Wait for SSH (up to 6 min)
|
||||||
|
ok = False
|
||||||
|
for _ in range(36):
|
||||||
|
try:
|
||||||
|
rc2, out2, _ = _ssh(f"sascha@{req.ip}", "hostname", timeout=10)
|
||||||
|
if rc2 == 0:
|
||||||
|
ok = True
|
||||||
|
steps.append(f"ssh: {out2.strip()} reachable")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": "SSH timeout", "steps": steps}, status_code=504)
|
||||||
|
|
||||||
|
# Step 2.5: Add to Ansible inventory
|
||||||
|
ini = "/app-config/ansible/pfannkuchen.ini"
|
||||||
|
group = getattr(req, 'group', 'auto')
|
||||||
|
inv_cmd = f"""python3 -c "
|
||||||
|
lines = open('{ini}').readlines()
|
||||||
|
if not any('{req.hostname} ' in l for l in lines):
|
||||||
|
out = []
|
||||||
|
found = False
|
||||||
|
for l in lines:
|
||||||
|
out.append(l)
|
||||||
|
if l.strip() == '[auto]':
|
||||||
|
found = True
|
||||||
|
elif found and (l.startswith('[') or l.strip() == ''):
|
||||||
|
out.insert(-1, '{req.hostname} ansible_host={req.ip}\\n')
|
||||||
|
found = False
|
||||||
|
if found:
|
||||||
|
out.append('{req.hostname} ansible_host={req.ip}\\n')
|
||||||
|
open('{ini}','w').writelines(out)
|
||||||
|
print('added')
|
||||||
|
else:
|
||||||
|
print('exists')
|
||||||
|
" """
|
||||||
|
_ssh(AUTOMATION1, inv_cmd, timeout=30)
|
||||||
|
_ssh(AUTOMATION1, f"mkdir -p /app-config/ansible/host_vars/{req.hostname} && printf 'ansible_host: {req.ip}\\nansible_user: sascha\\n' > /app-config/ansible/host_vars/{req.hostname}/vars.yml", timeout=30)
|
||||||
|
_ssh(AUTOMATION1, f"ssh-keygen -f /home/sascha/.ssh/known_hosts -R {req.ip} 2>/dev/null; ssh -o StrictHostKeyChecking=accept-new sascha@{req.ip} hostname 2>/dev/null", timeout=30)
|
||||||
|
steps.append("inventory: added")
|
||||||
|
|
||||||
|
# Step 3: Ansible base setup via direct SSH (reliable fallback)
|
||||||
|
rc3, _, err3 = _ssh(AUTOMATION1, f"cd /app-config/ansible && bash pfannkuchen.sh setup {req.hostname}", timeout=600)
|
||||||
|
steps.append(f"ansible: {'ok' if rc3 == 0 else 'failed (rc=' + str(rc3) + ')'}")
|
||||||
|
|
||||||
|
return {"status": "ok" if rc3 == 0 else "partial", "hostname": req.hostname, "ip": req.ip, "node": req.node, "steps": steps}
|
||||||
|
|
||||||
|
@app.get("/vm/status/{vmid}")
|
||||||
|
async def vm_status(vmid: int, _=Depends(_verify)):
|
||||||
|
auth = _pve_auth()
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=10) as c:
|
||||||
|
nodes = await c.get("https://10.5.85.11:8006/api2/json/nodes", headers={"Authorization": auth})
|
||||||
|
for n in nodes.json().get("data", []):
|
||||||
|
r = await c.get(f"https://10.5.85.11:8006/api2/json/nodes/{n['node']}/qemu/{vmid}/status/current", headers={"Authorization": auth})
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json().get("data", {})
|
||||||
|
return JSONResponse({"error": "VM not found"}, status_code=404)
|
||||||
|
|
||||||
|
@app.delete("/vm/{vmid}")
|
||||||
|
async def vm_delete(vmid: int, _=Depends(_verify)):
|
||||||
|
auth = _pve_auth()
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=30) as c:
|
||||||
|
nodes = await c.get("https://10.5.85.11:8006/api2/json/nodes", headers={"Authorization": auth})
|
||||||
|
for n in nodes.json().get("data", []):
|
||||||
|
r = await c.delete(f"https://10.5.85.11:8006/api2/json/nodes/{n['node']}/qemu/{vmid}", headers={"Authorization": auth})
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
return JSONResponse({"error": "VM not found"}, status_code=404)
|
||||||
|
|
||||||
|
@app.post("/inventory/host")
|
||||||
|
async def inventory_host(request: Request, _=Depends(_verify)):
|
||||||
|
body = await request.json()
|
||||||
|
name, ip = body["name"], body["ip"]
|
||||||
|
group = body.get("group", "auto")
|
||||||
|
user = body.get("user", "sascha")
|
||||||
|
ini = "/app-config/ansible/pfannkuchen.ini"
|
||||||
|
# Add host to group in pfannkuchen.ini (idempotent)
|
||||||
|
add_cmd = f"""python3 -c "
|
||||||
|
lines = open('{ini}').readlines()
|
||||||
|
# Check if host already exists
|
||||||
|
if any('{name} ' in l or '{name}\\n' in l for l in lines):
|
||||||
|
print('already exists')
|
||||||
|
else:
|
||||||
|
# Find the group and insert after it
|
||||||
|
out, found = [], False
|
||||||
|
for l in lines:
|
||||||
|
out.append(l)
|
||||||
|
if l.strip() == '[{group}]':
|
||||||
|
found = True
|
||||||
|
elif found and (l.startswith('[') or l.strip() == ''):
|
||||||
|
out.insert(-1, '{name} ansible_host={ip}\\n')
|
||||||
|
found = False
|
||||||
|
if found: # group was last
|
||||||
|
out.append('{name} ansible_host={ip}\\n')
|
||||||
|
open('{ini}','w').writelines(out)
|
||||||
|
print('added to [{group}]')
|
||||||
|
" """
|
||||||
|
rc, out, _ = _ssh(AUTOMATION1, add_cmd, timeout=30)
|
||||||
|
# Also create host_vars
|
||||||
|
_ssh(AUTOMATION1, f"mkdir -p /app-config/ansible/host_vars/{name} && printf 'ansible_host: {ip}\\nansible_user: {user}\\n' > /app-config/ansible/host_vars/{name}/vars.yml", timeout=30)
|
||||||
|
return {"status": "ok", "name": name, "ip": ip, "group": group, "result": out.strip()}
|
||||||
|
|
||||||
|
@app.post("/ansible/run")
|
||||||
|
async def ansible_run(request: Request, _=Depends(_verify)):
|
||||||
|
body = await request.json()
|
||||||
|
hostname = body.get("limit", body.get("hostname", ""))
|
||||||
|
template_id = body.get("template_id", 10)
|
||||||
|
if not hostname:
|
||||||
|
return JSONResponse({"error": "limit/hostname required"}, status_code=400)
|
||||||
|
rc, out, err = _ssh(AUTOMATION1, f"cd /app-config/ansible && bash pfannkuchen.sh setup {hostname}", timeout=600)
|
||||||
|
return {"status": "ok" if rc == 0 else "error", "rc": rc, "output": out[-1000:]}
|
||||||
|
|
||||||
|
@app.get("/ansible/status/{job_id}")
|
||||||
|
async def ansible_status(job_id: int, _=Depends(_verify)):
|
||||||
|
return {"info": "direct SSH mode - no async job tracking"}
|
||||||
|
|
||||||
|
# --- TTS Endpoints ---
|
||||||
|
|
||||||
|
class TTSRequest(BaseModel):
|
||||||
|
text: str
|
||||||
|
target: str = "speaker" # "speaker" or "telegram"
|
||||||
|
voice: str = "deep_thought.mp3"
|
||||||
|
language: str = "de"
|
||||||
|
|
||||||
|
SPEAKER_URL = "http://10.10.1.166:10800"
|
||||||
|
CHATTERBOX_URL = "http://10.2.1.104:8004/tts"
|
||||||
|
|
||||||
|
@app.post("/tts/speak")
|
||||||
|
async def tts_speak(req: TTSRequest, _=Depends(_verify)):
|
||||||
|
if req.target == "speaker":
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=120) as c:
|
||||||
|
r = await c.post(SPEAKER_URL, json={"text": req.text})
|
||||||
|
return {"status": "ok" if r.status_code == 200 else "error", "target": "speaker"}
|
||||||
|
elif req.target == "telegram":
|
||||||
|
# Generate WAV via Chatterbox, save to hermes VM as OGG for Telegram voice
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=120) as c:
|
||||||
|
r = await c.post(CHATTERBOX_URL, json={
|
||||||
|
"text": req.text, "voice_mode": "clone",
|
||||||
|
"reference_audio_filename": req.voice,
|
||||||
|
"output_format": "wav", "language": req.language,
|
||||||
|
"exaggeration": 0.3, "cfg_weight": 0.7, "temperature": 0.6,
|
||||||
|
})
|
||||||
|
if r.status_code != 200:
|
||||||
|
return JSONResponse({"error": "chatterbox failed"}, status_code=500)
|
||||||
|
# Save WAV and convert to OGG on hermes
|
||||||
|
import tempfile
|
||||||
|
wav_path = tempfile.mktemp(suffix=".wav")
|
||||||
|
ogg_path = "/tmp/trulla_voice.ogg"
|
||||||
|
with open(wav_path, "wb") as f:
|
||||||
|
f.write(r.content)
|
||||||
|
rc, _, _ = _ssh("sascha@10.4.1.100", f"rm -f {ogg_path}", timeout=10)
|
||||||
|
# Copy WAV to hermes and convert
|
||||||
|
_sp.run(["scp", "-o", "ConnectTimeout=5", wav_path, f"sascha@10.4.1.100:/tmp/trulla_voice.wav"], timeout=30)
|
||||||
|
_ssh("sascha@10.4.1.100", f"ffmpeg -y -i /tmp/trulla_voice.wav -c:a libopus -b:a 64k {ogg_path} 2>/dev/null", timeout=30)
|
||||||
|
os.unlink(wav_path)
|
||||||
|
return {"status": "ok", "target": "telegram", "media_path": ogg_path, "hint": "Use MEDIA:/tmp/trulla_voice.ogg in response"}
|
||||||
|
else:
|
||||||
|
return JSONResponse({"error": f"unknown target: {req.target}"}, status_code=400)
|
||||||
|
|
||||||
|
@app.get("/tts/voices")
|
||||||
|
async def tts_voices(_=Depends(_verify)):
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=10) as c:
|
||||||
|
r = await c.get("http://10.2.1.104:8004/get_predefined_voices")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
@app.get("/tts/health")
|
||||||
|
async def tts_health(_=Depends(_verify)):
|
||||||
|
results = {}
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=5) as c:
|
||||||
|
try:
|
||||||
|
r = await c.get(SPEAKER_URL)
|
||||||
|
results["speaker"] = r.json()
|
||||||
|
except Exception as e:
|
||||||
|
results["speaker"] = {"status": "offline", "error": str(e)}
|
||||||
|
try:
|
||||||
|
r = await c.get("http://10.2.1.104:8004/api/model-info")
|
||||||
|
results["chatterbox"] = "ok"
|
||||||
|
except Exception as e:
|
||||||
|
results["chatterbox"] = {"status": "offline", "error": str(e)}
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
@app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
||||||
|
SKIP_SERVICES = {"vm", "inventory", "ansible", "debug", "tts"}
|
||||||
|
if service in SKIP_SERVICES:
|
||||||
|
raise HTTPException(404, f"Unknown service: {service}")
|
||||||
cfg = SERVICES.get(service)
|
cfg = SERVICES.get(service)
|
||||||
if not cfg:
|
if not cfg:
|
||||||
raise HTTPException(404, f"Unknown service: {service}. Available: {list(SERVICES.keys())}")
|
raise HTTPException(404, f"Unknown service: {service}. Available: {list(SERVICES.keys())}")
|
||||||
|
|
@ -206,3 +444,6 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
||||||
except Exception:
|
except Exception:
|
||||||
data = resp.text
|
data = resp.text
|
||||||
return JSONResponse(content=data, status_code=resp.status_code)
|
return JSONResponse(content=data, status_code=resp.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue