Files
appfactory-worker/app/main.py
T
2026-05-28 13:50:29 +02:00

265 lines
6.5 KiB
Python

import json
import os
import socket
import sqlite3
import subprocess
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI
DB_FILE = Path("/opt/appfactory/data/appfactory/appfactory.db")
TOOLS_DIR = Path("/tools")
WORKER_ID = os.getenv("APPFACTORY_WORKER_ID", socket.gethostname())
POLL_SECONDS = int(os.getenv("APPFACTORY_WORKER_POLL_SECONDS", "3"))
app = FastAPI(title="AppFactory Worker")
_worker_thread = None
_worker_started = False
def utc_now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
def get_connection():
con = sqlite3.connect(DB_FILE, timeout=30)
con.row_factory = sqlite3.Row
return con
def append_job_log(job_id: int, stream: str, message: str):
con = get_connection()
con.execute(
"""
INSERT INTO job_logs (job_id, stream, message, created_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
""",
(job_id, stream, message),
)
con.commit()
con.close()
def update_job_status(job_id: int, status: str, error_text: str | None = None, result: dict | None = None):
con = get_connection()
if status == "running":
con.execute(
"""
UPDATE jobs
SET status = 'running',
started_at = COALESCE(started_at, CURRENT_TIMESTAMP),
worker_id = ?
WHERE id = ?
""",
(WORKER_ID, job_id),
)
elif status in ("success", "failed", "cancelled"):
con.execute(
"""
UPDATE jobs
SET status = ?,
finished_at = CURRENT_TIMESTAMP,
error_text = ?,
result_json = ?
WHERE id = ?
""",
(
status,
error_text,
json.dumps(result or {}, ensure_ascii=False),
job_id,
),
)
con.commit()
con.close()
def get_next_job():
con = get_connection()
con.execute("BEGIN IMMEDIATE")
row = con.execute(
"""
SELECT *
FROM jobs
WHERE status = 'queued'
ORDER BY id ASC
LIMIT 1
"""
).fetchone()
if not row:
con.commit()
con.close()
return None
job_id = row["id"]
running_same_target = con.execute(
"""
SELECT id
FROM jobs
WHERE status = 'running'
AND target_type = ?
AND target_id = ?
AND id != ?
LIMIT 1
""",
(row["target_type"], row["target_id"], job_id),
).fetchone()
if running_same_target:
con.commit()
con.close()
return None
con.execute(
"""
UPDATE jobs
SET status = 'running',
started_at = CURRENT_TIMESTAMP,
worker_id = ?
WHERE id = ?
AND status = 'queued'
""",
(WORKER_ID, job_id),
)
con.commit()
con.close()
return dict(row)
def build_env(job: dict):
payload = json.loads(job.get("payload_json") or "{}")
env = os.environ.copy()
env["APPFACTORY_TRIGGER_SOURCE"] = job.get("source") or "worker"
env["APPFACTORY_TRIGGERED_BY_USER_ID"] = str(job.get("created_by_user_id") or "")
env["APPFACTORY_TRIGGERED_BY_USERNAME"] = str(job.get("created_by_username") or "")
env["APPFACTORY_TRIGGERED_BY_DISPLAY_NAME"] = str(job.get("created_by_display_name") or "")
env["APPFACTORY_COMMIT_AUTHOR"] = str(payload.get("commit_author") or "")
env["APPFACTORY_PUSHER"] = str(payload.get("pusher") or "")
env["APPFACTORY_COMMIT_SHA"] = str(payload.get("commit_sha") or "")
return env
def command_for_job(job: dict):
job_type = job["type"]
target_id = job["target_id"]
if job_type == "deploy_app":
return [str(TOOLS_DIR / "deploy-app.sh"), target_id]
if job_type == "deploy_core_service":
return [str(TOOLS_DIR / "deploy-core-service.sh"), target_id]
raise ValueError(f"Unsupported job type: {job_type}")
def execute_job(job: dict):
job_id = int(job["id"])
target = job["target_id"]
append_job_log(job_id, "system", f"Worker {WORKER_ID} started job {job_id} for {target}")
update_job_status(job_id, "running")
try:
command = command_for_job(job)
env = build_env(job)
append_job_log(job_id, "system", "Command: " + " ".join(command))
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
)
stdout_chunks = []
stderr_chunks = []
stdout, stderr = process.communicate()
if stdout:
stdout_chunks.append(stdout)
append_job_log(job_id, "stdout", stdout)
if stderr:
stderr_chunks.append(stderr)
append_job_log(job_id, "stderr", stderr)
returncode = process.returncode
if returncode == 0:
update_job_status(
job_id,
"success",
result={
"returncode": returncode,
"stdout_length": len("".join(stdout_chunks)),
"stderr_length": len("".join(stderr_chunks)),
},
)
append_job_log(job_id, "system", "Job finished successfully")
else:
update_job_status(
job_id,
"failed",
error_text=f"Command failed with return code {returncode}",
result={"returncode": returncode},
)
append_job_log(job_id, "system", f"Job failed with return code {returncode}")
except Exception as exc:
update_job_status(job_id, "failed", error_text=str(exc))
append_job_log(job_id, "stderr", str(exc))
def worker_loop():
while True:
try:
job = get_next_job()
if job:
execute_job(job)
else:
time.sleep(POLL_SECONDS)
except Exception as exc:
print(f"Worker loop error: {exc}", flush=True)
time.sleep(POLL_SECONDS)
@app.on_event("startup")
def start_worker():
global _worker_thread
global _worker_started
if _worker_started:
return
_worker_started = True
_worker_thread = threading.Thread(target=worker_loop, daemon=True)
_worker_thread.start()
@app.get("/health")
def health():
return {
"status": "ok",
"worker_id": WORKER_ID,
}