from flask import Flask, request, jsonify import os import subprocess import redis from threading import Lock import shutil app = Flask(__name__) # Environment variables NEXTCLOUD_URL_DAV = os.getenv("NEXTCLOUD_URL_DAV") NEXTCLOUD_USER = os.getenv("NEXTCLOUD_USER") NEXTCLOUD_PASSWORD = os.getenv("NEXTCLOUD_PASSWORD") NEXTCLOUD_PATH = os.getenv("NEXTCLOUD_PATH") N8N_WEBHOOK_URL = os.getenv("N8N_WEBHOOK_URL", "https://n8n.n8n.svc.kube.ia86.cc/webhook/7950310f-e526-475a-82d1-63818da79339") DEBUG = bool(os.getenv("DEBUG", True)) redis_host = os.getenv("REDIS_HOST", "redis.redis.svc.cluster.local") redis_port = int(os.getenv("REDIS_PORT", 6379)) redis_client = redis.Redis(host=redis_host, port=redis_port, decode_responses=True) lock_prefix = "lock:mkdocs:" local_lock = Lock() def log_debug(msg): if DEBUG: print(f"🔍 DEBUG: {msg}", flush=True) def obscure_password(password): result = subprocess.run( ["rclone", "obscure", password], capture_output=True, text=True, check=True ) return result.stdout.strip() def write_rclone_config(): config_dir = "/cache/rclone" os.makedirs(config_dir, exist_ok=True) config_path = os.path.join(config_dir, "rclone.conf") if os.path.exists(config_path): log_debug(f"Reusing existing rclone config at {config_path}") return config_path obscured_password = obscure_password(NEXTCLOUD_PASSWORD) with open(config_path, "w") as f: f.write(f"""[nextcloud] type = webdav url = {NEXTCLOUD_URL_DAV} vendor = nextcloud user = {NEXTCLOUD_USER} pass = {obscured_password} encoding = Slash,BackSlash,Colon,Dot """) log_debug(f"Created new rclone config at {config_path}") return config_path def stream_command(cmd): log_debug(f"Executing: {cmd}") process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) output = [] for line in process.stdout: log_debug(line.rstrip()) output.append(line.rstrip()) process.wait() return process.returncode, "\n".join(output) def rclone_sync(website, local_path, file_path=None): config_file = write_rclone_config() remote_base = f"nextcloud:{NEXTCLOUD_PATH}/@{website}" if file_path: dest_path = os.path.join(local_path, file_path) if not os.path.exists(dest_path) or not os.path.exists(os.path.join(local_path, "mkdocs.yml")): log_debug("Target file or mkdocs.yml missing — falling back to full sync") file_path = None if file_path: remote_path = f"{remote_base}/{file_path}" os.makedirs(os.path.dirname(dest_path), exist_ok=True) cmd = f"rclone --config {config_file} copy \"{remote_path}\" \"{os.path.dirname(dest_path)}\" --use-server-modtime --verbose" else: remote_path = remote_base cmd = f"rclone --config {config_file} sync \"{remote_path}\" \"{local_path}\" --use-server-modtime --delete-after --fast-list --multi-thread-streams=4 --transfers=8 --checkers=16 --verbose" log_debug(f"Syncing from {remote_path} to {local_path}") return stream_command(cmd) @app.route("/build", methods=["POST"]) def build_mkdocs(): log_debug("POST request received at /build") data = request.json website = data.get("WEBSITE") file_path = data.get("FILE") error_callback = data.get("ERROR_CALLBACK", N8N_WEBHOOK_URL) if not website: log_debug("Error: Missing 'WEBSITE' parameter in request.") return jsonify({"error": "WEBSITE parameter missing"}), 400 lock_key = f"{lock_prefix}{website}" log_debug(f"Attempting to acquire Redis lock: {lock_key}") try: lock_acquired = redis_client.set(lock_key, "locked", nx=True, ex=60) except redis.exceptions.ConnectionError as e: log_debug(f"Redis connection failed: {e}") return jsonify({"error": "Redis connection error"}), 500 if not lock_acquired: log_debug(f"Build already active for website: {website}") return jsonify({"status": "busy", "message": f"Build already active: {website}"}), 429 tmp_path = f"/cache/{website}" compile_path = f"{tmp_path}#compile" final_path = f"/srv/{website}" src = os.path.join(tmp_path, "mkdocs.yml") try: code, output_sync = rclone_sync(website, tmp_path, file_path) if code != 0: return jsonify({"status": "error", "message": "rclone sync failed", "stdout": output_sync}), 500 if not os.path.exists(src): log_debug(f"{src} not found after rclone sync") return jsonify({"error": f"{src} not found after sync"}), 404 log_debug(f"Running MkDocs build: {src} -> {compile_path}") mkdocs_cmd = f"mkdocs build --no-strict --config-file {src} --site-dir {compile_path}" code, output_build = stream_command(mkdocs_cmd) if code != 0: return jsonify({"status": "error", "message": "MkDocs build failed", "stdout": output_build}), 500 log_debug(f"Performing differential copy from {compile_path} to {final_path}") rsync_cmd = f"rsync -a --delete \"{compile_path}/\" \"{final_path}/\"" code, output_rsync = stream_command(rsync_cmd) if code != 0: return jsonify({"status": "error", "message": "Rsync failed", "stdout": output_rsync}), 500 log_debug(f"MkDocs build and sync successful for website: {website}") return jsonify({ "status": "success", "message": "Build successful", "stdout": { "rclone": output_sync, "mkdocs": output_build, "rsync": output_rsync } }), 200 finally: redis_client.delete(lock_key) log_debug(f"Redis lock released for website: {website}") if __name__ == "__main__": log_debug("Starting Flask server on 0.0.0.0:80") app.run(host="0.0.0.0", port=80)