Files
Deploymentcenter/scripts/deploy.py
T
Deploymentcenter BotandClaude Opus 5 0e93345ab8 fix(2.8.1): Sechs Befunde, eine Bauart - die Voreinstellung, die einsprang
Drei Agenten haben von aussen gemeldet, und fast jeder Befund hat dieselbe
Form: Irgendwo sprang ein Vorgabewert ein, obwohl die richtige Antwort
bekannt war - meist das Projekt, an das der Aufrufer ohnehin gebunden ist.

#351  manage?action=next antwortete durchgaengig mit HTTP 500. In
      BugRepo::buildFilter() stand :project_scope zweimal in derselben
      Anweisung; mit ATTR_EMULATE_PREPARES=false bindet PDO jeden Namen
      genau einmal - zwei Marker, ein Wert, SQLSTATE[HY093]. Getroffen war
      nur "next", weil nur dort der Rueckkanal-Filter gesetzt wird. Damit
      gab es keine exklusive Uebernahme. Es ist der VIERTE Fall dieser
      Bauart, deshalb prueft scripts/pruefe_platzhalter.py das jetzt
      mechanisch vor jedem Ausliefern.

#382  Meldungen ohne project_slug landeten im Scheinprojekt "default" -
#387  ohne dc_projects-Zeile, also project_id NULL. Die Antwort sah wie ein
#397  Erfolg aus, und danach sperrte dieselbe Token-Bindung den Zugriff
      (403 project_forbidden). Der Rueckfall aufs Heimatprojekt war an
      !isWorkbench gebunden; ein Werkbank-Token ohne Slug bekam gar keinen
      Vorgabewert. Jetzt: genannter Slug, sonst Heimatprojekt, sonst 400.
      Ein unbekanntes Projekt wird abgewiesen statt still angelegt.

#364  Ohne project_slug lieferten list, stats und die Roadmap ALLE
#176  Projekte statt des eigenen, weil die Vorgabe auf "all" stand und ein
      Werkbank-Token beim Lesen absichtlich nicht unter die Schranke faellt.
      Gemessen: 393 Items, davon 28 eigene. Ausgerechnet der erste Aufruf
      jeder Sitzung. Die Roadmap antwortet ausserdem schmal - 55 Felder je
      Eintrag waren 628.392 Zeichen und fuellten das Kontextfenster.

#404  Watchdog-Monitore bekamen nie ein Projekt: upsertHeartbeat() wurde
      keines uebergeben, obwohl authorizeSource() die Token-Bindung liest.
      Befunde erbten dadurch "default" - dc_findings(project_slug=...) war
      fuer eine laufende, meldende Instanz leer.

#131  Wo der Leseweg richtig verengte, sagte er es nicht: HTTP 200 mit dem
      umgeschriebenen Filter sieht aus wie eine Auskunft ueber das
      angefragte Projekt. Ein Agent, der vor dem Melden dc_search fragt,
      legt so das Duplikat an. Jetzt scoped_to und scope_note im Umschlag -
      nur bei tatsaechlicher Verengung.

#354  dc-testkit 1.1.0 wurde zweimal mit verschiedenem Inhalt ausgeliefert
      (1898 und 1930 Zeilen), 1.0.0 davor ebenso. Der Absatz ueber
      DC_TESTKIT_VERSION stand die ganze Zeit im Skript und hat nicht
      getragen. Jetzt 1.1.1 plus ein Riegel in deploy.py, der abbricht,
      wenn sich der Inhalt aendert und die Nummer nicht.

Warum die Testsuite all das nicht sah: Ihr Master-Token ist an kein Projekt
gebunden und nimmt deshalb ueberall den anderen Zweig. Es gab sogar einen
Testfall fuer action=next - er lief gruen, waehrend der Aufruf in der Praxis
ausnahmslos scheiterte. Die neuen Faelle benutzen ausdruecklich
projektgebundene Token; testctl kann dafuer jetzt owner_type und scopes
waehlen.

Ausserdem: Die Agentenvorlage nennt fuer die SDK-Fassung dc_link_project
statt einer abgeschriebenen Zahl - "SDK 2.5.1" stand dort als Beispiel und
wurde als Wert uebernommen.

Nicht enthalten: die 20 Altlasten in "default". Sie sind ueber die API nicht
erreichbar; sql/wartung/2026-08-31_default-projekt-aufraeumen.sql liegt dafuer
bereit (alle 20 haben einen titelgleichen Zwilling im richtigen Projekt).

156 e2e-Faelle gruen, davon 9 neue.

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

443 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',
}
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()