#!/usr/bin/env python3 """ Deployment auf die Produktion (dc.mhdf.de). Vor dem Upload laeuft der Testlauf gegen die Testinstanz: erst wird der aktuelle Stand nach dc-dev.mhdf.de gebracht, dann tests/e2e ausgefuehrt. Erst wenn der durchlaeuft, geht etwas auf die Produktion. Warum in dieser Reihenfolge und nicht "Tests gegen den vorhandenen Dev-Stand": Sonst prueft der Lauf einen aelteren Stand als den, der gleich hochgeht - und gibt gruenes Licht fuer Code, den er nie gesehen hat. python scripts/deploy.py # mit Testlauf python scripts/deploy.py --dry-run # nur anzeigen (ohne Testlauf) python scripts/deploy.py --ohne-test # Testlauf ueberspringen """ import os import json import ftplib import io import subprocess 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', # Testcode. Er laeuft gegen die Testinstanz, nicht auf dem Server. Darin # liegt testctl.php, das Tabellen loeschen kann - auf der Produktion hat # das nichts zu suchen, auch nicht gesperrt. Ausgeliefert wird es # ausschliesslich von scripts/deploy_dev.py. 'tests', } # Einzelne Dateien, pfadgenau. Anders als IGNORE_PATTERNS, das jeden Pfadteil # vergleicht: Ein Eintrag "docs" schloesse auch public/docs/ aus, und ein # Eintrag "ROADMAP.md" jede gleichnamige Datei irgendwo im Baum. # # Warum ueberhaupt: Diese beiden Anleitungen nennen die interne Adresse der # Test-VM, einen Benutzernamen und den Weg, dort sudo freizuschalten - beide # sagen das in ihrem eigenen Kopf ("Bewusst nicht unter public/"). Erreichbar # waren sie im Webroot bisher nicht, weil die Rewrite-Regel ^docs/ auf # public/docs/ umleitet und dort nichts Gleichnamiges liegt. Das ist ein # Zufallsergebnis einer Regel, die fuer etwas anderes da ist: Faellt # mod_rewrite aus oder aendert jemand die Reihenfolge, liegen sie offen. Was # nicht hochgeladen wird, kann auch nicht ausgeliefert werden. # # docs/ selbst bleibt drin: cli/roadmap.php liest DC_ROOT/docs/ROADMAP.md, und # die Integrationsanleitungen werden aus dem WebUI verlinkt. IGNORE_PATHS = { 'docs/TESTUMGEBUNG.md', 'docs/TESTINSTANZ.md', } 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 if Path(rel_path).as_posix() in IGNORE_PATHS: return True for part in parts: if part in IGNORE_PATTERNS: return True if part == '.htaccess': continue if part.startswith('.'): return True return False # Dateien, die auf dem Server ausgefuehrt oder von einer Shell gelesen werden. # Fuer sie ist CRLF kein Schoenheitsfehler, sondern ein Abbruch. LF_REQUIRED_SUFFIXES = {'.sh', '.bash'} def open_for_upload(full_path): """ Oeffnet eine Datei zum Hochladen und entfernt bei Shell-Skripten das CR. Warum das noetig ist, obwohl .gitattributes "*.sh text eol=lf" fuehrt: Normalisiert wird beim EINCHECKEN. Eine Arbeitskopie, die vorher schon CRLF hatte, bleibt unberuehrt - und genau die laedt dieses Skript hoch, denn es liest den Arbeitsbaum, nicht den Index. storbinary uebertraegt Byte fuer Byte. So landete public/testkit/dc-testkit.sh mit CRLF im Webroot und scheiterte auf der Linux-VM mit "env: bash\r: No such file or directory". Die Umwandlung hier schuetzt unabhaengig davon, von welchem Rechner ausgeliefert wird und in welchem Zustand die Arbeitskopie ist. """ if os.path.splitext(full_path)[1].lower() not in LF_REQUIRED_SUFFIXES: return open(full_path, 'rb') with open(full_path, 'rb') as handle: data = handle.read() return io.BytesIO(data.replace(b'\r\n', b'\n')) 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_for_upload(full_path) 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.") def testlauf() -> None: """ Testinstanz aktualisieren und tests/e2e ausfuehren. Bricht bei einem Fehlschlag ab, bevor irgendetwas auf die Produktion geht. Bekannte, im Bugtracker gemeldete Befunde lassen den Lauf durch (siehe tests/e2e/bekannte_befunde.json). Ohne diese Unterscheidung waere das Tor entweder dauerhaft zu oder es wuerde nach der ersten Umgehung nie wieder ernst genommen. """ dev_config = SCRIPT_DIR / 'deploy_dev_config.json' if not dev_config.exists(): print("Abbruch: %s fehlt - ohne Testinstanz laesst sich der Testlauf nicht" % dev_config) print("ausfuehren. Vorlage: scripts/deploy_dev_config.example.json") print("Notfalls (und nur dann): python scripts/deploy.py --ohne-test") sys.exit(1) for beschreibung, befehl in ( ("Testinstanz aktualisieren", [sys.executable, str(SCRIPT_DIR / 'deploy_dev.py')]), ("Testlauf", [sys.executable, str(PROJECT_DIR / 'tests' / 'e2e' / 'run.py')]), ): print("=" * 70) print("%s ..." % beschreibung) print("=" * 70) # Ohne flush stuende die Ueberschrift bei umgeleiteter Ausgabe hinter # der Ausgabe des Unterprozesses - das Protokoll waere unlesbar. sys.stdout.flush() ergebnis = subprocess.run(befehl, cwd=str(PROJECT_DIR)) if ergebnis.returncode != 0: print("") print("Abbruch: %s fehlgeschlagen (Rueckgabewert %d)." % (beschreibung, ergebnis.returncode)) print("Auf die Produktion wurde nichts uebertragen.") print("") print("Einzelne Faelle nachsehen:") print(" python tests/e2e/run.py --nur \"\" --log") sys.exit(ergebnis.returncode) print("") if __name__ == '__main__': if '--dry-run' in sys.argv or '-n' in sys.argv: dry_run() else: if '--ohne-test' in sys.argv: print("Testlauf uebersprungen (--ohne-test).") else: testlauf() deploy()