#!/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 re 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) TESTKIT_DATEI = 'public/testkit/dc-testkit.sh' TESTKIT_CACHE_SCHLUESSEL = '__dc_testkit_version__' def testkit_fassung(): """Liest DC_TESTKIT_VERSION aus dem Testkit-Skript.""" pfad = os.path.join(PROJECT_DIR, TESTKIT_DATEI) if not os.path.exists(pfad): return None muster = re.compile(r'^DC_TESTKIT_VERSION="([^"]*)"') with open(pfad, 'r', encoding='utf-8', errors='replace') as f: for zeile in f: treffer = muster.match(zeile) if treffer: return treffer.group(1) return None def pruefe_testkit_fassung(cache, changes): """ Verhindert, dass zweimal derselbe Fassungsstand ausgeliefert wird. Das Testkit liegt auf jeder Testmaschine als Kopie, und selfupdate entscheidet allein anhand von DC_TESTKIT_VERSION, ob es sich lohnt zu holen. Bleibt die Zahl stehen, waehrend sich der Inhalt aendert, bleiben alte Kopien liegen - und melden dabei brav die neue Fassungsnummer. Genau das ist zweimal passiert: 1.0.0 mit 1215 und mit 1335 Zeilen, dann 1.1.0 mit 1898 und mit 1930. Beim zweiten Mal fehlte der aelteren Kopie der 2.8.0-Weg in cmd_confirm; jede Nachpruefung von dieser Maschine lief still im alten Verfahren weiter. Verglichen wird gegen den Stand, den DIESE Maschine zuletzt hochgeladen hat - dieselbe Grundlage, auf der deploy.py ohnehin entscheidet, was sich geaendert hat. Ohne Cache (frische Arbeitskopie) wird nicht geprueft: dort wird ohnehin alles neu uebertragen, und eine Warnung ohne Vergleichspunkt waere geraten. """ wird_hochgeladen = any(rel == TESTKIT_DATEI for rel, _, _ in changes) if not wird_hochgeladen: return zuletzt = cache.get(TESTKIT_CACHE_SCHLUESSEL) if zuletzt is None: return jetzt = testkit_fassung() if jetzt is None or jetzt != zuletzt: return print("") print(f" ABBRUCH: {TESTKIT_DATEI} hat sich geaendert, aber") print(f" DC_TESTKIT_VERSION steht weiterhin auf {jetzt}.") print("") print(" Damit wuerde dieselbe Fassungsnummer mit anderem Inhalt ausgeliefert.") print(" selfupdate auf den Testmaschinen sieht dann 'gleiche Fassung' und") print(" holt nichts - alte Kopien bleiben liegen und melden trotzdem die") print(" neue Nummer. Genau so ist Bugtracker #354 entstanden.") print("") print(" Zu tun: DC_TESTKIT_VERSION hochzaehlen und erneut ausliefern.") print("") sys.exit(1) def dry_run(): """Zeigt an, was uebertragen wuerde, ohne eine Verbindung aufzubauen.""" cache = load_cache() changes = collect_changes(cache) pruefe_testkit_fassung(cache, changes) 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) # Erst sehen, was ansteht - dann erst eine Verbindung aufbauen. # # Vorher wurde zuerst eingewaehlt und danach gesammelt; ein Lauf ohne # Aenderungen oeffnete also eine FTP-Sitzung, nur um sie sofort wieder zu # schliessen. Und die Fassungspruefung unten will abbrechen, bevor # irgendetwas den Server erreicht. files_to_upload = collect_changes(cache) pruefe_testkit_fassung(cache, files_to_upload) if not files_to_upload: print("No changed files to upload. Remote is up to date!") return 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) 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 # Womit diese Maschine das Testkit zuletzt beliefert hat. Nur so # hat pruefe_testkit_fassung() beim naechsten Lauf einen # Vergleichspunkt. if rel_file == TESTKIT_DATEI: fassung = testkit_fassung() if fassung is not None: new_cache[TESTKIT_CACHE_SCHLUESSEL] = fassung 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("") def platzhalter_pruefung() -> None: """ Statischer Suchlauf ueber alle SQL-Ausdruecke (scripts/pruefe_platzhalter.py). Derselbe benannte Platzhalter zweimal in einer Anweisung ergibt mit ATTR_EMULATE_PREPARES = false einen sicheren HTTP 500. Das ist im Deploymentcenter viermal passiert - zuletzt in BugRepo::buildFilter(), wo es manage?action=next vollstaendig unbrauchbar machte (#351). Nach dem dritten Mal stand der Vorsatz "ein Suchlauf ueber alle prepare()-Aufrufe gehoert zur Durchsicht" in der Testsuite. Ein Vorsatz hat das vierte Mal nicht verhindert; ein Aufruf an dieser Stelle schon. Er kostet keine Sekunde und braucht kein Netz. """ skript = SCRIPT_DIR / 'pruefe_platzhalter.py' if not skript.exists(): return ergebnis = subprocess.run([sys.executable, str(skript), str(PROJECT_DIR)]) if ergebnis.returncode != 0: print(" Ausliefern abgebrochen - siehe oben.") sys.exit(ergebnis.returncode) if __name__ == '__main__': if '--dry-run' in sys.argv or '-n' in sys.argv: platzhalter_pruefung() dry_run() else: platzhalter_pruefung() if '--ohne-test' in sys.argv: print("Testlauf uebersprungen (--ohne-test).") else: testlauf() deploy()