#!/usr/bin/env python3 """ IWZ Ferienwohnungen - Deploy nur geaenderter Dateien Baut die Hugo-Seite und laedt per FTP zur Bunny.net Storage Zone nur die Dateien hoch, die sich seit dem letzten Deploy mit diesem Skript geaendert haben (Vergleich per Datei-Hash gegen ein lokales Manifest). """ import ftplib import hashlib import json import os import subprocess import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent # Skript liegt in lib/ PUBLIC_DIR = PROJECT_ROOT / "public" MANIFEST_PATH = PROJECT_ROOT / ".deploy_manifest.json" FTP_PORT = 21 def env_file(): """Pfad zur .env: bevorzugt IWZ_ENV (von setenv.bat), sonst bin/.env.""" from_var = os.environ.get("IWZ_ENV") if from_var: return Path(from_var) return PROJECT_ROOT / "bin" / ".env" def load_env(path): env = {} if not path.exists(): return env for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) env[key.strip()] = value.strip() return env def hugo_build(): print("\n-> Hugo Build...") result = subprocess.run("hugo --gc --minify", shell=True) if result.returncode != 0: print("\nFEHLER: Hugo Build fehlgeschlagen!") sys.exit(1) def hash_file(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return h.hexdigest() def scan_public(): manifest = {} for entry in PUBLIC_DIR.rglob("*"): if entry.is_file(): rel = entry.relative_to(PUBLIC_DIR).as_posix() manifest[rel] = hash_file(entry) return manifest def load_manifest(): if not MANIFEST_PATH.exists(): return {} return json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) def save_manifest(manifest): MANIFEST_PATH.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8") def diff_manifest(old, new): changed = [rel for rel, digest in new.items() if old.get(rel) != digest] deleted = [rel for rel in old if rel not in new] return sorted(changed), sorted(deleted) def ftp_mkdirs(ftp, remote_dir): parts = remote_dir.split("/") path = "" for part in parts: if not part: continue path = f"{path}/{part}" if path else part try: ftp.mkd(path) except ftplib.error_perm: pass # existiert bereits def ftp_upload_files(ftp, rel_paths): for rel in rel_paths: local_path = PUBLIC_DIR / rel remote_dir = "/".join(rel.split("/")[:-1]) if remote_dir: ftp_mkdirs(ftp, remote_dir) with open(local_path, "rb") as f: ftp.storbinary(f"STOR {rel}", f) print(f" {rel}") def ftp_delete_files(ftp, rel_paths): for rel in rel_paths: try: ftp.delete(rel) print(f" gelöscht: {rel}") except ftplib.error_perm as e: print(f" konnte nicht löschen: {rel} ({e})") def deploy_to_bunny(host, zone, password, changed, deleted): print(f"\n-> Deploy zu Bunny.net Storage Zone '{zone}'...") with ftplib.FTP() as ftp: ftp.connect(host, FTP_PORT) ftp.login(zone, password) ftp.set_pasv(True) if changed: print(f"\n-> Lade {len(changed)} geänderte Datei(en) hoch:") ftp_upload_files(ftp, changed) if deleted: print(f"\n-> Entferne {len(deleted)} gelöschte Datei(en):") ftp_delete_files(ftp, deleted) if __name__ == "__main__": print("=== IWZ Deploy (nur Änderungen) ===") env = {**load_env(env_file()), **os.environ} zone = env.get("BUNNY_STORAGE_ZONE") host = env.get("BUNNY_STORAGE_HOST", "storage.bunnycdn.com") password = env.get("BUNNY_STORAGE_PASSWORD") if not zone or not password: print("\nFEHLER: BUNNY_STORAGE_ZONE / BUNNY_STORAGE_PASSWORD fehlen.") print("Siehe bin/.env.example -> bin/.env kopieren und befuellen.") sys.exit(1) hugo_build() old_manifest = load_manifest() new_manifest = scan_public() changed, deleted = diff_manifest(old_manifest, new_manifest) if not changed and not deleted: print("\nKeine Änderungen seit dem letzten Deploy. Nichts hochzuladen.") sys.exit(0) deploy_to_bunny(host, zone, password, changed, deleted) save_manifest(new_manifest) print("\nFertig! Änderungen sind live.")