Files
Deploymentcenter/scripts/deploy_dev.py
T
Deploymentcenter BotandClaude Opus 5 3b56839a1e fix(2.7.1): Auskuenfte, die stimmen - Fassung, Health, doctor, Ansicht
Der erste Durchlauf der Testinstanz hat neun Bugs zutage gefoerdert, und fast
alle waren dieselbe Sorte Fehler: eine Auskunft, die nicht stimmte.

Am folgenreichsten die Fassung. config/config.php fuehrte app.version als feste
Zeichenkette und stand seit 2.0.0 darauf, waehrend 2.7.0 lief - unbemerkt, weil
die Datei gitignored ist und in keinem Diff auftaucht. Zwei gemeldete Bugs sind
daran fehldiagnostiziert worden ("der Server ist zu alt"). src/bootstrap.php
leitet die Fassung jetzt aus "current" in public/docs/changelog.json ab.

Im Einzelnen:

- #346 Fassung aus dem Changelog statt aus der Konfiguration
- #60  /api/health ueberlebt eine leere Datenbank: eigener Check "schema",
       healthy=false mit Begruendung statt HTTP 500
- #54  dc-testkit doctor prueft Mindestfassung (semantisch) und Rechte, statt
       sie nur anzuzeigen - dafuer neu: GET /api/tokens/v1/whoami
- #53  dc-testkit.sh unter /testkit/ erreichbar (Route fehlte, der
       dokumentierte Bezugsweg hat nie funktioniert) und mit LF ausgeliefert
       (deploy.py/deploy_dev.py entfernen CR bei .sh/.bash beim Upload)
- #288 Filter und Formulare werfen nicht mehr aufs Dashboard zurueck: die
       offene Ansicht wandert bei GET als "view" in den Query-String, und die
       Filter loesen ueber requestSubmit() aus - form.submit() erzeugt kein
       submit-Ereignis, weshalb der zentrale Zuhoerer dort nie lief
- #347 /api/bugtracker/v1/roadmap auch ohne .php erreichbar
- #55  Fassungshinweis in TESTUMGEBUNG.md 4.6 (serverseitig war das mit 2.6.0
       bereits behoben)

Nicht geprueft: php -l. Auf dem Arbeitsplatz ist kein PHP installiert; die
Abnahme laeuft ueber die Testinstanz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:06:33 +02:00

