Files
Deploymentcenter/scripts/deploy.py
T
Deploymentcenter BotandClaude Opus 5 700be4614c chore(deploy): Entwurfsvorlage der Roadmap-Tafel nicht ausliefern
docs/roadmap-tafel-vorlage.html ist die Anschauungsvorlage aus #664 und
keine ausgelieferte Datei - der Server rendert die Tafel aus ?action=board.
Sie lag beim Produktions-Deploy in der Uebertragungsliste, obwohl sie auf
dem Webserver nichts zu suchen hat.

Die Datei selbst bleibt unangetastet: Sie ist nicht versioniert und wurde
waehrend dieser Sitzung von anderer Seite geaendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:23:08 +02:00

447 lines
16 KiB
Python

#!/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',
# Entwurfsvorlage fuer die Roadmap-Tafel (#664), als Anschauung im
# Projekt abgelegt. Sie ist keine ausgelieferte Datei: Der Server
# rendert die Tafel aus ?action=board, nicht aus dieser HTML-Datei.
'docs/roadmap-tafel-vorlage.html',
}
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 \"<Name>\" --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()