From da175daf1d2e6e17913a07b0cafa874f2ce53a8f Mon Sep 17 00:00:00 2001 From: Marvin Shah Date: Tue, 20 Oct 2026 11:10:23 +0200 Subject: [PATCH 1/3] Add migration scripts --- scripts/lib/migration-plan.py | 80 +++++++++++++++++ scripts/migrate-projects.sh | 157 ++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 scripts/lib/migration-plan.py create mode 100755 scripts/migrate-projects.sh diff --git a/scripts/lib/migration-plan.py b/scripts/lib/migration-plan.py new file mode 100644 index 000000000..d5604b5f9 --- /dev/null +++ b/scripts/lib/migration-plan.py @@ -0,0 +1,80 @@ +"""Gruppiert eine S3-Objektliste nach Account und entscheidet, wer migriert wird. + +Liest von stdin TSV-Zeilen `KeyLastModifiedSize`, wie sie +`aws s3api list-objects-v2 --query 'Contents[].[Key,LastModified,Size]' +--output text` ausgibt, und schreibt die Planungsdatei nach stdout. + +Aufruf: migration-plan.py +Siehe scripts/migrate-projects.sh. +""" + +import sys +from collections import defaultdict +from datetime import datetime, timedelta, timezone + + +def main(): + if len(sys.argv) != 3: + print(__doc__, file=sys.stderr) + return 1 + + prefix, years = sys.argv[1], int(sys.argv[2]) + cutoff = datetime.now(timezone.utc) - timedelta(days=365 * years) + + users = defaultdict(lambda: {"last": None, "objects": 0, "bytes": 0}) + + for line in sys.stdin: + parts = line.rstrip("\n").split("\t") + if len(parts) < 3: + continue + key, last_modified, size = parts[0], parts[1], parts[2] + rest = key[len(prefix) :] + if "/" not in rest: + # Objekt direkt unter dem Prefix, gehört zu keinem Account + continue + user = rest.split("/", 1)[0] + timestamp = datetime.fromisoformat(last_modified.replace("Z", "+00:00")) + entry = users[user] + if entry["last"] is None or timestamp > entry["last"]: + entry["last"] = timestamp + entry["objects"] += 1 + entry["bytes"] += int(size) + + kept = dropped = kept_bytes = dropped_bytes = 0 + + print("\t".join(["decision", "user", "last_modified", "objects", "bytes"])) + for user in sorted(users): + entry = users[user] + keep = entry["last"] >= cutoff + if keep: + kept += 1 + kept_bytes += entry["bytes"] + else: + dropped += 1 + dropped_bytes += entry["bytes"] + print( + "\t".join( + [ + "KEEP" if keep else "DROP", + user, + entry["last"].isoformat(), + str(entry["objects"]), + str(entry["bytes"]), + ] + ) + ) + + def gigabytes(value): + return f"{value / 1024 ** 3:.2f} GB" + + print( + f"Stichtag: {cutoff.date().isoformat()}\n" + f" behalten: {kept:>7} Accounts, {gigabytes(kept_bytes)}\n" + f" verworfen: {dropped:>7} Accounts, {gigabytes(dropped_bytes)}", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/migrate-projects.sh b/scripts/migrate-projects.sh new file mode 100755 index 000000000..772c88923 --- /dev/null +++ b/scripts/migrate-projects.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# +# Migriert die Nutzerdaten aus dem alten in den neuen Projekt-Bucket. +# +# Projekte werden dabei aufgeräumt: Übernommen wird ein Account nur, wenn +# mindestens eines seiner Objekte innerhalb des Stichtags zuletzt geändert +# wurde. Wer also in den letzten Jahren etwas bearbeitet hat, behält alle +# seine Projekte, alle anderen fallen weg. +# +# SRC_BUCKET=… ./scripts/migrate-projects.sh plan # Auswahl berechnen +# SRC_BUCKET=… DST_BUCKET=… ./scripts/migrate-projects.sh apply +# +# Assets und geteilte Ergebnisse werden dagegen vollständig kopiert, ohne +# Auswahl. Assets sind inhaltsadressiert und über alle Nutzer geteilt, und +# Share-Links sind öffentlich im Umlauf: +# +# SRC_BUCKET=… DST_BUCKET=… ./scripts/migrate-projects.sh shared +# +# `apply` und `shared` sind ohne DRY_RUN=0 nur Probeläufe. Beide nutzen +# `aws s3 sync`, sind also inkrementell: Ein erster Lauf vor dem Wartungs- +# fenster und ein zweiter darin holt nur noch die Differenz. +# +# Als Stichtag dient `LastModified` der S3-Objekte. Die index.json eines +# Accounts wird bei jedem Speichern neu geschrieben, der Wert entspricht also +# dem `updated_at` aus den Projektdaten. Vorsicht: Wurde der Quell-Bucket +# jemals im Ganzen umkopiert, sind die Zeitstempel zurückgesetzt und die +# Auswahl wäre wertlos. Das vorher einmal stichprobenartig prüfen. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SRC_BUCKET="${SRC_BUCKET:-}" +DST_BUCKET="${DST_BUCKET:-}" +PREFIX="${PREFIX:-data/projects/}" +SHARED_PREFIXES="${SHARED_PREFIXES:-data/assets/ data/sharing/}" +YEARS="${YEARS:-2}" +PLAN_FILE="${PLAN_FILE:-migration-plan.tsv}" +# Profile der lokalen AWS-CLI-Config. SRC_PROFILE darf leer bleiben, wenn die +# Credentials aus DST_PROFILE den Quell-Bucket lesen dürfen. +SRC_PROFILE="${SRC_PROFILE:-}" +DST_PROFILE="${DST_PROFILE:-}" +PARALLEL="${PARALLEL:-8}" +DRY_RUN="${DRY_RUN:-1}" + +src_args=() +if [ -n "$SRC_PROFILE" ]; then + src_args=(--profile "$SRC_PROFILE") +fi +dst_args=() +if [ -n "$DST_PROFILE" ]; then + dst_args=(--profile "$DST_PROFILE") +fi + +die() { + echo "Fehler: $*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'EOF' +Aufruf: migrate-projects.sh plan|apply|shared + + plan Auswahl der Accounts berechnen und als Planungsdatei schreiben + apply die auf KEEP stehenden Accounts kopieren + shared Assets und geteilte Ergebnisse vollständig kopieren + +Umgebungsvariablen: + SRC_BUCKET Quell-Bucket (Pflicht) + DST_BUCKET Ziel-Bucket (Pflicht für apply und shared) + PREFIX Standard: data/projects/ + SHARED_PREFIXES Standard: data/assets/ data/sharing/ + YEARS Stichtag in Jahren, Standard: 2 + PLAN_FILE Standard: migration-plan.tsv + SRC_PROFILE AWS-Profil für den Quell-Bucket (optional) + DST_PROFILE AWS-Profil für den Ziel-Bucket + PARALLEL parallele Kopiervorgänge, Standard: 8 + DRY_RUN 1 (Standard) = nur anzeigen, 0 = wirklich kopieren +EOF + exit 1 +} + +# Kopiert serverseitig. Die Credentials aus DST_PROFILE brauchen dafür +# Lesezugriff auf den Quell-Bucket und kms:GenerateDataKey auf den Key des +# Ziel-Buckets. +sync_args=(--only-show-errors) +prepare_sync() { + [ -n "$SRC_BUCKET" ] || die "SRC_BUCKET ist nicht gesetzt" + [ -n "$DST_BUCKET" ] || die "DST_BUCKET ist nicht gesetzt" + if [ "$DRY_RUN" != "0" ]; then + sync_args+=(--dryrun) + echo "Probelauf. Zum echten Kopieren DRY_RUN=0 setzen." >&2 + fi +} + +# ---------------------------------------------------------------- plan ----- + +plan() { + [ -n "$SRC_BUCKET" ] || die "SRC_BUCKET ist nicht gesetzt" + + echo "Liste Objekte unter s3://$SRC_BUCKET/$PREFIX ..." >&2 + + aws "${src_args[@]}" s3api list-objects-v2 \ + --bucket "$SRC_BUCKET" \ + --prefix "$PREFIX" \ + --query 'Contents[].[Key,LastModified,Size]' \ + --output text \ + | python3 "$SCRIPT_DIR/lib/migration-plan.py" "$PREFIX" "$YEARS" \ + >"$PLAN_FILE" + + echo "Plan geschrieben: $PLAN_FILE" >&2 +} + +# --------------------------------------------------------------- apply ----- + +apply() { + prepare_sync + [ -f "$PLAN_FILE" ] || die "$PLAN_FILE fehlt - erst 'plan' ausführen" + + local total + total=$(awk -F'\t' '$1 == "KEEP"' "$PLAN_FILE" | wc -l) + echo "Kopiere $total Accounts mit $PARALLEL parallelen Vorgängen ..." >&2 + + awk -F'\t' '$1 == "KEEP" { print $2 }' "$PLAN_FILE" \ + | xargs -P "$PARALLEL" -I{} \ + aws "${dst_args[@]}" s3 sync \ + "s3://$SRC_BUCKET/$PREFIX{}/" \ + "s3://$DST_BUCKET/$PREFIX{}/" \ + "${sync_args[@]}" + + echo "Fertig." >&2 +} + +# -------------------------------------------------------------- shared ----- + +shared() { + prepare_sync + + # Bewusst ohne Auswahl: Assets sind inhaltsadressiert und werden über alle + # Nutzer geteilt, geteilte Ergebnisse hängen an öffentlich verschickten Links. + for shared_prefix in $SHARED_PREFIXES; do + echo "Kopiere s3://$SRC_BUCKET/$shared_prefix ..." >&2 + aws "${dst_args[@]}" s3 sync \ + "s3://$SRC_BUCKET/$shared_prefix" \ + "s3://$DST_BUCKET/$shared_prefix" \ + "${sync_args[@]}" + done + + echo "Fertig." >&2 +} + +case "${1:-}" in + plan) plan ;; + apply) apply ;; + shared) shared ;; + *) usage ;; +esac From e4cd6059bb71926a8b22c862df5ce0591c68f680 Mon Sep 17 00:00:00 2001 From: Marvin Shah Date: Tue, 20 Oct 2026 17:13:18 +0200 Subject: [PATCH 2/3] disable maintenance mode in prod --- cdk/lib/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cdk/lib/config.ts b/cdk/lib/config.ts index f84290c91..e5eecc45b 100644 --- a/cdk/lib/config.ts +++ b/cdk/lib/config.ts @@ -57,7 +57,7 @@ export const STAGES: Record = { 'arn:aws:acm:us-east-1:391322831368:certificate/22374de3-ab9f-451a-8328-9c514ab9746d', // hostedZoneId: 'TODO_PROD_HOSTED_ZONE_ID', // hostedZoneName: 'wdrmaus.de.', - maintenance: true, + maintenance: false, account: ACCOUNTS.prod, region: DEFAULT_REGION, }, From 9818366f60eeb450a734f94452e08f2065ba0f59 Mon Sep 17 00:00:00 2001 From: Marvin Shah Date: Tue, 20 Oct 2026 17:14:09 +0200 Subject: [PATCH 3/3] Revert "Add migration scripts" This reverts commit da175daf1d2e6e17913a07b0cafa874f2ce53a8f. --- scripts/lib/migration-plan.py | 80 ----------------- scripts/migrate-projects.sh | 157 ---------------------------------- 2 files changed, 237 deletions(-) delete mode 100644 scripts/lib/migration-plan.py delete mode 100755 scripts/migrate-projects.sh diff --git a/scripts/lib/migration-plan.py b/scripts/lib/migration-plan.py deleted file mode 100644 index d5604b5f9..000000000 --- a/scripts/lib/migration-plan.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Gruppiert eine S3-Objektliste nach Account und entscheidet, wer migriert wird. - -Liest von stdin TSV-Zeilen `KeyLastModifiedSize`, wie sie -`aws s3api list-objects-v2 --query 'Contents[].[Key,LastModified,Size]' ---output text` ausgibt, und schreibt die Planungsdatei nach stdout. - -Aufruf: migration-plan.py -Siehe scripts/migrate-projects.sh. -""" - -import sys -from collections import defaultdict -from datetime import datetime, timedelta, timezone - - -def main(): - if len(sys.argv) != 3: - print(__doc__, file=sys.stderr) - return 1 - - prefix, years = sys.argv[1], int(sys.argv[2]) - cutoff = datetime.now(timezone.utc) - timedelta(days=365 * years) - - users = defaultdict(lambda: {"last": None, "objects": 0, "bytes": 0}) - - for line in sys.stdin: - parts = line.rstrip("\n").split("\t") - if len(parts) < 3: - continue - key, last_modified, size = parts[0], parts[1], parts[2] - rest = key[len(prefix) :] - if "/" not in rest: - # Objekt direkt unter dem Prefix, gehört zu keinem Account - continue - user = rest.split("/", 1)[0] - timestamp = datetime.fromisoformat(last_modified.replace("Z", "+00:00")) - entry = users[user] - if entry["last"] is None or timestamp > entry["last"]: - entry["last"] = timestamp - entry["objects"] += 1 - entry["bytes"] += int(size) - - kept = dropped = kept_bytes = dropped_bytes = 0 - - print("\t".join(["decision", "user", "last_modified", "objects", "bytes"])) - for user in sorted(users): - entry = users[user] - keep = entry["last"] >= cutoff - if keep: - kept += 1 - kept_bytes += entry["bytes"] - else: - dropped += 1 - dropped_bytes += entry["bytes"] - print( - "\t".join( - [ - "KEEP" if keep else "DROP", - user, - entry["last"].isoformat(), - str(entry["objects"]), - str(entry["bytes"]), - ] - ) - ) - - def gigabytes(value): - return f"{value / 1024 ** 3:.2f} GB" - - print( - f"Stichtag: {cutoff.date().isoformat()}\n" - f" behalten: {kept:>7} Accounts, {gigabytes(kept_bytes)}\n" - f" verworfen: {dropped:>7} Accounts, {gigabytes(dropped_bytes)}", - file=sys.stderr, - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/migrate-projects.sh b/scripts/migrate-projects.sh deleted file mode 100755 index 772c88923..000000000 --- a/scripts/migrate-projects.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env bash -# -# Migriert die Nutzerdaten aus dem alten in den neuen Projekt-Bucket. -# -# Projekte werden dabei aufgeräumt: Übernommen wird ein Account nur, wenn -# mindestens eines seiner Objekte innerhalb des Stichtags zuletzt geändert -# wurde. Wer also in den letzten Jahren etwas bearbeitet hat, behält alle -# seine Projekte, alle anderen fallen weg. -# -# SRC_BUCKET=… ./scripts/migrate-projects.sh plan # Auswahl berechnen -# SRC_BUCKET=… DST_BUCKET=… ./scripts/migrate-projects.sh apply -# -# Assets und geteilte Ergebnisse werden dagegen vollständig kopiert, ohne -# Auswahl. Assets sind inhaltsadressiert und über alle Nutzer geteilt, und -# Share-Links sind öffentlich im Umlauf: -# -# SRC_BUCKET=… DST_BUCKET=… ./scripts/migrate-projects.sh shared -# -# `apply` und `shared` sind ohne DRY_RUN=0 nur Probeläufe. Beide nutzen -# `aws s3 sync`, sind also inkrementell: Ein erster Lauf vor dem Wartungs- -# fenster und ein zweiter darin holt nur noch die Differenz. -# -# Als Stichtag dient `LastModified` der S3-Objekte. Die index.json eines -# Accounts wird bei jedem Speichern neu geschrieben, der Wert entspricht also -# dem `updated_at` aus den Projektdaten. Vorsicht: Wurde der Quell-Bucket -# jemals im Ganzen umkopiert, sind die Zeitstempel zurückgesetzt und die -# Auswahl wäre wertlos. Das vorher einmal stichprobenartig prüfen. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -SRC_BUCKET="${SRC_BUCKET:-}" -DST_BUCKET="${DST_BUCKET:-}" -PREFIX="${PREFIX:-data/projects/}" -SHARED_PREFIXES="${SHARED_PREFIXES:-data/assets/ data/sharing/}" -YEARS="${YEARS:-2}" -PLAN_FILE="${PLAN_FILE:-migration-plan.tsv}" -# Profile der lokalen AWS-CLI-Config. SRC_PROFILE darf leer bleiben, wenn die -# Credentials aus DST_PROFILE den Quell-Bucket lesen dürfen. -SRC_PROFILE="${SRC_PROFILE:-}" -DST_PROFILE="${DST_PROFILE:-}" -PARALLEL="${PARALLEL:-8}" -DRY_RUN="${DRY_RUN:-1}" - -src_args=() -if [ -n "$SRC_PROFILE" ]; then - src_args=(--profile "$SRC_PROFILE") -fi -dst_args=() -if [ -n "$DST_PROFILE" ]; then - dst_args=(--profile "$DST_PROFILE") -fi - -die() { - echo "Fehler: $*" >&2 - exit 1 -} - -usage() { - cat >&2 <<'EOF' -Aufruf: migrate-projects.sh plan|apply|shared - - plan Auswahl der Accounts berechnen und als Planungsdatei schreiben - apply die auf KEEP stehenden Accounts kopieren - shared Assets und geteilte Ergebnisse vollständig kopieren - -Umgebungsvariablen: - SRC_BUCKET Quell-Bucket (Pflicht) - DST_BUCKET Ziel-Bucket (Pflicht für apply und shared) - PREFIX Standard: data/projects/ - SHARED_PREFIXES Standard: data/assets/ data/sharing/ - YEARS Stichtag in Jahren, Standard: 2 - PLAN_FILE Standard: migration-plan.tsv - SRC_PROFILE AWS-Profil für den Quell-Bucket (optional) - DST_PROFILE AWS-Profil für den Ziel-Bucket - PARALLEL parallele Kopiervorgänge, Standard: 8 - DRY_RUN 1 (Standard) = nur anzeigen, 0 = wirklich kopieren -EOF - exit 1 -} - -# Kopiert serverseitig. Die Credentials aus DST_PROFILE brauchen dafür -# Lesezugriff auf den Quell-Bucket und kms:GenerateDataKey auf den Key des -# Ziel-Buckets. -sync_args=(--only-show-errors) -prepare_sync() { - [ -n "$SRC_BUCKET" ] || die "SRC_BUCKET ist nicht gesetzt" - [ -n "$DST_BUCKET" ] || die "DST_BUCKET ist nicht gesetzt" - if [ "$DRY_RUN" != "0" ]; then - sync_args+=(--dryrun) - echo "Probelauf. Zum echten Kopieren DRY_RUN=0 setzen." >&2 - fi -} - -# ---------------------------------------------------------------- plan ----- - -plan() { - [ -n "$SRC_BUCKET" ] || die "SRC_BUCKET ist nicht gesetzt" - - echo "Liste Objekte unter s3://$SRC_BUCKET/$PREFIX ..." >&2 - - aws "${src_args[@]}" s3api list-objects-v2 \ - --bucket "$SRC_BUCKET" \ - --prefix "$PREFIX" \ - --query 'Contents[].[Key,LastModified,Size]' \ - --output text \ - | python3 "$SCRIPT_DIR/lib/migration-plan.py" "$PREFIX" "$YEARS" \ - >"$PLAN_FILE" - - echo "Plan geschrieben: $PLAN_FILE" >&2 -} - -# --------------------------------------------------------------- apply ----- - -apply() { - prepare_sync - [ -f "$PLAN_FILE" ] || die "$PLAN_FILE fehlt - erst 'plan' ausführen" - - local total - total=$(awk -F'\t' '$1 == "KEEP"' "$PLAN_FILE" | wc -l) - echo "Kopiere $total Accounts mit $PARALLEL parallelen Vorgängen ..." >&2 - - awk -F'\t' '$1 == "KEEP" { print $2 }' "$PLAN_FILE" \ - | xargs -P "$PARALLEL" -I{} \ - aws "${dst_args[@]}" s3 sync \ - "s3://$SRC_BUCKET/$PREFIX{}/" \ - "s3://$DST_BUCKET/$PREFIX{}/" \ - "${sync_args[@]}" - - echo "Fertig." >&2 -} - -# -------------------------------------------------------------- shared ----- - -shared() { - prepare_sync - - # Bewusst ohne Auswahl: Assets sind inhaltsadressiert und werden über alle - # Nutzer geteilt, geteilte Ergebnisse hängen an öffentlich verschickten Links. - for shared_prefix in $SHARED_PREFIXES; do - echo "Kopiere s3://$SRC_BUCKET/$shared_prefix ..." >&2 - aws "${dst_args[@]}" s3 sync \ - "s3://$SRC_BUCKET/$shared_prefix" \ - "s3://$DST_BUCKET/$shared_prefix" \ - "${sync_args[@]}" - done - - echo "Fertig." >&2 -} - -case "${1:-}" in - plan) plan ;; - apply) apply ;; - shared) shared ;; - *) usage ;; -esac