#!/usr/bin/env python3 import os import json import ftplib import sys import hashlib from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent PROJECT_DIR = SCRIPT_DIR.parent CONFIG_FILE = SCRIPT_DIR / 'deploy_config.json' CACHE_FILE = SCRIPT_DIR / '.deploy_cache.json' IGNORE_PATTERNS = { '.git', '.gitignore', 'scripts', '.deploy_cache.json', 'Serverdaten.txt', 'Serverdaten.txt.bak', 'bin', 'obj', # Laufzeitdaten gehoeren dem Server, nicht dem Arbeitsplatz. var/.htaccess # wird dennoch uebertragen, damit das Verzeichnis existiert und gesperrt ist. 'log', '__pycache__', 'client-dotnet', # Bibliothek zum Mitnehmen ins Zielprojekt bzw. lokal ueber stdio # betriebener MCP-Server - beide gehoeren nicht ins Webroot. 'client-php', 'mcp', # Gebaute Installer-Binaries. Sie gehoeren nach /installer/ und werden # von upload_installer.py dorthin gebracht - ueber diesen Weg landeten # sonst ~100 MB zusaetzlich unter /artifacts/ im Webroot. 'artifacts', } def load_config(): if not CONFIG_FILE.exists(): print(f"Error: Config file {CONFIG_FILE} does not exist.") sys.exit(1) with open(CONFIG_FILE, 'r', encoding='utf-8') as f: return json.load(f) def load_cache(): if CACHE_FILE.exists(): try: with open(CACHE_FILE, 'r', encoding='utf-8') as f: return json.load(f) except Exception: return {} return {} def save_cache(cache): with open(CACHE_FILE, 'w', encoding='utf-8') as f: json.dump(cache, f, indent=2) def compute_hash(filepath): h = hashlib.md5() with open(filepath, 'rb') as f: while chunk := f.read(8192): h.update(chunk) return h.hexdigest() def ensure_remote_dir(ftp, remote_path): dirs = [d for d in remote_path.strip('/').split('/') if d] current = '' for d in dirs: current += '/' + d try: ftp.cwd(current) except ftplib.error_perm: try: ftp.mkd(current) print(f"Created remote directory: {current}") except Exception as e: print(f"Warning: Could not create directory {current}: {e}") def should_ignore(rel_path): parts = Path(rel_path).parts if not parts: return False for part in parts: if part in IGNORE_PATTERNS: return True if part == '.htaccess': continue if part.startswith('.'): return True return False def collect_changes(cache): """Ermittelt alle Dateien, die sich seit dem letzten Deployment geaendert haben.""" files_to_upload = [] for root, dirs, files in os.walk(PROJECT_DIR): rel_dir = os.path.relpath(root, PROJECT_DIR) if rel_dir == '.': rel_dir = '' dirs[:] = [d for d in dirs if not should_ignore(os.path.join(rel_dir, d))] for f in files: rel_file = os.path.normpath(os.path.join(rel_dir, f)).replace('\\', '/') if should_ignore(rel_file): continue full_path = os.path.join(root, f) file_hash = compute_hash(full_path) if cache.get(rel_file) != file_hash: files_to_upload.append((rel_file, full_path, file_hash)) files_to_upload.sort(key=upload_priority) return files_to_upload def upload_priority(entry): """ Reihenfolge des Uploads. Abhaengigkeiten zuerst: waere public/index.php schon oben, waehrend src/bootstrap.php noch fehlt, liefe die Seite in der Zwischenzeit in einen Fehler. Die .htaccess im Wurzelverzeichnis kommt zuletzt, damit neue Routen erst greifen, wenn ihre Zielskripte vorhanden sind. """ rel_file = entry[0] if rel_file == '.htaccess': rank = 4 elif rel_file.startswith('public/'): rank = 3 elif rel_file.startswith(('src/', 'config/', 'sql/', 'var/')): rank = 1 else: rank = 2 return (rank, rel_file) def dry_run(): """Zeigt an, was uebertragen wuerde, ohne eine Verbindung aufzubauen.""" cache = load_cache() changes = collect_changes(cache) if not changes: print("Keine Aenderungen - der Server ist auf dem aktuellen Stand.") return total = sum(os.path.getsize(p) for _, p, _ in changes) print(f"{len(changes)} Datei(en) wuerden uebertragen ({total / 1024:.1f} KB):\n") for rel_file, full_path, _ in changes: marker = 'NEU' if rel_file not in cache else ' ' print(f" {marker} {rel_file} ({os.path.getsize(full_path) / 1024:.1f} KB)") def deploy(): config = load_config() cache = load_cache() new_cache = dict(cache) print(f"Connecting to FTP {config['host']}...") try: if config.get('secure', False): ftp = ftplib.FTP_TLS() ftp.connect(config['host'], config.get('port', 21)) ftp.login(config['user'], config['pass']) ftp.prot_p() else: ftp = ftplib.FTP() ftp.connect(config['host'], config.get('port', 21)) ftp.login(config['user'], config['pass']) print("Logged in successfully.") except Exception as e: print(f"FTP Connection failed: {e}") sys.exit(1) files_to_upload = collect_changes(cache) if not files_to_upload: print("No changed files to upload. Remote is up to date!") ftp.quit() return print(f"Found {len(files_to_upload)} file(s) to upload:") for rel_file, full_path, file_hash in files_to_upload: print(f" -> {rel_file}") uploaded = 0 try: for rel_file, full_path, file_hash in files_to_upload: remote_file_path = f"/{rel_file}" remote_dir = os.path.dirname(remote_file_path).replace('\\', '/') if remote_dir and remote_dir != '/': ensure_remote_dir(ftp, remote_dir) ftp.cwd('/') print(f"Uploading {rel_file} ...") with open(full_path, 'rb') as f: ftp.storbinary(f"STOR {remote_file_path}", f) new_cache[rel_file] = file_hash uploaded += 1 finally: # Der Cache wird auch bei einem Abbruch geschrieben. Sonst beginnt der # naechste Lauf wieder bei null und ueberträgt alles erneut. save_cache(new_cache) try: ftp.quit() except Exception: pass print(f"\nDeployment abgeschlossen: {uploaded} von {len(files_to_upload)} Datei(en) uebertragen.") if __name__ == '__main__': if '--dry-run' in sys.argv or '-n' in sys.argv: dry_run() else: deploy()