Files
sufw-taetigkeitsnachweise/1_build-pipeline/deploy.py
T

144 lines
5.7 KiB
Python

"""Package and publish the last successful build to the SMB update directory."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import tempfile
import zipfile
PROJECT_ROOT = Path(__file__).resolve().parent.parent
BUILD_DIR = PROJECT_ROOT / "2_build"
DEPLOY_DIR = Path(r"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren")
EXE_NAME = "Tätigkeitsnachweis-Generator.exe"
MANIFEST_NAME = "release.json"
PACKAGE_FILES = [
EXE_NAME, "README.md", "CHANGELOG.md",
"config/settings.ini", "config/input-fields.json", "config/output-fields.json",
"template/Tätigkeitsnachweise Stellenförderung.pdf",
]
def normalize_version(version: str) -> str:
if not re.fullmatch(r"\d+\.\d+(?:\.\d+)?", version):
raise ValueError(f"Ungültige Versionsnummer: {version}")
return ".".join(str(int(part)) for part in (version.split(".") + ["0"])[:3])
def file_hash(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def write_release_manifest(build_dir: Path, version: str) -> None:
"""Record the version and all payload hashes after a successful build."""
manifest = {
"version": normalize_version(version),
"files": {name: file_hash(build_dir / name) for name in PACKAGE_FILES},
}
(build_dir / MANIFEST_NAME).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def validate_release(build_dir: Path) -> str:
if not (build_dir / MANIFEST_NAME).is_file():
raise ValueError("Kein bestätigter Build vorhanden. Bitte zuerst build.bat ausführen.")
manifest = json.loads((build_dir / MANIFEST_NAME).read_text(encoding="utf-8"))
version = normalize_version(manifest["version"])
for name in PACKAGE_FILES:
path = build_dir / name
if not path.is_file() or file_hash(path) != manifest.get("files", {}).get(name):
raise ValueError(f"Build-Datei fehlt oder wurde nach dem Build verändert: {name}. Bitte neu bauen.")
return version
def check_deploy_target(target_dir: Path) -> None:
"""Never create the SMB destination: a missing mount must be an error."""
try:
if not target_dir.is_dir():
raise OSError("Verzeichnis ist nicht verfügbar")
# Actually access the directory; exists() alone does not check read access.
with os.scandir(target_dir) as entries:
next(entries, None)
except OSError as exc:
raise OSError(
f"Deployment-Ziel nicht erreichbar: {target_dir}\n"
"Bitte VPN-Verbindung und Netzlaufwerk J: prüfen und deploy.bat erneut starten."
) from exc
def deploy(build_dir: Path = BUILD_DIR, target_dir: Path = DEPLOY_DIR) -> Path:
version = validate_release(build_dir)
check_deploy_target(target_dir)
archive_name = f"Tätigkeitsnachweis-Generator v{version}.zip"
archive = build_dir / archive_name
descriptor, temporary = tempfile.mkstemp(prefix="release-", suffix=".zip", dir=build_dir)
os.close(descriptor)
temporary = Path(temporary)
try:
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as package:
for name in PACKAGE_FILES:
package.write(build_dir / name, name)
for directory in ["input/", "output/"]:
# Fixed timestamps keep repeated deployments byte-identical.
package.writestr(zipfile.ZipInfo(directory, date_time=(2020, 1, 1, 0, 0, 0)), b"")
with zipfile.ZipFile(temporary) as package:
if package.testzip() is not None:
raise ValueError("Das erzeugte ZIP-Paket ist beschädigt.")
temporary.replace(archive)
finally:
temporary.unlink(missing_ok=True)
destination = target_dir / archive_name
if destination.exists():
if file_hash(destination) == file_hash(archive):
print(f"[OK] Diese Version ist bereits unverändert bereitgestellt: {destination}")
return destination
raise FileExistsError(f"{destination} existiert bereits mit anderem Inhalt. Bitte eine neue Version bauen.")
# Write access is checked by creating the staging file on the actual share.
remote_temporary = None
try:
descriptor, name = tempfile.mkstemp(prefix=".upload-", suffix=".tmp", dir=target_dir)
os.close(descriptor)
remote_temporary = Path(name)
shutil.copyfile(archive, remote_temporary)
if file_hash(remote_temporary) != file_hash(archive):
raise OSError("Die Prüfsumme nach der SMB-Übertragung stimmt nicht überein.")
# On Windows rename refuses to overwrite another published release.
remote_temporary.rename(destination)
except OSError as exc:
raise OSError(
"Deployment fehlgeschlagen. VPN-Verbindung und Schreibrechte prüfen. "
"Der erfolgreiche lokale Build bleibt erhalten; erneut mit deploy.bat starten."
) from exc
finally:
if remote_temporary is not None:
try:
remote_temporary.unlink(missing_ok=True)
except OSError:
pass # The VPN may have disconnected during transfer.
print(f"[OK] Deployment abgeschlossen: {destination}")
return destination
def main() -> int:
parser = argparse.ArgumentParser(description="Letzten erfolgreichen Build auf dem SMB-Laufwerk bereitstellen")
parser.parse_args()
try:
deploy()
except (OSError, ValueError, KeyError) as exc:
print(f"[FEHLER] {exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())