Sammelcommit fuer den ueber mehrere Sitzungen gewachsenen, bisher nicht
eingecheckten Stand. Getrennte Commits waeren ehrlicher gewesen, sind es
nachtraeglich aber nicht mehr: BugRepo, changelog.json und die Doku tragen
Aenderungen aus allen Straengen gleichzeitig, und keine Zwischenstufe waere
lauffaehig.
ROADMAP IM DEPLOYMENTCENTER (2.7.0)
Planungsstand, Reihenfolge und Zustaendigkeit lagen in handgepflegten
Markdown-Dateien neben einem Bugtracker, der genau diese drei Dinge bereits
fuehrt. Zwei Pfleger fuer dasselbe Feld - Drift war eine Frage der Zeit.
Ab jetzt: Zustand im DC, Begruendung in der Datei. Kein Feld hat zwei Pfleger.
- Migration 016: epic, roadmap_rank, target_build, defer_trigger,
doc_anchor, Status "deferred", Tabelle roadmap_epics.
KEINE Roadmap-Tabelle - ein Eintrag ist ein bugtracker_item.
- Zugehoerigkeit ist ein Feld: Ein Item steht auf der Roadmap, wenn es ein
epic hat. Ein zweites Kennzeichen daneben koennte auseinanderlaufen.
- Kennung ist die Item-ID. Handvergebene Kuerzel ("A12") erzeugen die
schlimmste Fehlerart: Beide Seiten stimmig, Verschiedenes gemeint, weil
die Kennung nach einer Loeschung neu vergeben wurde.
- Zurueckstellen verlangt defer_trigger. Ohne benannten Anlass ist ein
Vorhaben nicht zurueckgestellt, sondern vergessen. Die Regel sitzt im
Repository, damit sie auf allen drei Schreibwegen gleich greift.
- Modules/Bugtracker/Roadmap.php, /api/bugtracker/v1/roadmap.php,
WebUI-Modul mit Filtern, cli/roadmap.php --write/--check,
dc_roadmap und dc_roadmap_set im MCP-Adapter.
- cli/roadmap.php --write ERZEUGT den Zustandsteil der Markdown-Datei;
--check prueft nur die Prosa. Was erzeugt wird, kann nicht driften.
- docs/ROADMAP_MIGRATION.md: Uebernahme fuer die anderen Projekte.
TESTINSTANZ UND TESTTOR
- dc-dev.mhdf.de: zweite Installation, eigene DB, eigene Schluessel,
Basic-Auth davor, Rocket.Chat aus.
- 124 Testfaelle in tests/e2e gegen API, Rechte, Migrationen, Sperre.
- scripts/deploy.py bringt vor jedem Upload erst den anstehenden Stand auf
die Testinstanz und laesst dann den Testlauf laufen. Erst bei
Rueckgabewert 0 geht etwas auf die Produktion (--ohne-test als Notausgang).
Andersherum prueft der Lauf aelteren Code als den, der gleich hochgeht.
- tests/ ist aus dem Produktiv-Deploy ausgeschlossen; testctl.php kann
nicht versehentlich auf dc.mhdf.de landen.
- Ein Lauf mit mehr uebersprungenen als geprueften Faellen endet mit
Rueckgabewert 2 statt 0. Nichts ist kaputt, geprueft wurde es aber auch
nicht - der Unterschied darf nicht am Rueckgabewert verschwinden.
SUPERVISOR, PROJEKTLANDSCHAFT, NACHPRUEFUNG (2.6.0/2.6.1)
- Migration 013: projektuebergreifend melden (dev_agent, evidence,
triage_external, affects).
- Migration 014: supervisor_findings, Katalog, fuenf serverseitige
Detektoren im Tick.
- Migration 015: Nachpruefung als Arbeitsschlange statt Benachrichtigung.
- DEV-Lizenzschluessel zur Selbstbedienung, dc-testkit fuer die Test-VM.
- MCP-Adapter: 15 Werkzeuge, ohne Fremdabhaengigkeiten, mit Abgrenzung
von Fremddaten gegen Prompt-Injektion.
BEHOBEN
- observe und status im Supervisor legten den Zustand des Befundes als
"status" in die Antwort. Http::ok() merged ['status' => 'success']
darunter, also gewann der Befundzustand - eine angenommene Meldung kam
als status="open" an. Heisst jetzt finding_status. War Item #59.
Migrationen 013-016 sind auf dem Produktivserver noch NICHT ausgefuehrt.
Ohne sie bricht das WebUI, weil die Roadmap-Ansicht bei jedem Seitenaufruf
gegen bugtracker_items.epic abfragt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
283 lines
9.4 KiB
Python
283 lines
9.4 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 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',
|
|
}
|
|
|
|
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.")
|
|
|
|
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("")
|
|
|
|
|
|
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()
|