App_sperator / app.py
Rsnarsna's picture
Update app.py
21b282e verified
Raw
History Blame Contribute Delete
13.5 kB
"""
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
# ════════════════════════════════════════════════════════════
@app.get("/", response_class=HTMLResponse)
async def index():
return render_html()
@app.post("/upload")
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"}
@app.get("/status/{job_id}")
async def status(job_id: str):
job = JOBS.get(job_id)
if not job:
raise HTTPException(404, "Job not found")
return job
@app.get("/download/{job_id}/{role}")
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"
)
@app.get("/logs/{job_id}")
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"]}
@app.get("/health")
async def health():
return {
"status": "ok",
"version": APP_VERSION,
"title": APP_TITLE,
"jobs": len(JOBS),
}