Spaces:
Sleeping
Sleeping
| """ | |
| app.py - Splunk Packaging Toolkit FastAPI Service | |
| All settings imported from config.py | |
| """ | |
| import uuid | |
| import logging | |
| from pathlib import Path | |
| from datetime import datetime | |
| from concurrent.futures import ThreadPoolExecutor | |
| from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks | |
| from fastapi.responses import FileResponse, HTMLResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from build import build_packages | |
| from config import ( | |
| APP_TITLE, APP_VERSION, APP_DESCRIPTION, | |
| ALLOWED_EXTENSIONS, MAX_UPLOAD_BYTES, | |
| BASE_WORK_DIR, MAX_CONCURRENT_JOBS, | |
| JOB_ID_LENGTH, CORS_ALLOW_ORIGINS, | |
| CORS_ALLOW_METHODS, CORS_ALLOW_HEADERS, | |
| POLL_INTERVAL_MS, LOG_TAIL_LINES, PROGRESS_STEP_PCT, | |
| ) | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| app = FastAPI(title=APP_TITLE, description=APP_DESCRIPTION, version=APP_VERSION) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=CORS_ALLOW_ORIGINS, | |
| allow_methods=CORS_ALLOW_METHODS, | |
| allow_headers=CORS_ALLOW_HEADERS, | |
| ) | |
| JOBS: dict = {} | |
| BASE_DIR = Path(BASE_WORK_DIR) | |
| BASE_DIR.mkdir(parents=True, exist_ok=True) | |
| executor = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_JOBS) | |
| ALLOWED_EXT_STR = " Β· ".join(ALLOWED_EXTENSIONS) | |
| ACCEPT_ATTR = ",".join(ALLOWED_EXTENSIONS) | |
| def run_build(job_id: str, input_file: Path, work_dir: Path): | |
| job = JOBS[job_id] | |
| job["status"] = "running" | |
| job["started_at"] = datetime.utcnow().isoformat() | |
| try: | |
| result = build_packages(input_file, work_dir, job["logs"]) | |
| job["status"] = "done" | |
| job["result"] = result | |
| job["done_at"] = datetime.utcnow().isoformat() | |
| except Exception as e: | |
| job["status"] = "failed" | |
| job["error"] = str(e) | |
| job["logs"].append(f"[FAIL] {e}") | |
| # ββ HTML stored as plain string β no f-string ββββββββββββββββ | |
| HTML = """<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>__TITLE__</title> | |
| <style> | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| body{font-family:system-ui,sans-serif;background:#0f0f0f;color:#e5e5e5;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem} | |
| .card{background:#141414;border:1px solid #222;border-radius:16px;padding:2rem;width:100%;max-width:580px} | |
| h1{font-size:1.25rem;font-weight:600;margin-bottom:4px} | |
| .sub{font-size:.85rem;color:#666;margin-bottom:1.5rem} | |
| .drop{border:1.5px dashed #2a2a2a;border-radius:10px;padding:2rem;text-align:center;cursor:pointer;margin-bottom:1.25rem;transition:border-color .2s} | |
| .drop:hover,.drop.over{border-color:#e95420} | |
| .drop input{display:none} | |
| .drop-label{font-size:.9rem;color:#777} | |
| .drop-sub{font-size:.75rem;color:#444;margin-top:4px} | |
| #sel{font-size:.82rem;color:#e95420;margin-top:8px;min-height:18px} | |
| .btn{width:100%;padding:11px;background:#e95420;color:#fff;border:none;border-radius:8px;font-size:.95rem;cursor:pointer;font-weight:500;transition:background .2s} | |
| .btn:hover{background:#c8451a} | |
| .btn:disabled{background:#2a2a2a;color:#555;cursor:not-allowed} | |
| .progress{height:3px;background:#1a1a1a;border-radius:2px;margin-top:1.25rem;display:none} | |
| .progress-bar{height:100%;background:#e95420;border-radius:2px;width:0;transition:width .4s} | |
| .log-box{background:#0a0a0a;border:1px solid #1a1a1a;border-radius:8px;padding:1rem;font-family:monospace;font-size:.75rem;max-height:200px;overflow-y:auto;margin-top:1.25rem;display:none} | |
| .downloads{margin-top:1.5rem;display:none} | |
| .dl-title{font-size:.8rem;color:#555;margin-bottom:.75rem;text-transform:uppercase;letter-spacing:.05em} | |
| .dl-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px} | |
| .dl-btn{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#1a1a1a;border:1px solid #252525;border-radius:8px;color:#e5e5e5;text-decoration:none;font-size:.82rem;transition:border-color .2s} | |
| .dl-btn:hover{border-color:#e95420} | |
| .dl-btn.master{grid-column:1/-1} | |
| .role-badge{font-size:.7rem;padding:2px 7px;border-radius:4px;font-weight:500} | |
| .sh-badge{background:#1a3a6b;color:#7eb4f7} | |
| .idx-badge{background:#1a3a1a;color:#7ecf7e} | |
| .fwd-badge{background:#3a2a1a;color:#f7c47e} | |
| .master-badge{background:#2a2a2a;color:#999} | |
| .size{font-size:.72rem;color:#555} | |
| .ok{color:#4ade80}.warn{color:#facc15}.fail{color:#f87171}.info{color:#60a5fa} | |
| .stats{display:none;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:1.25rem} | |
| .stat-box{background:#1a1a1a;border-radius:8px;padding:.75rem;text-align:center} | |
| .stat-num{font-size:1.1rem;font-weight:600;color:#e95420} | |
| .stat-lbl{font-size:.72rem;color:#555;margin-top:2px} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <h1>__TITLE__</h1> | |
| <div class="sub">__DESC__</div> | |
| <div class="drop" id="drop" | |
| onclick="document.getElementById('fi').click()" | |
| ondragover="ev(event,'over')" | |
| ondragleave="ev(event,'')" | |
| ondrop="dropFile(event)"> | |
| <input type="file" id="fi" accept="__ACCEPT__" onchange="onFile(this)"> | |
| <div class="drop-label">Drop app here or click to browse</div> | |
| <div class="drop-sub">__EXTS__</div> | |
| <div id="sel"></div> | |
| </div> | |
| <button class="btn" id="btn" onclick="upload()" disabled>Build packages</button> | |
| <div class="progress" id="prog"><div class="progress-bar" id="pbar"></div></div> | |
| <div class="log-box" id="log"></div> | |
| <div class="stats" id="stats"> | |
| <div class="stat-box"><div class="stat-num" id="s-sh">-</div><div class="stat-lbl">Search Head</div></div> | |
| <div class="stat-box"><div class="stat-num" id="s-idx">-</div><div class="stat-lbl">Indexer</div></div> | |
| <div class="stat-box"><div class="stat-num" id="s-fwd">-</div><div class="stat-lbl">Forwarder</div></div> | |
| </div> | |
| <div class="downloads" id="dl"> | |
| <div class="dl-title">Download role packages</div> | |
| <div class="dl-grid" id="dlg"></div> | |
| </div> | |
| </div> | |
| <script> | |
| const POLL_MS=__POLL_MS__; | |
| const LOG_TAIL=__LOG_TAIL__; | |
| const PROG_STEP=__PROG_STEP__; | |
| let file=null,jobId=null,pollTimer=null,pct=0; | |
| function ev(e,cls){e.preventDefault();document.getElementById('drop').className='drop'+(cls?' '+cls:'');} | |
| function dropFile(e){e.preventDefault();document.getElementById('drop').className='drop';const f=e.dataTransfer.files[0];if(f)setFile(f);} | |
| function onFile(i){if(i.files[0])setFile(i.files[0]);} | |
| function setFile(f){ | |
| file=f; | |
| document.getElementById('sel').textContent=f.name+' ('+Math.round(f.size/1024)+' KB)'; | |
| document.getElementById('btn').disabled=false; | |
| } | |
| async function upload(){ | |
| if(!file)return; | |
| const btn=document.getElementById('btn'); | |
| btn.disabled=true; btn.textContent='Uploading...'; | |
| showProg(5); | |
| const form=new FormData(); form.append('file',file); | |
| try{ | |
| const r=await fetch('/upload',{method:'POST',body:form}); | |
| const d=await r.json(); | |
| if(!r.ok) throw new Error(d.detail||'Upload failed'); | |
| jobId=d.job_id; | |
| btn.textContent='Building...'; | |
| showProg(15); | |
| document.getElementById('log').style.display='block'; | |
| pollTimer=setInterval(doPoll, POLL_MS); | |
| }catch(e){ | |
| btn.disabled=false; btn.textContent='Build packages'; | |
| addLog('[FAIL] '+e.message,'fail'); | |
| } | |
| } | |
| async function doPoll(){ | |
| if(!jobId) return; | |
| try{ | |
| const r=await fetch('/status/'+jobId); | |
| if(!r.ok){ addLog('[FAIL] status error '+r.status,'fail'); clearInterval(pollTimer); return; } | |
| const d=await r.json(); | |
| renderLogs(d.logs||[]); | |
| if(d.status==='running') showProg(Math.min(pct+PROG_STEP,85)); | |
| if(d.status==='done'){ | |
| clearInterval(pollTimer); showProg(100); | |
| setTimeout(()=>{ document.getElementById('prog').style.display='none'; },600); | |
| showDownloads(d.result); | |
| document.getElementById('btn').textContent='Build packages'; | |
| document.getElementById('btn').disabled=false; | |
| } | |
| if(d.status==='failed'){ | |
| clearInterval(pollTimer); showProg(0); | |
| addLog('[FAIL] '+(d.error||'Build failed'),'fail'); | |
| document.getElementById('btn').disabled=false; | |
| document.getElementById('btn').textContent='Build packages'; | |
| } | |
| }catch(e){ console.error(e); } | |
| } | |
| function showProg(p){ | |
| pct=p; | |
| document.getElementById('prog').style.display='block'; | |
| document.getElementById('pbar').style.width=p+'%'; | |
| } | |
| function renderLogs(lines){ | |
| const box=document.getElementById('log'); | |
| box.innerHTML=lines.slice(-LOG_TAIL).map(l=>{ | |
| let c='info'; | |
| if(l.includes('[OK]')) c='ok'; | |
| else if(l.includes('[WARN]')) c='warn'; | |
| else if(l.includes('[FAIL]')) c='fail'; | |
| return '<div class="'+c+'">'+l+'</div>'; | |
| }).join(''); | |
| box.scrollTop=box.scrollHeight; | |
| } | |
| function addLog(msg,cls){ | |
| const box=document.getElementById('log'); | |
| box.style.display='block'; | |
| box.innerHTML+='<div class="'+(cls||'info')+'">'+msg+'</div>'; | |
| } | |
| function showDownloads(result){ | |
| const roles={ | |
| master: {label:'Master (full)',badge:'master-badge',cls:'master'}, | |
| search_heads: {label:'Search Head', badge:'sh-badge', cls:''}, | |
| indexers: {label:'Indexer', badge:'idx-badge', cls:''}, | |
| forwarders: {label:'Forwarder', badge:'fwd-badge', cls:''}, | |
| }; | |
| const dlg=document.getElementById('dlg'); | |
| dlg.innerHTML=''; | |
| const pkgs=result.packages||{}; | |
| if(pkgs.search_heads) document.getElementById('s-sh').textContent=(pkgs.search_heads.size_kb||0)+' KB'; | |
| if(pkgs.indexers) document.getElementById('s-idx').textContent=(pkgs.indexers.size_kb||0)+' KB'; | |
| if(pkgs.forwarders) document.getElementById('s-fwd').textContent=(pkgs.forwarders.size_kb||0)+' KB'; | |
| document.getElementById('stats').style.display='grid'; | |
| for(const role of ['master','search_heads','indexers','forwarders']){ | |
| const info=pkgs[role]; | |
| if(!info) continue; | |
| const r=roles[role]||{label:role,badge:'master-badge',cls:''}; | |
| const a=document.createElement('a'); | |
| a.className='dl-btn '+(r.cls||''); | |
| a.href='/download/'+jobId+'/'+role; | |
| a.download=info.filename; | |
| const excl=info.excluded!=null?' | -'+info.excluded+' excl.':''; | |
| a.innerHTML= | |
| '<span><span class="role-badge '+r.badge+'">'+r.label+'</span></span>'+ | |
| '<span class="size">'+info.size_kb+' KB'+excl+'</span>'; | |
| dlg.appendChild(a); | |
| } | |
| document.getElementById('dl').style.display='block'; | |
| } | |
| </script> | |
| </body> | |
| </html>""" | |
| def render_html() -> str: | |
| return (HTML | |
| .replace("__TITLE__", APP_TITLE) | |
| .replace("__DESC__", APP_DESCRIPTION) | |
| .replace("__ACCEPT__", ACCEPT_ATTR) | |
| .replace("__EXTS__", ALLOWED_EXT_STR) | |
| .replace("__POLL_MS__", str(POLL_INTERVAL_MS)) | |
| .replace("__LOG_TAIL__", str(LOG_TAIL_LINES)) | |
| .replace("__PROG_STEP__", str(PROGRESS_STEP_PCT)) | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ROUTES β plain strings, no f-string, no double braces | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def index(): | |
| return render_html() | |
| async def upload(background_tasks: BackgroundTasks, file: UploadFile = File(...)): | |
| filename = file.filename or "" | |
| if not any(filename.endswith(ext) for ext in ALLOWED_EXTENSIONS): | |
| raise HTTPException(400, f"Only {', '.join(ALLOWED_EXTENSIONS)} files supported") | |
| content = await file.read() | |
| if len(content) > MAX_UPLOAD_BYTES: | |
| raise HTTPException(413, f"File too large. Max {MAX_UPLOAD_BYTES // (1024*1024)}MB") | |
| job_id = str(uuid.uuid4())[:JOB_ID_LENGTH] | |
| work_dir = BASE_DIR / job_id | |
| work_dir.mkdir(parents=True, exist_ok=True) | |
| input_path = work_dir / filename | |
| input_path.write_bytes(content) | |
| JOBS[job_id] = { | |
| "job_id": job_id, | |
| "filename": filename, | |
| "status": "queued", | |
| "created_at": datetime.utcnow().isoformat(), | |
| "started_at": None, | |
| "done_at": None, | |
| "logs": [], | |
| "result": None, | |
| "error": None, | |
| } | |
| background_tasks.add_task(run_build, job_id, input_path, work_dir) | |
| return {"job_id": job_id, "filename": filename, "status": "queued"} | |
| async def status(job_id: str): | |
| job = JOBS.get(job_id) | |
| if not job: | |
| raise HTTPException(404, "Job not found") | |
| return job | |
| async def download(job_id: str, role: str): | |
| job = JOBS.get(job_id) | |
| if not job: | |
| raise HTTPException(404, "Job not found") | |
| if job["status"] != "done": | |
| raise HTTPException(400, "Build not complete yet") | |
| pkgs = job["result"].get("packages", {}) | |
| if role not in pkgs: | |
| raise HTTPException(404, f"Role '{role}' not found") | |
| pkg_path = Path(pkgs[role]["path"]) | |
| if not pkg_path.exists(): | |
| raise HTTPException(404, "Package file missing on disk") | |
| return FileResponse( | |
| str(pkg_path), | |
| filename=pkgs[role]["filename"], | |
| media_type="application/gzip" | |
| ) | |
| async def logs(job_id: str): | |
| job = JOBS.get(job_id) | |
| if not job: | |
| raise HTTPException(404, "Job not found") | |
| return {"job_id": job_id, "status": job["status"], "logs": job["logs"]} | |
| async def health(): | |
| return { | |
| "status": "ok", | |
| "version": APP_VERSION, | |
| "title": APP_TITLE, | |
| "jobs": len(JOBS), | |
| } | |