mirror of
https://github.com/blw1138/cross-py-builder.git
synced 2026-09-07 21:41:09 -05:00
Add controller with web UI and retire CLI
- Add ctrl/ package: Flask app (waitress), SQLite job store, background scheduler that probes configured workers, dispatches via /upload and /checkout_git, fetches artifacts, and serves a single-page UI - Supporting API: /api/jobs (create/list/detail/cancel), /api/workers, /api/capabilities, artifact download, and SSE log stream - Workers keep the existing HTTP API; add /progress/<job_id> endpoint and per-step build tracking for live status - Retire agent_manager.py and the cross-py-builder CLI; controller UI is the primary frontend - Record design in DESIGN.md; add waitress dependency Note: /progress is exposed on the worker but the synchronous /upload and /checkout endpoints block until a build completes, so per-step worker progress is not yet relayed live in the UI. Live streaming needs an async-start worker endpoint as a follow-up.
This commit is contained in:
@@ -1,339 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
from packaging.version import Version
|
||||
from tabulate import tabulate
|
||||
|
||||
from .build_agent import build_agent_version
|
||||
from .zeroconf_server import ZeroconfServer
|
||||
|
||||
DEFAULT_PORT = 9001
|
||||
TIMEOUT = 10
|
||||
|
||||
def find_server_ips():
|
||||
ZeroconfServer.configure("_crosspybuilder._tcp.local.", socket.gethostname(), DEFAULT_PORT)
|
||||
hostnames = []
|
||||
try:
|
||||
ZeroconfServer.start(listen_only=True)
|
||||
time.sleep(.3) # give it time to find network
|
||||
hostnames = ZeroconfServer.found_ip_addresses()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
ZeroconfServer.stop()
|
||||
|
||||
# get known hosts
|
||||
# with open("../known_hosts", "r") as file:
|
||||
# lines = file.readlines()
|
||||
# hostnames.extend(lines)
|
||||
|
||||
return hostnames
|
||||
|
||||
|
||||
def get_all_servers_status():
|
||||
table_data = []
|
||||
server_ips = find_server_ips()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
futures = []
|
||||
for server in server_ips:
|
||||
ip = server.split(':')[0]
|
||||
port = server.split(":")[-1].strip() if ":" in server else DEFAULT_PORT
|
||||
futures.append(executor.submit(get_worker_status, ip, port))
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
result = future.result() # Get the result of the thread
|
||||
table_data.append(result)
|
||||
except Exception as e:
|
||||
print(f"Error fetching status from server: {e}") # Handle potential errors
|
||||
return table_data
|
||||
|
||||
|
||||
def get_worker_status(hostname, port=DEFAULT_PORT):
|
||||
"""Fetch worker status from the given hostname."""
|
||||
try:
|
||||
response = requests.get(f"http://{hostname}:{port}/status", timeout=TIMEOUT)
|
||||
status = response.json()
|
||||
status['port'] = port
|
||||
if status['hostname'] != hostname and status['ip'] != hostname:
|
||||
status['ip'] = socket.gethostbyname(hostname)
|
||||
return status
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"hostname": hostname, "port": port, "status": "offline"}
|
||||
|
||||
|
||||
def zip_project(source_dir, output_zip):
|
||||
"""Zips the given directory."""
|
||||
excluded_dirs = {"venv", ".venv", "dist", "build"}
|
||||
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for root, dirs, files in os.walk(source_dir):
|
||||
|
||||
dirs[:] = [d for d in dirs if d.lower() not in excluded_dirs]
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
zipf.write(file_path, os.path.relpath(file_path, source_dir))
|
||||
|
||||
|
||||
def send_build_request(server_ip, server_port=DEFAULT_PORT, zip_file=None, git_url=None, download_after=True, version=None):
|
||||
|
||||
if zip_file:
|
||||
upload_url = f"http://{server_ip}:{server_port}/upload"
|
||||
print(f"Submitting build request to URL: {upload_url} - Please wait. This may take a few minutes...")
|
||||
with open(zip_file, 'rb') as f:
|
||||
response = requests.post(upload_url, files={"file": f})
|
||||
elif git_url:
|
||||
checkout_url = f"http://{server_ip}:{server_port}/checkout_git"
|
||||
response = requests.post(checkout_url, json={"repo_url": git_url})
|
||||
else:
|
||||
raise ValueError("Missing zip file or git url!")
|
||||
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
print(response_data)
|
||||
print(f"Build successful. ID: {response_data['id']} Hostname: {response_data['hostname']} OS: {response_data['os']} CPU: {response_data['cpu']} Spec files: {len(response_data['spec_files'])} - Elapsed time: {response_data['duration']}" )
|
||||
if download_after:
|
||||
download_url = f"http://{server_ip}:{server_port}/download/{response_data.get('id')}"
|
||||
try:
|
||||
base_name = os.path.splitext(os.path.basename(zip_file))[0]
|
||||
version_string = ("-" + version) if version else ""
|
||||
save_name = f"{base_name}{version_string}-{response_data['os'].lower()}-{response_data['cpu'].lower()}.zip"
|
||||
download_zip(download_url, save_name=save_name)
|
||||
except Exception as e:
|
||||
print(f"Error downloading zip: {e}")
|
||||
else:
|
||||
print("Upload failed:", response.status_code, response.text)
|
||||
|
||||
|
||||
def download_zip(url, save_name, save_dir="."):
|
||||
"""Download a ZIP file from a URL and save it with its original filename."""
|
||||
response = requests.get(url, stream=True)
|
||||
response.raise_for_status() # Raise an error if request fails
|
||||
|
||||
save_path = os.path.join(save_dir, save_name)
|
||||
with open(save_path, "wb") as file:
|
||||
for chunk in response.iter_content(chunk_size=8192): # Download in chunks
|
||||
file.write(chunk)
|
||||
|
||||
print(f"Saved binaries to file: {save_path}")
|
||||
return save_path
|
||||
|
||||
|
||||
def select_server(servers, cpu=None, os_name=None):
|
||||
"""Selects a build server based on CPU architecture and OS filters."""
|
||||
available = [s for s in servers if s["status"] == "ready"]
|
||||
|
||||
if cpu:
|
||||
available = [s for s in available if cpu.lower() in s["cpu"].lower()]
|
||||
if os_name:
|
||||
available = [s for s in available if os_name.lower() in s["os"].lower()]
|
||||
return available[0] if available else None # Return first matching server or None
|
||||
|
||||
|
||||
def process_new_job(args, server_data):
|
||||
available_servers = [s for s in server_data if s["status"] == "ready"]
|
||||
|
||||
if args.cpu:
|
||||
available_servers = [s for s in available_servers if args.cpu.lower() in s["cpu"].lower()]
|
||||
if args.os:
|
||||
available_servers = [s for s in available_servers if args.os.lower() in s["os"].lower()]
|
||||
|
||||
if not available_servers:
|
||||
print("No available servers matching the criteria.")
|
||||
return
|
||||
|
||||
# Keep only unique servers
|
||||
unique_servers = {}
|
||||
for server in available_servers:
|
||||
key = (server["cpu"], server["os"])
|
||||
if key not in unique_servers:
|
||||
unique_servers[key] = server
|
||||
|
||||
available_servers = list(unique_servers.values())
|
||||
print(f"Found {len(available_servers)} servers available to build")
|
||||
print(tabulate(available_servers, headers="keys", tablefmt="grid"))
|
||||
|
||||
zip_file = None
|
||||
if args.build:
|
||||
project_path = args.build
|
||||
tmp_dir = tempfile.gettempdir()
|
||||
zip_file = os.path.join(tmp_dir, f"{os.path.basename(project_path)}.zip")
|
||||
zip_project(project_path, zip_file)
|
||||
print(f"Zipped {project_path} to {zip_file}")
|
||||
|
||||
# Start builds on all matching servers
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
print("Submitting builds to:")
|
||||
download = True
|
||||
for server in available_servers:
|
||||
print(f"\t{server['hostname']} - {server['os']} - {server['cpu']}")
|
||||
futures = {executor.submit(
|
||||
send_build_request,server["ip"], DEFAULT_PORT, zip_file, args.checkout, download, args.version):
|
||||
server for server in available_servers}
|
||||
|
||||
# Collect results
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
server = futures[future]
|
||||
try:
|
||||
response = future.result() # Get the response
|
||||
except Exception as e:
|
||||
print(f"Build failed on {server['hostname']}: {e}")
|
||||
|
||||
try:
|
||||
if zip_file:
|
||||
os.remove(zip_file)
|
||||
except Exception as e:
|
||||
print(f"Error removing zip file: {e}")
|
||||
|
||||
|
||||
def delete_cache(server_data):
|
||||
available_servers = [s for s in server_data if s["status"] == "ready"]
|
||||
print(f"Deleting cache in from all available servers ({len(available_servers)})")
|
||||
for server in available_servers:
|
||||
try:
|
||||
response = requests.get(f"http://{server['ip']}:{server.get('port', DEFAULT_PORT)}/delete_cache")
|
||||
response.raise_for_status()
|
||||
print(f"Cache cleared on {server['hostname']}")
|
||||
except Exception as e:
|
||||
print(f"Error deleting cache on {server['hostname']}: {e}")
|
||||
|
||||
|
||||
def update_worker(server):
|
||||
try:
|
||||
print(f"Updating {server['hostname']} from {server.get('agent_version')} => {build_agent_version}")
|
||||
|
||||
with open("build_agent.py", "rb") as file1, open("../requirements.txt", "rb") as file2:
|
||||
update_files = {
|
||||
"file1": open("build_agent.py", "rb"),
|
||||
"file2": open("../requirements.txt", "rb")
|
||||
}
|
||||
response = requests.post(f"http://{server['ip']}:{server.get('port', DEFAULT_PORT)}/update",
|
||||
files=update_files)
|
||||
response.raise_for_status()
|
||||
|
||||
response_json = response.json()
|
||||
if response_json.get('updated_files') and not response_json.get('error_files'):
|
||||
try:
|
||||
requests.get(f"http://{server['ip']}:{server.get('port', DEFAULT_PORT)}/restart", timeout=TIMEOUT)
|
||||
except requests.exceptions.ConnectionError:
|
||||
pass
|
||||
return server
|
||||
else:
|
||||
print(f"Error updating {server['hostname']}. Errors: {response_json.get('error_files')} - Updated: {response_json.get('updated_files')}")
|
||||
except Exception as e:
|
||||
print(f"Unhandled error updating {server['hostname']}: {e}")
|
||||
return None
|
||||
|
||||
def update_build_workers(server_data):
|
||||
available_workers = [s for s in server_data if s["status"] == "ready"]
|
||||
workers_to_update = [x for x in available_workers if Version(x.get('agent_version')) < Version(build_agent_version)]
|
||||
|
||||
if not workers_to_update:
|
||||
print(f"All {len(available_workers)} workers up to date")
|
||||
return
|
||||
|
||||
print(f"Updating workers on all available servers ({len(workers_to_update)}) to {build_agent_version}")
|
||||
updated_servers = []
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
futures = {executor.submit(update_worker, server): server for server in workers_to_update}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
server = future.result()
|
||||
if server:
|
||||
updated_servers.append(server)
|
||||
|
||||
if updated_servers:
|
||||
print("Waiting for servers to restart...")
|
||||
unverified_servers = {server["ip"]: server for server in updated_servers}
|
||||
end_time = datetime.now() + timedelta(seconds=30)
|
||||
while unverified_servers and datetime.now() < end_time:
|
||||
for server_ip in list(unverified_servers.keys()): # Iterate over a copy to avoid modification issues
|
||||
try:
|
||||
response = requests.get(f"http://{server_ip}:{server.get('port', DEFAULT_PORT)}/status")
|
||||
response.raise_for_status()
|
||||
server_info = unverified_servers[server_ip] # Get full server details
|
||||
agent_version = response.json().get('agent_version')
|
||||
if agent_version == build_agent_version:
|
||||
print(f"Agent on {server_info['hostname']} successfully upgraded to {build_agent_version}")
|
||||
else:
|
||||
print(f"Agent on {server_info['hostname']} failed to upgrade. Still on version {agent_version}")
|
||||
unverified_servers.pop(server_ip)
|
||||
except requests.exceptions.ConnectionError:
|
||||
pass # Server is still restarting
|
||||
if unverified_servers:
|
||||
time.sleep(1) # Short delay before retrying to avoid spamming requests
|
||||
if unverified_servers:
|
||||
print("Some servers did not restart in time:", list(unverified_servers.keys()))
|
||||
|
||||
print("Update complete")
|
||||
|
||||
|
||||
def shutdown_agent(hostname):
|
||||
print(f"Shutting down hostname: {hostname}")
|
||||
try:
|
||||
requests.get(f"http://{hostname}:{DEFAULT_PORT}/shutdown", timeout=TIMEOUT)
|
||||
except (requests.exceptions.ConnectionError, TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
def restart_agent(hostname):
|
||||
print(f"Restarting agent: {hostname}")
|
||||
try:
|
||||
requests.get(f"http://{hostname}:{DEFAULT_PORT}/restart", timeout=TIMEOUT)
|
||||
except (requests.exceptions.ConnectionError, TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build agent manager for cross_py_builder")
|
||||
parser.add_argument("--status", action="store_true", help="Get status of available servers")
|
||||
parser.add_argument("--build", type=str, help="Path to the project to build")
|
||||
parser.add_argument("--checkout", type=str, help="Url to Git repo for checkout")
|
||||
parser.add_argument("-cpu", type=str, help="CPU architecture")
|
||||
parser.add_argument("-os", type=str, help="Operating system")
|
||||
parser.add_argument("-version", type=str, help="Version number for build")
|
||||
parser.add_argument("--delete-cache", action="store_true", help="Delete cache")
|
||||
parser.add_argument("--update-all", action="store_true", help="Update build agent")
|
||||
parser.add_argument("--restart", type=str, help="Hostname to restart")
|
||||
parser.add_argument("--restart-all", action="store_true", help="Restart all agents")
|
||||
parser.add_argument("--shutdown", type=str, help="Hostname to shutdown")
|
||||
parser.add_argument("--shutdown-all", action="store_true", help="Shutdown all agents")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.status:
|
||||
server_data = get_all_servers_status()
|
||||
server_data = [x for x in server_data if x['status'] != 'offline']
|
||||
print(tabulate(server_data, headers="keys", tablefmt="grid"))
|
||||
return
|
||||
elif args.restart:
|
||||
restart_agent(args.restart)
|
||||
elif args.restart_all:
|
||||
print("Restarting all agents...")
|
||||
for server_ip in find_server_ips():
|
||||
restart_agent(server_ip)
|
||||
elif args.shutdown:
|
||||
shutdown_agent(args.shutdown)
|
||||
elif args.shutdown_all:
|
||||
print("Shutting down all agents...")
|
||||
for server_ip in find_server_ips():
|
||||
shutdown_agent(server_ip)
|
||||
elif args.delete_cache:
|
||||
delete_cache(get_all_servers_status())
|
||||
elif args.build or args.checkout:
|
||||
process_new_job(args, get_all_servers_status())
|
||||
elif args.update_all:
|
||||
update_build_workers(get_all_servers_status())
|
||||
else:
|
||||
print("No path given!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -32,6 +32,20 @@ BUILD_DIR = "pybuild-data"
|
||||
TMP_DIR = tempfile.gettempdir()
|
||||
system_status = {"status": "ready", "running_job": None}
|
||||
build_lock = threading.Lock()
|
||||
progress_lock = threading.Lock()
|
||||
build_progress = {}
|
||||
|
||||
def _set_step(job_id, step, **extra):
|
||||
with progress_lock:
|
||||
build_progress[job_id] = dict(build_progress.get(job_id, {}),
|
||||
job_id=job_id,
|
||||
step=step,
|
||||
step_started_at=datetime.datetime.now().isoformat(),
|
||||
**extra)
|
||||
|
||||
def _clear_progress(job_id):
|
||||
with progress_lock:
|
||||
build_progress.pop(job_id, None)
|
||||
|
||||
def is_windows():
|
||||
return platform.system().lower() == "windows"
|
||||
@@ -167,6 +181,28 @@ def status():
|
||||
})
|
||||
|
||||
|
||||
@app.route('/progress/<job_id>', methods=['GET'])
|
||||
def progress(job_id):
|
||||
if not is_valid_job_id(job_id):
|
||||
return jsonify({"error": f"Invalid job id: {job_id}"}), 404
|
||||
|
||||
with progress_lock:
|
||||
info = dict(build_progress.get(job_id)) if job_id in build_progress else None
|
||||
|
||||
if not info:
|
||||
return jsonify({"status": "not_found", "job_id": job_id}), 404
|
||||
|
||||
info['elapsed'] = ""
|
||||
step_started = info.get('step_started_at')
|
||||
if step_started:
|
||||
try:
|
||||
started = datetime.datetime.fromisoformat(step_started)
|
||||
info['elapsed'] = str(datetime.datetime.now() - started)
|
||||
except ValueError:
|
||||
pass
|
||||
return jsonify(info)
|
||||
|
||||
|
||||
def generate_job_id():
|
||||
return str(uuid.uuid4()).split('-')[-1]
|
||||
|
||||
@@ -193,6 +229,7 @@ def checkout_project():
|
||||
start_time = datetime.datetime.now()
|
||||
try:
|
||||
os.makedirs(build_root, exist_ok=True)
|
||||
_set_step(job_id, "cloning_repo", repo_url=repo_url)
|
||||
system_status['status'] = "cloning_repo"
|
||||
subprocess.check_call(['git', 'clone', repo_url, repo_dir])
|
||||
system_status['status'] = "ready"
|
||||
@@ -201,6 +238,7 @@ def checkout_project():
|
||||
print(f"Error processing checkout: {e}")
|
||||
system_status['status'] = "ready"
|
||||
system_status['running_job'] = None
|
||||
_clear_progress(job_id)
|
||||
shutil.rmtree(repo_dir, ignore_errors=True)
|
||||
if isinstance(e, subprocess.CalledProcessError):
|
||||
return jsonify({'error': 'Failed to clone repository'}), 500
|
||||
@@ -233,6 +271,7 @@ def upload_project():
|
||||
working_dir = os.path.join(TMP_DIR, BUILD_DIR, job_id)
|
||||
|
||||
file = request.files['file']
|
||||
_set_step(job_id, "processing_files", source=file.filename)
|
||||
zip_path = os.path.join(working_dir, "source.zip")
|
||||
|
||||
# Save ZIP file
|
||||
@@ -248,25 +287,29 @@ def upload_project():
|
||||
except Exception as e:
|
||||
print(f"Uncaught error processing job: {e}")
|
||||
system_status['status'] = "ready"
|
||||
_clear_progress(job_id)
|
||||
shutil.rmtree(working_dir, ignore_errors=True)
|
||||
return jsonify({"error": f"Uncaught error processing job: {e}"}), 500
|
||||
finally:
|
||||
build_lock.release()
|
||||
|
||||
def install_and_build(project_path, job_id, start_time):
|
||||
_set_step(job_id, "starting", project_path=project_path)
|
||||
|
||||
# Find the PyInstaller spec file
|
||||
spec_files = glob.glob(os.path.join(project_path, "*.spec"))
|
||||
if not spec_files:
|
||||
_clear_progress(job_id)
|
||||
return jsonify({"error": "No .spec files found"}), 400
|
||||
|
||||
print(f"Starting new build job - {len(spec_files)} spec files found")
|
||||
system_status['status'] = "working"
|
||||
system_status['running_job'] = os.path.basename(project_path)
|
||||
system_status['running_job'] = job_id
|
||||
|
||||
# Set up virtual environment
|
||||
venv_path = os.path.join(project_path, "venv")
|
||||
try:
|
||||
_set_step(job_id, "creating_venv")
|
||||
system_status['status'] = "creating_venv"
|
||||
print(f"\n========== Configuring Virtual Environment ({venv_path}) ==========")
|
||||
python_exec = "python" if is_windows() else "python3"
|
||||
@@ -279,11 +322,13 @@ def install_and_build(project_path, job_id, start_time):
|
||||
print(f"Error setting up virtual environment: {e}")
|
||||
system_status['status'] = "ready"
|
||||
system_status['running_job'] = None
|
||||
_clear_progress(job_id)
|
||||
shutil.rmtree(project_path, ignore_errors=True)
|
||||
return jsonify({"error": f"Error setting up virtual environment: {e}"}), 500
|
||||
|
||||
# Install requirements
|
||||
try:
|
||||
_set_step(job_id, "installing_packages")
|
||||
system_status['status'] = "installing_packages"
|
||||
subprocess.run([py_exec, "-m", "pip", "install", "--upgrade", "pip"], check=True)
|
||||
subprocess.run([py_exec, "-m", "pip", "install", "pyinstaller", "pyinstaller_versionfile", "--prefer-binary"], check=True)
|
||||
@@ -296,13 +341,17 @@ def install_and_build(project_path, job_id, start_time):
|
||||
print(f"Error installing requirements: {e}")
|
||||
system_status['status'] = "ready"
|
||||
system_status['running_job'] = None
|
||||
_clear_progress(job_id)
|
||||
shutil.rmtree(project_path, ignore_errors=True)
|
||||
return jsonify({"error": f"Error installing requirements: {e}"}), 500
|
||||
|
||||
results = {}
|
||||
try:
|
||||
spec_total = len(spec_files)
|
||||
for index, spec_file in enumerate(spec_files):
|
||||
# Compile with PyInstaller
|
||||
_set_step(job_id, "compiling", spec_index=index, spec_total=spec_total,
|
||||
spec_name=os.path.basename(spec_file))
|
||||
system_status['status'] = "compiling"
|
||||
print(f"\n========== Compiling spec file {index+1} of {len(spec_files)} - {spec_file} ==========")
|
||||
simple_name = os.path.splitext(os.path.basename(spec_file))[0]
|
||||
@@ -322,22 +371,30 @@ def install_and_build(project_path, job_id, start_time):
|
||||
print(line, end="")
|
||||
log_file.write(line)
|
||||
log_file.flush()
|
||||
with progress_lock:
|
||||
if job_id in build_progress:
|
||||
build_progress[job_id]['last_log_line'] = line.rstrip()
|
||||
process.wait()
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"PyInstaller failed with exit code {process.returncode}. Last line: {str(last_line).strip()}")
|
||||
|
||||
print(f"\n========== Compilation of spec file {spec_file} complete ==========\n")
|
||||
with progress_lock:
|
||||
if job_id in build_progress:
|
||||
build_progress[job_id]['spec_completed'] = index + 1
|
||||
except Exception as e:
|
||||
print(f"Error compiling project: {e}")
|
||||
system_status['status'] = "ready"
|
||||
system_status['running_job'] = None
|
||||
_clear_progress(job_id)
|
||||
shutil.rmtree(project_path, ignore_errors=True)
|
||||
return jsonify({"error": f"Error compiling project: {e}"}), 500
|
||||
|
||||
dist_path = os.path.join(project_path, "dist")
|
||||
system_status['status'] = "ready"
|
||||
system_status['running_job'] = None
|
||||
_clear_progress(job_id)
|
||||
return jsonify({
|
||||
"id": job_id,
|
||||
"message": "Build completed",
|
||||
|
||||
Reference in New Issue
Block a user