Serialize builds and add health endpoint to build agent

- Add a non-blocking build lock so only one job runs at a time;
  concurrent upload/checkout requests get a 409 response
- Add /healthz returning 200 when idle and 503 while a build is running
- Ignore .DS_Store artifacts
This commit is contained in:
Brett Williams
2026-08-30 21:32:01 -05:00
parent 1902d36ae7
commit 5f0d2db1a9
2 changed files with 20 additions and 1 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
known_hosts known_hosts
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*.egg-info/ *.egg-info/
.DS_Store
+18
View File
@@ -5,6 +5,7 @@ import signal
import socket import socket
import sys import sys
import tempfile import tempfile
import threading
import time import time
from flask import Flask, request, jsonify, send_file from flask import Flask, request, jsonify, send_file
@@ -30,6 +31,7 @@ LOCAL_DIR = os.path.dirname(__file__)
BUILD_DIR = "pybuild-data" BUILD_DIR = "pybuild-data"
TMP_DIR = tempfile.gettempdir() TMP_DIR = tempfile.gettempdir()
system_status = {"status": "ready", "running_job": None} system_status = {"status": "ready", "running_job": None}
build_lock = threading.Lock()
def is_windows(): def is_windows():
return platform.system().lower() == "windows" return platform.system().lower() == "windows"
@@ -127,6 +129,12 @@ def status_page():
return (f"{APP_NAME} - Build Agent {build_agent_version} - \n" return (f"{APP_NAME} - Build Agent {build_agent_version} - \n"
f"{system_os()} | {cpu_arch()} | {version} | {hostname} | {ZeroconfServer.get_local_ip()}") f"{system_os()} | {cpu_arch()} | {version} | {hostname} | {ZeroconfServer.get_local_ip()}")
@app.get("/healthz")
def healthz():
ok = system_status['status'] == "ready"
return jsonify({"status": "ok" if ok else "busy",
"agent_version": build_agent_version}), 200 if ok else 503
@app.get("/status") @app.get("/status")
def status(): def status():
def get_directory_size(directory): def get_directory_size(directory):
@@ -173,6 +181,9 @@ def checkout_project():
if not request.is_json or not request.json.get('repo_url'): if not request.is_json or not request.json.get('repo_url'):
return jsonify({'error': 'Repository URL is required'}), 400 return jsonify({'error': 'Repository URL is required'}), 400
if not build_lock.acquire(blocking=False):
return jsonify({'error': f"Another build is already running: {system_status['running_job']}"}), 409
repo_url = request.json['repo_url'] repo_url = request.json['repo_url']
print(f"\n========== Checking Out Git Project ==========") print(f"\n========== Checking Out Git Project ==========")
@@ -194,6 +205,8 @@ def checkout_project():
if isinstance(e, subprocess.CalledProcessError): if isinstance(e, subprocess.CalledProcessError):
return jsonify({'error': 'Failed to clone repository'}), 500 return jsonify({'error': 'Failed to clone repository'}), 500
return jsonify({'error': f"Uncaught error processing checkout: {e}"}), 500 return jsonify({'error': f"Uncaught error processing checkout: {e}"}), 500
finally:
build_lock.release()
def safe_extract(zip_ref, dest_dir): def safe_extract(zip_ref, dest_dir):
"""Extract a zipfile, rejecting members that would escape dest_dir.""" """Extract a zipfile, rejecting members that would escape dest_dir."""
@@ -206,6 +219,9 @@ def safe_extract(zip_ref, dest_dir):
@app.route('/upload', methods=['POST']) @app.route('/upload', methods=['POST'])
def upload_project(): def upload_project():
if not build_lock.acquire(blocking=False):
return jsonify({'error': f"Another build is already running: {system_status['running_job']}"}), 409
try: try:
start_time = datetime.datetime.now() start_time = datetime.datetime.now()
if 'file' not in request.files: if 'file' not in request.files:
@@ -234,6 +250,8 @@ def upload_project():
system_status['status'] = "ready" system_status['status'] = "ready"
shutil.rmtree(working_dir, ignore_errors=True) shutil.rmtree(working_dir, ignore_errors=True)
return jsonify({"error": f"Uncaught error processing job: {e}"}), 500 return jsonify({"error": f"Uncaught error processing job: {e}"}), 500
finally:
build_lock.release()
def install_and_build(project_path, job_id, start_time): def install_and_build(project_path, job_id, start_time):