410 lines
16 KiB
Python
410 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Vollstaendiger Publish-Workflow: Version bumpen, bauen, auf VPS deployen, committen.
|
||
|
||
Schritte:
|
||
1. versionCode automatisch erhoehen (aus build.gradle.kts)
|
||
2. APK bauen (./gradlew assembleDebug) – ueberspringbar mit --skip-build
|
||
3. APK per SCP auf VPS hochladen
|
||
4. Server-Version per API aktualisieren (kein Container-Neustart noetig)
|
||
5. Verifizieren – ueberspringbar mit --skip-verify
|
||
6. git commit + push – ueberspringbar mit --skip-push
|
||
|
||
Rollback-Strategie:
|
||
- Fehler vor/waehrend Upload -> build.gradle.kts wird automatisch zurueckgesetzt
|
||
- Fehler nach Upload (API) -> APK liegt schon oben; kein Rollback, Recovery-Hinweis
|
||
- Fehler bei Verify/Git -> Deployment war erfolgreich; nur Warnung, kein Abbruch
|
||
|
||
Verwendung:
|
||
python publish-apk.py
|
||
python publish-apk.py --version-name 2.0.42
|
||
python publish-apk.py --skip-build --skip-push
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import platform
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
|
||
GRADLEW = "gradlew.bat" if platform.system() == "Windows" else "./gradlew"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Konfiguration
|
||
# ---------------------------------------------------------------------------
|
||
VPS = "root@195.246.231.210"
|
||
REMOTE_DIR = "/opt/bollwerk"
|
||
SERVER_URL = "https://bollwerk.online"
|
||
BUILD_GRADLE = "app/build.gradle.kts"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Farb-Hilfsfunktionen (ANSI, funktioniert in Windows Terminal / PowerShell 7)
|
||
# ---------------------------------------------------------------------------
|
||
RESET = "\033[0m"
|
||
GREEN = "\033[92m"
|
||
YELLOW = "\033[93m"
|
||
RED = "\033[91m"
|
||
CYAN = "\033[96m"
|
||
GRAY = "\033[90m"
|
||
|
||
def ok(msg): print(f"{GREEN}[OK] {msg}{RESET}", flush=True)
|
||
def fail(msg): print(f"{RED}[!!] {msg}{RESET}", flush=True)
|
||
def skip(msg): print(f"{GRAY}[--] {msg}{RESET}", flush=True)
|
||
def step(msg): print(f"\n{YELLOW}{msg}{RESET}", flush=True)
|
||
def warn(msg): print(f"{YELLOW}[??] {msg}{RESET}", flush=True)
|
||
def info(msg): print(f"{GRAY} {msg}{RESET}", flush=True)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Hilfsfunktionen
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def run(cmd, check=True):
|
||
"""Fuehrt einen Shell-Befehl aus und gibt den Return-Code zurueck."""
|
||
result = subprocess.run(cmd, shell=True)
|
||
if check and result.returncode != 0:
|
||
raise RuntimeError(f"Befehl fehlgeschlagen (Exit {result.returncode}): {cmd}")
|
||
return result.returncode
|
||
|
||
|
||
def rollback_gradle(original_content: str):
|
||
print(f"{YELLOW}[ROLLBACK] build.gradle.kts wird zurueckgesetzt...{RESET}", flush=True)
|
||
try:
|
||
with open(BUILD_GRADLE, "w", encoding="utf-8") as f:
|
||
f.write(original_content)
|
||
print(f"{YELLOW}[ROLLBACK] build.gradle.kts zurueckgesetzt.{RESET}", flush=True)
|
||
except Exception as e:
|
||
print(f"{RED}[ROLLBACK FEHLGESCHLAGEN] Bitte build.gradle.kts manuell korrigieren: {e}{RESET}", flush=True)
|
||
|
||
|
||
def post_json(url: str, body: dict, token: str, timeout: int = 20):
|
||
"""HTTP POST mit JSON-Body und Bearer-Token. Gibt status_code zurueck."""
|
||
data = json.dumps(body).encode("utf-8")
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=data,
|
||
headers={
|
||
"Authorization": f"Bearer {token}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
method="POST",
|
||
)
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
return resp.status
|
||
|
||
|
||
def get_json(url: str, timeout: int = 15):
|
||
req = urllib.request.Request(url)
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
return json.loads(resp.read().decode("utf-8"))
|
||
|
||
|
||
def get_status(url: str, timeout: int = 10):
|
||
req = urllib.request.Request(url)
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
return resp.status
|
||
|
||
|
||
def parse_major_minor(version_name: str):
|
||
"""Extrahiert major/minor aus x.y oder x.y.z."""
|
||
m = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?$", version_name.strip())
|
||
if not m:
|
||
return None
|
||
return m.group(1), m.group(2)
|
||
|
||
|
||
def validate_three_part_version(version_name: str):
|
||
"""Validiert strikt x.y.z (nur numerische Segmente)."""
|
||
m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_name.strip())
|
||
if not m:
|
||
return None
|
||
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||
|
||
|
||
def _extract_admin_token(raw_text: str) -> str:
|
||
"""Extrahiert BOLLWERK_ADMIN_TOKEN aus docker-compose/.env-Inhalten."""
|
||
if not raw_text:
|
||
return ""
|
||
|
||
patterns = [
|
||
r"(?m)^\s*BOLLWERK_ADMIN_TOKEN\s*[:=]\s*['\"]?([^'\"\s#]+)",
|
||
r"(?m)^\s*-\s*BOLLWERK_ADMIN_TOKEN\s*[:=]\s*['\"]?([^'\"\s#]+)",
|
||
r"(?m)^\s*-\s*BOLLWERK_ADMIN_TOKEN\s*=\s*['\"]?([^'\"\s#]+)",
|
||
]
|
||
for pattern in patterns:
|
||
match = re.search(pattern, raw_text)
|
||
if match:
|
||
return match.group(1).strip()
|
||
return ""
|
||
|
||
|
||
def get_admin_token_from_vps() -> str:
|
||
"""Holt den Admin-Token direkt vom VPS (Dateien, dann Container-Env)."""
|
||
ssh_opts = "-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no"
|
||
|
||
probe_commands = [
|
||
(
|
||
"compose+env files",
|
||
f'ssh {ssh_opts} {VPS} '
|
||
f'"cd {REMOTE_DIR}; '
|
||
f'[ -f docker-compose.yml ] && cat docker-compose.yml || true; '
|
||
f'echo __ENV_SPLIT__; '
|
||
f'[ -f .env ] && cat .env || true"',
|
||
),
|
||
(
|
||
"docker inspect env",
|
||
f'ssh {ssh_opts} {VPS} "docker inspect $(docker ps -q) 2>/dev/null || true"',
|
||
),
|
||
]
|
||
|
||
for _, cmd in probe_commands:
|
||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||
token = _extract_admin_token(result.stdout or "")
|
||
if token:
|
||
return token
|
||
return ""
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Argument-Parsing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def parse_args():
|
||
parser = argparse.ArgumentParser(description="Bollwerk APK Publish-Workflow")
|
||
parser.add_argument("--version-name", default="", help="Neue versionName im Format x.y.z (z.B. '1.7.14')")
|
||
parser.add_argument("--version-code", type=int, default=0, help="Expliziter versionCode")
|
||
parser.add_argument("--apk-path", default="app/build/outputs/apk/debug/app-debug.apk")
|
||
parser.add_argument("--skip-build", action="store_true")
|
||
parser.add_argument("--skip-verify", action="store_true")
|
||
parser.add_argument("--skip-push", action="store_true")
|
||
return parser.parse_args()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main():
|
||
args = parse_args()
|
||
|
||
# --- Pre-Check: build.gradle.kts lesen ---
|
||
if not os.path.exists(BUILD_GRADLE):
|
||
fail(f"Datei nicht gefunden: {BUILD_GRADLE}")
|
||
sys.exit(1)
|
||
|
||
with open(BUILD_GRADLE, "r", encoding="utf-8") as f:
|
||
original_content = f.read()
|
||
|
||
code_match = re.search(r'versionCode\s*=\s*(\d+)', original_content)
|
||
name_match = re.search(r'versionName\s*=\s*"([^"]+)"', original_content)
|
||
if not code_match:
|
||
fail(f"versionCode nicht in {BUILD_GRADLE} gefunden.")
|
||
sys.exit(1)
|
||
if not name_match:
|
||
fail(f"versionName nicht in {BUILD_GRADLE} gefunden.")
|
||
sys.exit(1)
|
||
|
||
current_code = int(code_match.group(1))
|
||
current_name = name_match.group(1)
|
||
new_code = args.version_code if args.version_code > 0 else current_code + 1
|
||
|
||
if args.version_name:
|
||
parsed = validate_three_part_version(args.version_name)
|
||
if not parsed:
|
||
fail("--version-name muss strikt im Format x.y.z angegeben werden (z. B. 1.7.14).")
|
||
sys.exit(1)
|
||
if parsed[2] != new_code:
|
||
fail(f"Patch-Segment der versionName ({parsed[2]}) muss dem versionCode ({new_code}) entsprechen.")
|
||
sys.exit(1)
|
||
new_name = args.version_name.strip()
|
||
else:
|
||
major_minor = parse_major_minor(current_name)
|
||
if not major_minor:
|
||
fail(f"Aktuelle versionName '{current_name}' hat kein gueltiges Schema. Erlaubt: x.y oder x.y.z")
|
||
sys.exit(1)
|
||
new_name = f"{major_minor[0]}.{major_minor[1]}.{new_code}"
|
||
|
||
if new_code <= current_code:
|
||
fail(f"Neuer versionCode ({new_code}) muss groesser als aktueller ({current_code}) sein.")
|
||
sys.exit(1)
|
||
|
||
# --- Pre-Check: SSH-Agent ---
|
||
rc = subprocess.run("ssh-add -l", shell=True, capture_output=True).returncode
|
||
if rc != 0:
|
||
fail("SSH-Agent hat keinen Key. Bitte ausfuehren: ssh-add C:\\Users\\JensR\\.ssh\\id_ed25519")
|
||
sys.exit(1)
|
||
|
||
# --- Pre-Check: Admin-Token direkt vom VPS holen ---
|
||
step("[Preflight] Lade BOLLWERK_ADMIN_TOKEN direkt vom VPS...")
|
||
admin_token = get_admin_token_from_vps()
|
||
if admin_token:
|
||
ok("Admin-Token vom VPS geladen")
|
||
else:
|
||
fail("BOLLWERK_ADMIN_TOKEN konnte auf dem VPS nicht ermittelt werden.")
|
||
info(f"Pruefe auf {REMOTE_DIR}/docker-compose.yml, {REMOTE_DIR}/.env oder Container-Env")
|
||
sys.exit(1)
|
||
|
||
ok("Pre-Checks bestanden (Token, Gradle-Parsing, SSH-Agent)")
|
||
|
||
print(f"\n{CYAN}=== Publish APK v{new_name} ==={RESET}", flush=True)
|
||
print(f"{GRAY} {current_name} [{current_code}] -> {new_name} [{new_code}]{RESET}", flush=True)
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Schritt 0: build.gradle.kts patchen
|
||
# -------------------------------------------------------------------------
|
||
patched = re.sub(
|
||
rf'versionCode\s*=\s*{current_code}\b',
|
||
f'versionCode = {new_code}',
|
||
original_content,
|
||
)
|
||
patched = re.sub(
|
||
r'versionName\s*=\s*"[^"]+"',
|
||
f'versionName = "{new_name}"',
|
||
patched,
|
||
)
|
||
|
||
try:
|
||
with open(BUILD_GRADLE, "w", encoding="utf-8") as f:
|
||
f.write(patched)
|
||
ok(f"build.gradle.kts aktualisiert (versionCode={new_code}, versionName={new_name})")
|
||
except Exception as e:
|
||
fail(f"build.gradle.kts konnte nicht geschrieben werden: {e}")
|
||
sys.exit(1)
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Schritt 1: Build
|
||
# -------------------------------------------------------------------------
|
||
if args.skip_build:
|
||
skip("Build uebersprungen (--skip-build)")
|
||
else:
|
||
step("[1/4] APK bauen...")
|
||
rc = subprocess.run(f"{GRADLEW} assembleDebug", shell=True).returncode
|
||
if rc != 0:
|
||
fail(f"Build fehlgeschlagen (Exit {rc})")
|
||
rollback_gradle(original_content)
|
||
sys.exit(1)
|
||
ok("APK gebaut")
|
||
|
||
if not os.path.exists(args.apk_path):
|
||
fail(f"APK nicht gefunden nach Build: {args.apk_path}")
|
||
rollback_gradle(original_content)
|
||
sys.exit(1)
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Schritt 2: APK hochladen
|
||
# -------------------------------------------------------------------------
|
||
step("[2/4] APK hochladen -> VPS...")
|
||
try:
|
||
rc = subprocess.run(
|
||
f'ssh -o ConnectTimeout=15 {VPS} "mkdir -p {REMOTE_DIR}/data"',
|
||
shell=True,
|
||
).returncode
|
||
if rc != 0:
|
||
raise RuntimeError(f"ssh mkdir fehlgeschlagen (Exit {rc})")
|
||
|
||
rc = subprocess.run(
|
||
f'scp -o ConnectTimeout=30 "{args.apk_path}" "{VPS}:{REMOTE_DIR}/data/app-latest.apk"',
|
||
shell=True,
|
||
).returncode
|
||
if rc != 0:
|
||
raise RuntimeError(f"scp fehlgeschlagen (Exit {rc})")
|
||
|
||
ok("APK hochgeladen")
|
||
except Exception as e:
|
||
fail(f"Upload fehlgeschlagen: {e}")
|
||
rollback_gradle(original_content)
|
||
info(f"TIPP: Pruefen ob VPS erreichbar ist: ssh {VPS} 'echo OK'")
|
||
sys.exit(1)
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Schritt 3: Server-Version per API setzen
|
||
# Ab hier kein Rollback mehr (APK liegt schon oben)
|
||
# -------------------------------------------------------------------------
|
||
step("[3/4] Server-Version aktualisieren...")
|
||
api_body = {"versionCode": new_code, "versionName": new_name}
|
||
api_success = False
|
||
max_retries = 2
|
||
|
||
for attempt in range(1, max_retries + 1):
|
||
try:
|
||
status = post_json(f"{SERVER_URL}/api/admin/version", api_body, admin_token)
|
||
if status == 200:
|
||
api_success = True
|
||
break
|
||
raise RuntimeError(f"HTTP {status}")
|
||
except Exception as e:
|
||
if attempt < max_retries:
|
||
print(f" Versuch {attempt} fehlgeschlagen ({e}) - Retry in 3s...", flush=True)
|
||
time.sleep(3)
|
||
else:
|
||
fail(f"API-Call nach {max_retries} Versuchen fehlgeschlagen: {e}")
|
||
info(f"APK liegt bereits auf dem VPS. Recovery:")
|
||
info(f" curl -X POST {SERVER_URL}/api/admin/version \\")
|
||
info(f" -H 'Authorization: Bearer <TOKEN>' \\")
|
||
info(f" -H 'Content-Type: application/json' \\")
|
||
info(f" -d '{json.dumps(api_body)}'")
|
||
|
||
if api_success:
|
||
ok(f"Version gesetzt: {new_name}")
|
||
else:
|
||
warn("VPS-Version nicht aktualisiert. build.gradle.kts wird trotzdem committet.")
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Schritt 4: Verifizieren (non-fatal)
|
||
# -------------------------------------------------------------------------
|
||
if not api_success or args.skip_verify:
|
||
skip("Verifizierung uebersprungen")
|
||
else:
|
||
step("[4/4] Verifizieren...")
|
||
time.sleep(2)
|
||
|
||
try:
|
||
ver = get_json(f"{SERVER_URL}/api/version")
|
||
if ver.get("versionCode") == new_code and ver.get("versionName") == new_name:
|
||
ok(f"/api/version: {ver.get('versionName')} ({ver.get('versionCode')})")
|
||
else:
|
||
warn(f"/api/version meldet {ver.get('versionName')} ({ver.get('versionCode')}) - erwartet {new_name} ({new_code})")
|
||
except Exception as e:
|
||
warn(f"/api/version nicht erreichbar: {e}")
|
||
|
||
try:
|
||
status = get_status(f"{SERVER_URL}/")
|
||
ok(f"Homepage erreichbar (HTTP {status})")
|
||
except Exception as e:
|
||
warn(f"Homepage nicht erreichbar: {e}")
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Git Commit + Push
|
||
# -------------------------------------------------------------------------
|
||
step("[Git] Version-Bump committen...")
|
||
try:
|
||
run(f'git add "{BUILD_GRADLE}"')
|
||
run(f'git commit -m "chore: release v{new_name}"')
|
||
except Exception as e:
|
||
warn(f"git commit fehlgeschlagen: {e}")
|
||
info(f'Manuell: git add "{BUILD_GRADLE}" ; git commit -m "chore: release v{new_name}"')
|
||
|
||
if not args.skip_push:
|
||
try:
|
||
run("git push")
|
||
ok("Gepusht")
|
||
except Exception as e:
|
||
warn(f"git push fehlgeschlagen: {e}")
|
||
info("Manuell: git push")
|
||
else:
|
||
skip("Push uebersprungen (--skip-push)")
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Zusammenfassung
|
||
# -------------------------------------------------------------------------
|
||
print(f"\n{CYAN}=== Publish abgeschlossen ==={RESET}", flush=True)
|
||
print(f" Version : {new_name}", flush=True)
|
||
print(f" Code : {new_code}", flush=True)
|
||
print(f" Homepage: {SERVER_URL}/", flush=True)
|
||
print(f" API : {SERVER_URL}/api/version", flush=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|