diff --git a/.env.beispiel b/.env.beispiel new file mode 100644 index 0000000..919ea66 --- /dev/null +++ b/.env.beispiel @@ -0,0 +1,20 @@ +# Vorlage fuer .env - kopieren, ausfuellen, absichern: +# +# cp .env.beispiel .env && chmod 600 .env +# +# Gelesen von veroeffentliche_04.py. Hier steht KEIN Geheimnis: rsync und scp +# haben keine Passwort-Option, die Anmeldung laeuft ueber den SSH-Schluessel. +# Einmalig einrichten mit +# +# ssh-copy-id dschlueter@jamulix.de +# +# Danach fragt nichts mehr nach einem Passwort. + +OR_HOST=jamulix.de +OR_BENUTZER=dschlueter +OR_ZIEL=/var/www/jamulix.de/html/OR/ +OR_URL=https://jamulix.de/OR/ + +# Optional - nur setzen, wenn vom Ueblichen abweichend: +# OR_SSH_PORT=22 +# OR_SSH_KEY=~/.ssh/id_ed25519 diff --git a/.gitignore b/.gitignore index b18dc3c..71bff65 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ svg-inkscape/ Operations_Research_mit_Python_Version_04_Programme/output/ produktionsmix.xlsx produktionsmix_ergebnis.xlsx + +# Zugangsdaten fuers Veroeffentlichen (veroeffentliche_04.py). Enthaelt zwar +# kein Passwort - die Anmeldung laeuft ueber den SSH-Schluessel -, aber Host, +# Benutzer und Serverpfad gehen niemanden etwas an. Vorlage: .env.beispiel +.env diff --git a/Operations_Research_mit_Python_Version_04/veroeffentliche_04.py b/Operations_Research_mit_Python_Version_04/veroeffentliche_04.py new file mode 100755 index 0000000..2afb419 --- /dev/null +++ b/Operations_Research_mit_Python_Version_04/veroeffentliche_04.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 + +# veroeffentliche_04.py +# +# Laedt OR_HTML_04/ auf den Webserver - mit rsync, nicht mit scp. +# +# Warum das der Unterschied ist: scp ueberschreibt nur, es loescht nie. Nach +# dem Teilen von Anhang E/F blieb 'anhang-glossar-literatur.html' online +# erreichbar, obwohl die Datei aus dem Buch verschwunden war - verlinkt von +# nirgendwo, aber auffindbar. Das Zielverzeichnis vorher zu leeren waere die +# grobe Antwort darauf: Bricht die Uebertragung von 23 MB in 213 Dateien ab, +# ist die Website tot. rsync --delete uebertraegt nur Geaendertes und entfernt +# genau das, was verschwunden ist. +# +# Der zweite Grund fuer dieses Skript wiegt schwerer: Die Website hing +# wochenlang einen Bau zurueck, ohne dass es auffiel. Deshalb prueft Schritt 1, +# ob der Stand ueberhaupt aktuell ist, und Schritt 4 prueft hinterher per HTTP +# nach, ob das Ergebnis stimmt. +# +# Anmeldung per SSH-Schluessel. rsync und scp haben keine Passwort-Option; ein +# Passwort braeuchte sshpass und staende waehrend der Uebertragung in der +# Prozessliste. Einmalig: +# +# ssh-copy-id dschlueter@jamulix.de +# +# Aufruf: +# python3 Operations_Research_mit_Python_Version_04/veroeffentliche_04.py +# ... --probelauf +# +# Konfiguration in Version_04/.env (chmod 600, nicht im Repository) - siehe +# .env.beispiel. Ein Geheimnis steht nicht darin. + +import argparse +import os +import subprocess +import sys +import urllib.error +import urllib.request + +HIER = os.path.dirname(os.path.abspath(__file__)) +BASIS = os.path.dirname(HIER) + +VERSION = "04" +HTML_NAME = f"OR_HTML_{VERSION}" +HTML_VERZ = os.path.join(BASIS, HTML_NAME) +WEB_NAME = f"web_{VERSION}" +PDF_DATEINAME = f"Operations_Research_mit_Python_Version_{VERSION}.pdf" + +ENV_DATEI = os.path.join(BASIS, ".env") + +# Verzeichnisse, deren Aenderung einen neuen Bau noetig macht. Ist eine Datei +# darin neuer als die juengste erzeugte Seite, ist die Website veraltet. +QUELLEN = [ + os.path.join(BASIS, f"Operations_Research_mit_Python_Version_{VERSION}"), + os.path.join(BASIS, f"bilder_{VERSION}"), + os.path.join(BASIS, WEB_NAME), +] + +# Was nach dem Hochladen erreichbar sein MUSS bzw. NICHT MEHR erreichbar sein +# darf. Die 404-Zeile ist die eigentliche Probe darauf, dass --delete gewirkt +# hat: Sie war der Anlass fuer dieses Skript. +NACHPRUEFUNG = [ + ("", 200), + ("index.html", 200), + ("notebooks.html", 200), + ("programme.html", 200), + ("anhang-glossar.html", 200), + ("anhang-literatur.html", 200), + ("stichwortverzeichnis.html", 200), + ("Notebooks_04.zip", 200), + ("Notebooks_04/", 200), + ("programme/", 200), + ("assets/site.css", 200), + ("katex/katex.min.js", 200), + (PDF_DATEINAME, 200), + ("anhang-glossar-literatur.html", 404), +] + +LOESCHGRENZE = 30 + + +def lies_env() -> dict[str, str]: + """Liest .env - Zeilen der Form SCHLUESSEL=wert, '#' leitet einen + Kommentar ein. Bewusst kein python-dotenv: fuenf Schluessel rechtfertigen + keine Abhaengigkeit.""" + if not os.path.exists(ENV_DATEI): + raise SystemExit( + f"FEHLER: {ENV_DATEI} fehlt.\n" + f" Vorlage kopieren und ausfuellen:\n" + f" cp .env.beispiel .env && chmod 600 .env") + werte = {} + with open(ENV_DATEI, encoding="utf-8") as f: + for zeile in f: + zeile = zeile.split("#", 1)[0].strip() + if not zeile or "=" not in zeile: + continue + schluessel, _, wert = zeile.partition("=") + werte[schluessel.strip()] = wert.strip().strip('"').strip("'") + fehlend = [s for s in ("OR_HOST", "OR_BENUTZER", "OR_ZIEL", "OR_URL") + if not werte.get(s)] + if fehlend: + raise SystemExit(f"FEHLER: In .env fehlen: {', '.join(fehlend)}") + return werte + + +def juengste_datei(verzeichnisse: list[str]) -> tuple[float, str]: + """Liefert (Zeitstempel, Pfad) der zuletzt geaenderten Datei. + + Dieses Skript selbst zaehlt nicht mit: Es aendert die Website nicht, sein + Bearbeiten duerfte den Bau also nicht als veraltet erscheinen lassen.""" + neuste, pfad = 0.0, "" + for verzeichnis in verzeichnisse: + for wurzel, verz, dateien in os.walk(verzeichnis): + verz[:] = [v for v in verz + if v != "__pycache__" and not v.startswith(".")] + for name in dateien: + voll = os.path.join(wurzel, name) + if os.path.abspath(voll) == os.path.abspath(__file__): + continue + try: + zeit = os.path.getmtime(voll) + except OSError: + continue + if zeit > neuste: + neuste, pfad = zeit, voll + return neuste, pfad + + +def vorpruefung() -> None: + """Jede Verletzung bricht ab. Ein veralteter oder unvollstaendiger Stand + soll gar nicht erst auf den Server kommen.""" + def scheitern(text: str) -> None: + raise SystemExit(f"FEHLER: {text}") + + bau_aufruf = (f"python3 Operations_Research_mit_Python_Version_{VERSION}/" + f"build_version_{VERSION}.py --pdf --html") + if not os.path.exists(os.path.join(HTML_VERZ, "index.html")): + scheitern(f"{HTML_NAME}/index.html fehlt - erst bauen:\n" + f" {bau_aufruf}") + + # Ist der Bau aktuell? Verglichen wird gegen die juengste erzeugte Seite, + # nicht gegen die aelteste: Der Bau schreibt alle Seiten in einem Zug. + quell_zeit, quell_datei = juengste_datei(QUELLEN) + bau_zeit, _ = juengste_datei([HTML_VERZ]) + if quell_zeit > bau_zeit: + scheitern( + f"Die Quelle ist neuer als die Website:\n" + f" {os.path.relpath(quell_datei, BASIS)}\n" + f" Erst bauen, dann hochladen - sonst geht ein alter Stand " + f"online.\n" + f" (Bewusst trotzdem hochladen: --ohne-vorpruefung)") + + # Das PDF wurde schon einmal aus der Wurzel kopiert, ohne dass die Kopie + # nachgezogen wurde - die Website bot ein aelteres PDF an als das Buch. + pdf_wurzel = os.path.join(BASIS, PDF_DATEINAME) + pdf_kopie = os.path.join(HTML_VERZ, PDF_DATEINAME) + if not os.path.exists(pdf_kopie): + scheitern(f"{HTML_NAME}/{PDF_DATEINAME} fehlt - mit --pdf --html bauen.") + if os.path.exists(pdf_wurzel): + gross_wurzel = os.path.getsize(pdf_wurzel) + gross_kopie = os.path.getsize(pdf_kopie) + if gross_wurzel != gross_kopie: + scheitern( + f"Das PDF der Website weicht vom gebauten ab " + f"({gross_kopie:n} statt {gross_wurzel:n} Bytes).\n" + f" Nochmal mit --html bauen, das kopiert es nach.") + + # Ohne die beiden Weiterleitungen antwortet der Webserver auf die + # Verzeichnisse mit 403 - genau das war der Fehlerbericht. + pflicht = ["notebooks.html", "programme.html", f"Notebooks_{VERSION}.zip", + f"Notebooks_{VERSION}/index.html", "programme/index.html"] + fehlend = [d for d in pflicht + if not os.path.exists(os.path.join(HTML_VERZ, d))] + if fehlend: + scheitern(f"In {HTML_NAME}/ fehlen: {', '.join(fehlend)}\n" + f" Erst mit --html bauen.") + + print("Vorpruefung bestanden: Bau ist aktuell und vollstaendig.") + + +def ssh_optionen(env: dict[str, str]) -> str: + teile = ["ssh"] + if env.get("OR_SSH_PORT"): + teile += ["-p", env["OR_SSH_PORT"]] + if env.get("OR_SSH_KEY"): + teile += ["-i", os.path.expanduser(env["OR_SSH_KEY"])] + return " ".join(teile) + + +def rsync_befehl(env: dict[str, str], probelauf: bool) -> list[str]: + # -rlt statt -a: Eigentuemer und Gruppe lassen sich als normaler Nutzer + # ohnehin nicht setzen, -a erzeugte dafuer nur Warnungen. --chmod sorgt + # dafuer, dass der Webserver alles lesen kann, unabhaengig von der lokalen + # umask. + befehl = ["rsync", "-rltz", "--delete", "--chmod=D755,F644", + "--skip-compress=pdf/zip/woff2/png/jpg/svg", + "--itemize-changes", + "-e", ssh_optionen(env)] + if probelauf: + befehl.append("--dry-run") + ziel = env["OR_ZIEL"] + befehl += [HTML_VERZ + os.sep, + f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}:{ziel}'] + return befehl + + +def pruefe_ziel_erreichbar(env: dict[str, str]) -> None: + """Ein Handschlag, bevor 23 MB losgeschickt werden - und er unterscheidet + die beiden Fehlerursachen, die sonst leicht verwechselt werden: Anmeldung + scheitert (ssh liefert 255) gegen rsync fehlt am Ziel.""" + kennung = f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}' + # BatchMode: keine Passwortabfrage, damit der Aufruf nicht haengt. Der + # ssh-agent funktioniert weiterhin. stderr laeuft absichtlich durch, damit + # ssh seine eigene Diagnose zeigen kann. + ergebnis = subprocess.run( + ssh_optionen(env).split() + ["-o", "BatchMode=yes", kennung, + "command -v rsync || echo KEIN_RSYNC"], + stdout=subprocess.PIPE, text=True) + if ergebnis.returncode == 255: + raise SystemExit( + f"FEHLER: Keine SSH-Verbindung zu {kennung} (siehe Meldung oben).\n" + f" Haeufigste Ursache: Der oeffentliche Schluessel liegt noch\n" + f" nicht auf dem Server. Einmalig:\n" + f" ssh-copy-id {kennung}\n" + f" Bei passwortgeschuetztem Schluessel: ssh-add einmal " + f"ausfuehren.") + if ergebnis.returncode != 0: + raise SystemExit(f"FEHLER: ssh {kennung} endete mit Code " + f"{ergebnis.returncode}.") + if "KEIN_RSYNC" in ergebnis.stdout or not ergebnis.stdout.strip(): + raise SystemExit( + "FEHLER: Auf dem Server ist kein rsync installiert.\n" + " Ausweg ohne rsync - nach OR.neu/ hochladen und tauschen;\n" + " das entfernt ebenfalls Altes und ist fuer Besucher fast\n" + " unterbrechungsfrei:\n" + f" scp -r {HTML_NAME}/* {kennung}:{env['OR_ZIEL']}../OR.neu/\n" + f' ssh {kennung} ' + f'"mv OR OR.alt && mv OR.neu OR && rm -rf OR.alt"') + + +def zaehle_aenderungen(ausgabe: str) -> tuple[list[str], list[str]]: + """Trennt die --itemize-changes-Ausgabe in Uebertragungen und Loeschungen.""" + uebertragen, geloescht = [], [] + for zeile in ausgabe.splitlines(): + if zeile.startswith("*deleting"): + geloescht.append(zeile.split(None, 1)[-1]) + elif zeile[:1] in ("<", ">", "c", "h") and " " in zeile: + uebertragen.append(zeile.split(None, 1)[-1]) + return uebertragen, geloescht + + +def nachpruefung(basis_url: str) -> bool: + """Holt jede Adresse und vergleicht den Statuscode mit der Erwartung.""" + print("\nNachpruefung:") + alles_gut = True + for pfad, erwartet in NACHPRUEFUNG: + url = basis_url.rstrip("/") + "/" + pfad + anfrage = urllib.request.Request(url, method="HEAD") + try: + with urllib.request.urlopen(anfrage, timeout=20) as antwort: + code = antwort.status + except urllib.error.HTTPError as fehler: + code = fehler.code + except OSError as fehler: + code = f"{type(fehler).__name__}" + passt = code == erwartet + alles_gut = alles_gut and passt + print(f" {'OK ' if passt else 'FEHLER'} {code!s:>6} " + f"(erwartet {erwartet}) /{pfad}") + return alles_gut + + +def main() -> int: + zerleger = argparse.ArgumentParser( + description="Laedt OR_HTML_04/ auf den Webserver (rsync ueber SSH).") + zerleger.add_argument("--probelauf", action="store_true", + help="alles ausser der echten Uebertragung") + zerleger.add_argument("--loeschgrenze", type=int, default=LOESCHGRENZE, + help=f"Abbruch, wenn mehr Dateien geloescht wuerden " + f"(Vorgabe: {LOESCHGRENZE})") + zerleger.add_argument("--ohne-vorpruefung", action="store_true", + help="Schritt 1 ueberspringen (bewusst alter Stand)") + zerleger.add_argument("--ohne-nachpruefung", action="store_true", + help="Schritt 4 ueberspringen (kein Netz zum Server)") + argumente = zerleger.parse_args() + + env = lies_env() + ziel = f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}:{env["OR_ZIEL"]}' + print(f"Ziel: {ziel}") + + if not argumente.ohne_vorpruefung: + vorpruefung() + pruefe_ziel_erreichbar(env) + + # Schritt 2: Immer erst zaehlen. Der Probelauf kostet Sekunden und ist die + # einzige Gelegenheit, einen falschen Zielpfad zu bemerken, BEVOR --delete + # eine fremde Website ausraeumt. + probe = subprocess.run(rsync_befehl(env, probelauf=True), + capture_output=True, text=True) + if probe.returncode != 0: + print(probe.stderr, file=sys.stderr) + return probe.returncode + uebertragen, geloescht = zaehle_aenderungen(probe.stdout) + print(f"\nZu uebertragen: {len(uebertragen)} Datei(en)") + for name in uebertragen[:15]: + print(f" + {name}") + if len(uebertragen) > 15: + print(f" ... und {len(uebertragen) - 15} weitere") + print(f"Zu loeschen: {len(geloescht)} Datei(en)") + for name in geloescht: + print(f" - {name}") + + if len(geloescht) > argumente.loeschgrenze: + print(f"\nFEHLER: {len(geloescht)} Loeschungen ueberschreiten die Grenze " + f"von {argumente.loeschgrenze}.\n" + f" Das ist meist ein falscher Zielpfad oder ein halb " + f"gebautes {HTML_NAME}/.\n" + f" Wenn es stimmt: --loeschgrenze {len(geloescht)}", + file=sys.stderr) + return 1 + + if argumente.probelauf: + print("\nProbelauf - es wurde nichts uebertragen.") + return 0 + + if not uebertragen and not geloescht: + print("\nNichts zu tun - der Server ist auf dem Stand des Baus.") + else: + print("\nUebertrage ...") + lauf = subprocess.run(rsync_befehl(env, probelauf=False)) + if lauf.returncode != 0: + return lauf.returncode + print(f"Fertig: {len(uebertragen)} uebertragen, " + f"{len(geloescht)} geloescht.") + + if argumente.ohne_nachpruefung: + return 0 + return 0 if nachpruefung(env["OR_URL"]) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/PROGRESS.md b/PROGRESS.md index 060828e..a63c0a8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2078,6 +2078,45 @@ Gegenprobe: drei Läufe mit `PYTHONHASHSEED=random` liefern dieselbe Prüfsumme `3da5b9b2…`. Keine Hauptzeile trägt mehr einen doppelten Link; die 219 Fachbegriffe und 328 Indexmarken bleiben unverändert. +### ✅ `veroeffentliche_04.py` — der Upload wird nachprüfbar + +Der Anlass war messbar. Der Live-Stand vor dem Skript: + +``` +/OR/ 200 +/OR/anhang-glossar-literatur.html 200 <- Leiche des vorigen Stands +/OR/anhang-glossar.html 404 +/OR/notebooks.html 404 +/OR/Notebooks_04.zip 404 +/OR/Notebooks_04/ 403 +``` + +**`scp -r` überschreibt nur, es löscht nie.** Die alte Glossar-Seite bliebe nach einem +`scp`-Upload dauerhaft erreichbar. Das Zielverzeichnis vorher zu leeren wäre die grobe +Antwort: Bricht die Übertragung von 23 MB in 213 Dateien ab, ist die Website tot. +`rsync --delete` überträgt nur Geändertes und entfernt genau das, was verschwunden ist. + +Der zweite Grund wiegt schwerer: **Die Website hing wochenlang einen Bau zurück**, ohne dass +es auffiel. Deshalb prüft das Skript vorher, ob eine Quelldatei neuer ist als die gebaute +Website, ob das PDF der Website byte-genau dem gebauten entspricht und ob die +Verzeichnis-Weiterleitungen vorhanden sind (der Grund für den 403). Und es prüft +**hinterher per HTTP** vierzehn Adressen nach — einschließlich der Probe, dass die entfernte +Seite wirklich 404 liefert. + +Dazwischen ein Probelauf, der zählt: Mehr als 30 Löschungen brechen ab. Das ist der Schutz +gegen einen falschen Zielpfad — mit `--delete` räumt ein Tippfehler sonst ein fremdes +Verzeichnis aus. + +**Anmeldung per SSH-Schlüssel, nicht per Passwort in der `.env`.** `rsync` und `scp` haben +keine Passwort-Option; ein Passwort bräuchte `sshpass` und stünde während der Übertragung in +der Prozessliste. Die `.env` (in `.gitignore`, Vorlage `.env.beispiel`) enthält deshalb nur +Host, Benutzer, Zielpfad und URL. + +**Beim ersten Test lief die Prüfung in eine irreführende Meldung:** „Auf dem Server ist kein +rsync installiert" — tatsächlich schlug schon die Anmeldung fehl, weil der öffentliche +Schlüssel noch nicht auf `jamulix.de` liegt. Die Prüfung unterscheidet die Fälle jetzt am +ssh-Rückgabewert 255 und nennt `ssh-copy-id`. + --- ## 8. Commit-Historie des V04-Strangs diff --git a/README.md b/README.md index 347124e..5f7f4f1 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ pip install -r Operations_Research_mit_Python_Version_04_Programme/requirements. | --- | --- | | `Operations_Research_mit_Python_Version_04/` | **Quelle**: 37 Kapiteldateien (inkl. 5 Teil-Synthesen) + Build-Skripte | | `bilder_04/` | **Quelle**: 33 Diagramme (SVG) + 16 `erzeuge_*.py`-Generatoren | +| `web_04/` | **Quelle**: `site.css`, `site.js`, `icons.svg`, `plotly.min.js`, `katex/` — die statischen Bestandteile der Website | | `Kritik_und_Verbesserungsvorschlaege/` | **Quelle**: die Rezensionen und Vorschläge, die den Umbau ausgelöst haben | | `pyproject.toml`, `Dockerfile`, `.dockerignore` | **Quelle**: Abhängigkeitsgruppen und Kurs-Image | | `pandoc-defaults-*.yaml`, `pandoc/` | **Quelle**: Konfiguration des PDF-Baus | @@ -166,8 +167,9 @@ Prüfung hinterlassen. | `inkscape` | SVG-Grafiken im PDF | LaTeX bricht bei `\includesvg` ab | | `dot` (Graphviz) | 5 der 16 Bildgeneratoren | nur beim Neuerzeugen dieser Diagramme nötig | -**`OR_HTML_04/katex/`** ist externes Material und wird von keinem Skript erzeugt. Fehlt es, -bleiben die Formeln auf der Website ungesetzt. +**`web_04/katex/`** ist externes Material und wird von keinem Skript erzeugt — es liegt +deshalb als Quelle im Repository und wird bei jedem Bau nach `OR_HTML_04/katex/` kopiert. +Fehlt es, bleiben die Formeln auf der Website ungesetzt. **Warum die PDF-Konfiguration doppelt vorliegt:** `pandoc-defaults-basis.yaml` ist eine Kopie der Benutzerdatei `~/.config/pandoc/defaults.yaml` — mit **relativem** Pfad auf @@ -177,6 +179,46 @@ Benutzerdatei zurück, falls sie fehlt. --- +## Veröffentlichen + +`OR_HTML_04/` ist selbstgenügsam: Der Ordner lässt sich direkt auf einen Webserver kopieren. +Für den regelmäßigen Fall gibt es ein Skript. + +```bash +# einmalig: den eigenen SSH-Schlüssel auf den Server legen +ssh-copy-id benutzer@example.org + +# einmalig: Ziel eintragen +cp .env.beispiel .env && chmod 600 .env # dann .env ausfüllen + +# jedes Mal +python3 Operations_Research_mit_Python_Version_04/veroeffentliche_04.py +``` + +Das Skript benutzt **`rsync --delete`, nicht `scp`** — und das ist der entscheidende +Unterschied: `scp` überschreibt nur, es entfernt nie. Eine Seite, die aus dem Buch +verschwindet, bliebe sonst dauerhaft online erreichbar. `rsync` überträgt zudem nur, was sich +geändert hat; ein zweiter Lauf direkt danach überträgt nichts. + +Vier Schritte, jeder mit einer eigenen Aufgabe: + +1. **Vorprüfung.** Ist eine Quelldatei neuer als die gebaute Website, bricht das Skript ab — + ein alter Stand soll gar nicht erst hochgeladen werden. Ebenso geprüft: dass das PDF der + Website byte-genau dem gebauten entspricht und dass die Verzeichnis-Weiterleitungen da + sind. +2. **Probelauf.** Zählt und zeigt, was übertragen und was gelöscht würde. Über 30 Löschungen + ist der Lauf verdächtig (falscher Zielpfad, halb gebautes Verzeichnis) und bricht ab; + `--loeschgrenze N` hebt die Grenze an. +3. **Übertragung.** +4. **Nachprüfung per HTTP.** Vierzehn Adressen werden abgerufen und ihr Statuscode mit der + Erwartung verglichen — einschließlich der Probe, dass eine entfernte Seite wirklich 404 + liefert. + +`--probelauf` führt nur die Schritte 1 und 2 aus. In der `.env` steht **kein Geheimnis**: +`rsync` und `scp` haben keine Passwort-Option, die Anmeldung läuft über den SSH-Schlüssel. + +--- + ## Notebooks und Google Colab Jedes Kapitel hat ein Notebook in `Notebooks_04/`, und die Website verlinkt es zum