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>
326 lines
11 KiB
Python
326 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
|
|
|
|
|
|
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, (Path(root) / f).read_bytes()))
|
|
|
|
for rel in sorted(generated):
|
|
items.append((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()
|