#!/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()