Files
appfactory-worker/app/main.py
T
2026-05-29 09:18:03 +02:00

439 lines
11 KiB
Python

import json
import os
import signal
import socket
import sqlite3
import subprocess
import threading
import time
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"))
HEARTBEAT_SECONDS = int(os.getenv("APPFACTORY_WORKER_HEARTBEAT_SECONDS", "10"))
CANCEL_CHECK_SECONDS = int(os.getenv("APPFACTORY_CANCEL_CHECK_SECONDS", "2"))
app = FastAPI(title="AppFactory Worker")
_worker_thread = None
_heartbeat_thread = None
_worker_started = False
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 get_job_status(job_id: int) -> str | None:
con = get_connection()
row = con.execute(
"SELECT status FROM jobs WHERE id = ?",
(job_id,),
).fetchone()
con.close()
if not row:
return None
return row["status"]
def update_worker_status(current_job_id=None):
con = get_connection()
con.execute(
"""
INSERT INTO workers (
id,
status,
started_at,
last_seen_at,
current_job_id,
metadata_json
)
VALUES (
?,
'online',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
?,
?
)
ON CONFLICT(id)
DO UPDATE SET
status = 'online',
last_seen_at = CURRENT_TIMESTAMP,
current_job_id = excluded.current_job_id,
metadata_json = excluded.metadata_json
""",
(
WORKER_ID,
current_job_id,
json.dumps(
{
"poll_seconds": POLL_SECONDS,
"heartbeat_seconds": HEARTBEAT_SECONDS,
"cancel_check_seconds": CANCEL_CHECK_SECONDS,
},
ensure_ascii=False,
),
),
)
con.commit()
con.close()
def heartbeat_loop():
while True:
try:
update_worker_status()
except Exception as exc:
print(f"Heartbeat error: {exc}", flush=True)
time.sleep(HEARTBEAT_SECONDS)
def recover_stale_jobs():
con = get_connection()
rows = con.execute(
"""
SELECT id
FROM jobs
WHERE status IN ('running', 'cancelled_requested')
AND started_at < datetime('now', '-15 minutes')
"""
).fetchall()
for row in rows:
job_id = row["id"]
con.execute(
"""
UPDATE jobs
SET status = 'failed',
finished_at = CURRENT_TIMESTAMP,
error_text = 'Worker recovery: stale running job'
WHERE id = ?
""",
(job_id,),
)
con.execute(
"""
INSERT INTO job_logs (job_id, stream, message, created_at)
VALUES (?, 'system', 'Worker recovery marked stale running job as failed', CURRENT_TIMESTAMP)
""",
(job_id,),
)
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 IN ('running', 'cancelled_requested')
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 terminate_process(process: subprocess.Popen, job_id: int):
append_job_log(job_id, "system", "Cancel requested. Terminating process...")
try:
process.terminate()
process.wait(timeout=10)
append_job_log(job_id, "system", "Process terminated.")
except subprocess.TimeoutExpired:
append_job_log(job_id, "system", "Process did not terminate in time. Killing process...")
process.kill()
process.wait(timeout=10)
append_job_log(job_id, "system", "Process killed.")
except Exception as exc:
append_job_log(job_id, "stderr", f"Error while terminating process: {exc}")
def execute_job(job: dict):
job_id = int(job["id"])
target = job["target_id"]
update_worker_status(current_job_id=job_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,
start_new_session=True,
)
while process.poll() is None:
status = get_job_status(job_id)
if status == "cancelled_requested":
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except Exception:
terminate_process(process, job_id)
else:
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except Exception:
process.kill()
process.wait(timeout=10)
update_job_status(
job_id,
"cancelled",
error_text="Job cancelled by user request",
result={"cancelled": True},
)
append_job_log(job_id, "system", "Job cancelled.")
return
time.sleep(CANCEL_CHECK_SECONDS)
stdout, stderr = process.communicate()
returncode = process.returncode
if stdout:
append_job_log(job_id, "stdout", stdout)
if stderr:
append_job_log(job_id, "stderr", stderr)
if get_job_status(job_id) == "cancelled_requested":
update_job_status(
job_id,
"cancelled",
error_text="Job cancelled by user request",
result={"returncode": returncode, "cancelled": True},
)
append_job_log(job_id, "system", "Job cancelled after process finished.")
return
if returncode == 0:
update_job_status(
job_id,
"success",
result={
"returncode": returncode,
"stdout_length": len(stdout or ""),
"stderr_length": len(stderr or ""),
},
)
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))
finally:
update_worker_status(current_job_id=None)
def worker_loop():
while True:
try:
recover_stale_jobs()
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 _heartbeat_thread
global _worker_started
if _worker_started:
return
recover_stale_jobs()
update_worker_status(current_job_id=None)
_worker_started = True
_heartbeat_thread = threading.Thread(target=heartbeat_loop, daemon=True)
_heartbeat_thread.start()
_worker_thread = threading.Thread(target=worker_loop, daemon=True)
_worker_thread.start()
@app.get("/health")
def health():
return {
"status": "ok",
"worker_id": WORKER_ID,
"poll_seconds": POLL_SECONDS,
"heartbeat_seconds": HEARTBEAT_SECONDS,
"cancel_check_seconds": CANCEL_CHECK_SECONDS,
}