339 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Deployment auf die Testinstanz (dc-dev.mhdf.de).
Bewusst ein eigenes Skript und keine Option von deploy.py:
* Ein Tippfehler in einem Schalter darf nicht dazu fuehren, dass ein
Testlauf die Produktion ueberschreibt. Zwei Skripte, zwei
Konfigurationsdateien, zwei Caches - dann gibt es keinen Schalter, den
man falsch setzen kann.
* Die Testinstanz braucht Dinge, die auf der Produktion nichts zu suchen
haben: die Zugangssperre in der .htaccess, eine eigene config.php und
testctl.php. Diese Dateien werden hier erzeugt bzw. zusaetzlich
hochgeladen, statt im Repository zu liegen und ueberallhin mitzureisen.
Aufruf:
python scripts/deploy_dev.py # geaenderte Dateien uebertragen
python scripts/deploy_dev.py --dry-run # nur anzeigen, nichts uebertragen
python scripts/deploy_dev.py --full # Cache ignorieren, alles neu
"""
import ftplib
import hashlib
import io
import json
import os
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_DIR = SCRIPT_DIR.parent
CONFIG_FILE = SCRIPT_DIR / 'deploy_dev_config.json'
CACHE_FILE = SCRIPT_DIR / '.deploy_dev_cache.json'
PRELUDE_FILE = SCRIPT_DIR / 'dev' / 'htaccess-prelude.conf'
CONFIG_TMPL = SCRIPT_DIR / 'dev' / 'config.dev.php.tmpl'
TESTCTL = PROJECT_DIR / 'tests' / 'server' / 'testctl.php'
# Verzeichnisse und Dateien, die nie auf die Testinstanz gehoeren.
# Zusaetzlich zu deploy.py steht hier tests/ - der Testcode laeuft auf dem
# Arbeitsplatz, nicht auf dem Server. Einzige Ausnahme ist testctl.php, das
# weiter unten eigens hochgeladen wird.
IGNORE_DIRS = {
'.git', 'scripts', 'bin', 'obj', 'log', '__pycache__',
'client-dotnet', 'client-php', 'mcp', 'artifacts', 'tests',
}
IGNORE_FILES = {
'.gitignore', '.gitattributes', '.deploy_cache.json',
'Serverdaten.txt', 'Serverdaten.txt.bak',
}
# Produktivgeheimnisse. config/config.php wird durch die erzeugte Fassung
# ersetzt; .htpasswd und der Signierschluessel entstehen auf der Testinstanz.
IGNORE_PATHS = {
'config/config.php',
'config/.htpasswd',
'config/release-signing.pem',
'.htaccess', # wird mit vorangestellter Sperre erzeugt
}
def die(msg):
print("Abbruch: %s" % msg)
sys.exit(1)
def load_config():
if not CONFIG_FILE.exists():
die("%s fehlt. Vorlage: scripts/deploy_dev_config.example.json" % CONFIG_FILE)
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
cfg = json.load(f)
# Schutz gegen den einen Fehler, der hier wirklich weh taete.
prod = SCRIPT_DIR / 'deploy_config.json'
if prod.exists():
with open(prod, 'r', encoding='utf-8') as f:
prod_cfg = json.load(f)
if cfg.get('host') == prod_cfg.get('host') and cfg.get('user') == prod_cfg.get('user'):
die("Die Zugangsdaten zeigen auf den Produktivserver. "
"scripts/deploy_dev_config.json pruefen.")
if 'dc-dev' not in cfg.get('base_url', ''):
die("base_url ist keine Testadresse: %r" % cfg.get('base_url'))
return cfg
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, sort_keys=True)
def apr1(password, salt):
"""Apache-MD5 ($apr1$) - das Format, das der Hoster in .htpasswd erwartet."""
itoa64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
ctx = hashlib.md5(password + b"$apr1$" + salt)
final = hashlib.md5(password + salt + password).digest()
i = len(password)
while i > 0:
ctx.update(final[:16] if i > 16 else final[:i])
i -= 16
i = len(password)
while i:
ctx.update(b"\x00" if i & 1 else password[:1])
i >>= 1
final = ctx.digest()
for i in range(1000):
c = hashlib.md5()
c.update(password if i & 1 else final)
if i % 3:
c.update(salt)
if i % 7:
c.update(password)
c.update(final if i & 1 else password)
final = c.digest()
out = ""
for a, b, c in ((0, 6, 12), (1, 7, 13), (2, 8, 14), (3, 9, 15), (4, 10, 5)):
v = (final[a] << 16) | (final[b] << 8) | final[c]
for _ in range(4):
out += itoa64[v & 0x3F]
v >>= 6
v = final[11]
for _ in range(2):
out += itoa64[v & 0x3F]
v >>= 6
return "$apr1$" + salt.decode() + "$" + out
def app_version():
"""Versionsnummer aus der Produktivkonfiguration bzw. der Vorlage lesen."""
for name in ('config.php', 'config.example.php'):
p = PROJECT_DIR / 'config' / name
if not p.exists():
continue
for line in p.read_text(encoding='utf-8', errors='replace').splitlines():
if "'version'" in line and '=>' in line:
part = line.split('=>', 1)[1].strip().strip(',').strip()
if part[:1] in ("'", '"'):
return part[1:-1]
return '0.0.0-dev'
def generated_files(cfg):
"""
Dateien, die es nur auf der Testinstanz gibt.
Sie werden bei jedem Lauf neu erzeugt und ueber ihren Inhalt gehasht - so
faellt eine Aenderung an Vorlage oder Konfiguration genauso auf wie eine
Aenderung an einer normalen Datei.
"""
docroot = cfg['docroot'].rstrip('/')
prelude = PRELUDE_FILE.read_text(encoding='utf-8').replace('%DOCROOT%', docroot)
root_ht = (PROJECT_DIR / '.htaccess').read_text(encoding='utf-8')
tmpl = CONFIG_TMPL.read_text(encoding='utf-8')
replacements = {
'%APP_VERSION%': app_version(),
'%BASE_URL%': cfg['base_url'].rstrip('/'),
'%DB_HOST%': cfg['db']['host'],
'%DB_NAME%': cfg['db']['name'],
'%DB_USER%': cfg['db']['user'],
'%DB_PASS%': cfg['db']['pass'],
'%SHARED_KEY%': cfg['keys']['shared_key'],
'%WEBHOOK_KEY%': cfg['keys']['webhook_key'],
'%LICENSE_KEY%': cfg['keys']['license_key'],
}
for needle, value in replacements.items():
tmpl = tmpl.replace(needle, value)
basic = cfg['basic_auth']
salt = hashlib.md5((basic['user'] + basic['pass']).encode()).hexdigest()[:8]
htpasswd = "%s:%s\n" % (basic['user'], apr1(basic['pass'].encode(), salt.encode()))
files = {
'.htaccess': prelude + root_ht,
'config/config.php': tmpl,
'config/.htpasswd': htpasswd,
}
if TESTCTL.exists():
files['testctl.php'] = TESTCTL.read_text(encoding='utf-8')
return dict((k, v.encode('utf-8')) for k, v in files.items())
def should_ignore(rel_path):
if rel_path in IGNORE_PATHS:
return True
parts = Path(rel_path).parts
for part in parts[:-1]:
if part in IGNORE_DIRS or part.startswith('.'):
return True
name = parts[-1] if parts else ''
if name in IGNORE_FILES:
return True
if name.startswith('.') and name not in ('.htaccess', '.gitkeep'):
return True
return False
# Dateien, die auf der Test-VM ausgefuehrt werden. CRLF ist dort kein
# Schoenheitsfehler, sondern ein Abbruch: "env: bash\r: No such file or
# directory". .gitattributes normalisiert erst beim Einchecken - hochgeladen
# wird der Arbeitsbaum. Deshalb hier, wie in deploy.py.
LF_REQUIRED_SUFFIXES = {'.sh', '.bash'}
def normalize_for_upload(rel, data):
if os.path.splitext(rel)[1].lower() not in LF_REQUIRED_SUFFIXES:
return data
return data.replace(b'\r\n', b'\n')
def collect(cache, generated, full=False):
"""(remote_pfad, bytes, hash) fuer alles, was uebertragen werden muss."""
items = []
for root, dirs, files in os.walk(PROJECT_DIR):
rel_dir = os.path.relpath(root, PROJECT_DIR)
rel_dir = '' if rel_dir == '.' else rel_dir.replace('\\', '/')
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith('.')]
for f in files:
rel = ('%s/%s' % (rel_dir, f)).lstrip('/')
if should_ignore(rel):
continue
items.append((rel, normalize_for_upload(rel, (Path(root) / f).read_bytes())))
for rel in sorted(generated):
items.append((rel, normalize_for_upload(rel, generated[rel])))
out = []
for rel, data in items:
h = hashlib.md5(data).hexdigest()
if full or cache.get(rel) != h:
out.append((rel, data, h))
out.sort(key=priority)
return out
def priority(entry):
"""
Abhaengigkeiten zuerst, Tuerschloss zuletzt.
Die .htaccess kommt ganz am Ende: sie enthaelt die Routen. Waeren die
schon da, waehrend die Zielskripte noch fehlen, liefe die Testinstanz
waehrend des Deployments in Fehler - und ein parallel laufender Testlauf
haette Befunde, die nichts mit dem Code zu tun haben.
"""
rel = entry[0]
if rel == '.htaccess':
rank = 5
elif rel == 'testctl.php':
rank = 4
elif rel.startswith('public/'):
rank = 3
elif rel.startswith(('src/', 'config/', 'sql/', 'var/')):
rank = 1
else:
rank = 2
return (rank, rel)
def ensure_remote_dir(ftp, path, known):
cur = ''
for part in [p for p in path.strip('/').split('/') if p]:
cur += '/' + part
if cur in known:
continue
try:
ftp.cwd(cur)
except ftplib.error_perm:
try:
ftp.mkd(cur)
print(" Verzeichnis angelegt: %s" % cur)
except Exception as e:
print(" Warnung: %s nicht anlegbar (%s)" % (cur, e))
known.add(cur)
def main():
args = sys.argv[1:]
dry = '--dry-run' in args or '-n' in args
full = '--full' in args
cfg = load_config()
cache = {} if full else load_cache()
changes = collect(cache, generated_files(cfg), full=full)
if not changes:
print("Keine Aenderungen - die Testinstanz ist auf dem aktuellen Stand.")
return
total = sum(len(d) for _, d, _ in changes)
print("%d Datei(en), %.1f KB -> %s" % (len(changes), total / 1024.0, cfg['base_url']))
if dry:
for rel, data, _ in changes:
mark = 'NEU' if rel not in cache else ' '
print(" %s %s (%.1f KB)" % (mark, rel, len(data) / 1024.0))
return
ftp = ftplib.FTP_TLS() if cfg.get('secure', True) else ftplib.FTP()
ftp.connect(cfg['host'], cfg.get('port', 21), timeout=45)
ftp.login(cfg['user'], cfg['pass'])
if cfg.get('secure', True):
ftp.prot_p()
print("Verbunden mit %s" % cfg['host'])
new_cache = dict(cache)
known_dirs = set()
done = 0
try:
for rel, data, h in changes:
remote = '/' + rel
d = os.path.dirname(remote)
if d and d != '/':
ensure_remote_dir(ftp, d, known_dirs)
ftp.cwd('/')
print(" -> %s" % rel)
ftp.storbinary("STOR %s" % remote, io.BytesIO(data))
new_cache[rel] = h
done += 1
finally:
save_cache(new_cache)
try:
ftp.quit()
except Exception:
pass
print("")
print("Fertig: %d von %d Datei(en) uebertragen." % (done, len(changes)))
if __name__ == '__main__':
main()