340 lines
11 KiB
Python
340 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import boto3
|
|
from botocore.config import Config
|
|
from botocore.exceptions import ClientError
|
|
|
|
from patch_layout import PATCH_LAYOUT, normalized_path
|
|
|
|
S3_PREFIX = "World of Warcraft"
|
|
MANIFEST_S3_KEY = "manifest.json"
|
|
MANAGED_MANIFEST_S3_KEY = "moonwell-managed-manifest.json"
|
|
|
|
LEGACY_MANAGED_FILES = {
|
|
"utils/warcraftxlhost.exe",
|
|
"utils/warcraftxlhost.log",
|
|
"warcraftxl.before-texture-upload-fix.dll",
|
|
}
|
|
|
|
def compute_build_hash(files: list[dict]) -> str:
|
|
lines = [
|
|
f'{item["path"]}:{item["size"]}:{item["sha256"]}'
|
|
for item in sorted(files, key=lambda item: item["path"])
|
|
]
|
|
return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
|
|
|
|
|
|
def normalized_path_key(path: str) -> str:
|
|
"""Match client paths using Windows filesystem semantics."""
|
|
return normalized_path(path)
|
|
|
|
|
|
def missing_repository_patches(manifest: dict) -> list[str]:
|
|
paths = {
|
|
normalized_path_key(item["path"])
|
|
for item in manifest.get("files", [])
|
|
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
}
|
|
return sorted(PATCH_LAYOUT.repository_patches - paths)
|
|
|
|
|
|
def external_graphics_patches(manifest: dict) -> list[str]:
|
|
return sorted(
|
|
item["path"]
|
|
for item in manifest.get("files", [])
|
|
if isinstance(item, dict)
|
|
and isinstance(item.get("path"), str)
|
|
and PATCH_LAYOUT.is_external_graphics_patch(item["path"])
|
|
)
|
|
|
|
|
|
def is_legacy_managed_path(path: str) -> bool:
|
|
key = normalized_path_key(path)
|
|
return (
|
|
key in LEGACY_MANAGED_FILES
|
|
or key in {"wow.exe", "d3d9.dll", "warcraftxl.dll", "utils/d3d9-native.dll"}
|
|
or key.startswith("extensions/")
|
|
or key.startswith("data/patch-wxl.mpq/")
|
|
)
|
|
|
|
|
|
def merge_manifests(
|
|
base_manifest: dict,
|
|
staging_manifest: dict,
|
|
previous_managed_paths: list[str] | None = None,
|
|
) -> dict:
|
|
previous_managed_keys = {
|
|
normalized_path_key(path) for path in (previous_managed_paths or [])
|
|
}
|
|
files_by_path = {
|
|
normalized_path_key(item["path"]): item
|
|
for item in base_manifest.get("files", [])
|
|
if not (
|
|
normalized_path_key(item["path"]) in previous_managed_keys
|
|
or is_legacy_managed_path(item["path"])
|
|
or PATCH_LAYOUT.is_repository_patch(item["path"])
|
|
)
|
|
or PATCH_LAYOUT.is_external_graphics_patch(item["path"])
|
|
}
|
|
for item in staging_manifest.get("files", []):
|
|
if PATCH_LAYOUT.is_external_graphics_patch(item["path"]):
|
|
continue
|
|
files_by_path[normalized_path_key(item["path"])] = item
|
|
|
|
files = sorted(files_by_path.values(), key=lambda item: item["path"].casefold())
|
|
return {
|
|
"buildHash": compute_build_hash(files),
|
|
"files": files,
|
|
}
|
|
|
|
|
|
def find_stale_managed_paths(
|
|
base_manifest: dict,
|
|
staging_manifest: dict,
|
|
previous_managed_paths: list[str] | None = None,
|
|
) -> list[str]:
|
|
previous_managed_keys = {
|
|
normalized_path_key(path) for path in (previous_managed_paths or [])
|
|
}
|
|
staging_keys = {
|
|
normalized_path_key(item["path"])
|
|
for item in staging_manifest.get("files", [])
|
|
}
|
|
return sorted(
|
|
(
|
|
item["path"]
|
|
for item in base_manifest.get("files", [])
|
|
if (
|
|
(
|
|
normalized_path_key(item["path"]) in previous_managed_keys
|
|
or is_legacy_managed_path(item["path"])
|
|
or PATCH_LAYOUT.is_repository_patch(item["path"])
|
|
)
|
|
and not PATCH_LAYOUT.is_external_graphics_patch(item["path"])
|
|
)
|
|
and normalized_path_key(item["path"]) not in staging_keys
|
|
),
|
|
key=str.casefold,
|
|
)
|
|
|
|
|
|
def load_dotenv(env_path: Path) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
if not env_path.exists():
|
|
return values
|
|
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if key.startswith("export "):
|
|
key = key.removeprefix("export ").strip()
|
|
if not key:
|
|
continue
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
value = value[1:-1]
|
|
values[key] = value
|
|
return values
|
|
|
|
|
|
def get_env(dotenv: dict[str, str], key: str) -> str:
|
|
value = os.environ.get(key) or dotenv.get(key)
|
|
if not value:
|
|
print(f"ERROR: {key} is not set (env or .env)", file=sys.stderr)
|
|
sys.exit(1)
|
|
return value
|
|
|
|
|
|
def main() -> int:
|
|
project_root = Path(__file__).resolve().parent
|
|
dotenv = load_dotenv(project_root / ".env")
|
|
|
|
bucket = get_env(dotenv, "AWS_BUCKET")
|
|
endpoint = get_env(dotenv, "AWS_ENDPOINT")
|
|
region = get_env(dotenv, "AWS_DEFAULT_REGION")
|
|
access_key = get_env(dotenv, "AWS_ACCESS_KEY_ID")
|
|
secret_key = get_env(dotenv, "AWS_SECRET_ACCESS_KEY")
|
|
use_path_style = get_env(dotenv, "AWS_USE_PATH_STYLE_ENDPOINT").lower() == "true"
|
|
|
|
s3 = boto3.client(
|
|
"s3",
|
|
endpoint_url=endpoint,
|
|
region_name=region,
|
|
aws_access_key_id=access_key,
|
|
aws_secret_access_key=secret_key,
|
|
config=Config(s3={"addressing_style": "path"}) if use_path_style else None,
|
|
)
|
|
|
|
manifest_path = project_root / "manifest.json"
|
|
if not manifest_path.exists():
|
|
print("ERROR: manifest.json not found", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
staging_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as error:
|
|
print(f"ERROR: unable to read local manifest.json: {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
missing_repository = missing_repository_patches(staging_manifest)
|
|
if missing_repository:
|
|
print(
|
|
"ERROR: local manifest is missing repository patches: "
|
|
+ ", ".join(missing_repository),
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
unexpected_graphics = external_graphics_patches(staging_manifest)
|
|
if unexpected_graphics:
|
|
print(
|
|
"ERROR: managed manifest contains externally managed graphics patches: "
|
|
+ ", ".join(unexpected_graphics),
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
try:
|
|
response = s3.get_object(Bucket=bucket, Key=MANIFEST_S3_KEY)
|
|
production_manifest = json.loads(response["Body"].read())
|
|
except ClientError as error:
|
|
error_code = error.response.get("Error", {}).get("Code")
|
|
if error_code not in {"NoSuchKey", "404"}:
|
|
print(f"ERROR: unable to read production manifest: {error}", file=sys.stderr)
|
|
return 1
|
|
production_manifest = {"files": []}
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
print(f"ERROR: invalid production manifest: {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
response = s3.get_object(Bucket=bucket, Key=MANAGED_MANIFEST_S3_KEY)
|
|
previous_managed_manifest = json.loads(response["Body"].read())
|
|
previous_managed_paths = [
|
|
item["path"] for item in previous_managed_manifest.get("files", [])
|
|
]
|
|
except ClientError as error:
|
|
error_code = error.response.get("Error", {}).get("Code")
|
|
if error_code not in {"NoSuchKey", "404"}:
|
|
print(f"ERROR: unable to read managed manifest: {error}", file=sys.stderr)
|
|
return 1
|
|
previous_managed_paths = []
|
|
except (UnicodeDecodeError, json.JSONDecodeError, KeyError) as error:
|
|
print(f"ERROR: invalid managed manifest: {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
merged_manifest = merge_manifests(
|
|
production_manifest,
|
|
staging_manifest,
|
|
previous_managed_paths,
|
|
)
|
|
stale_managed_paths = find_stale_managed_paths(
|
|
production_manifest,
|
|
staging_manifest,
|
|
previous_managed_paths,
|
|
)
|
|
print(
|
|
"Manifest merge: "
|
|
f"production={len(production_manifest.get('files', []))}, "
|
|
f"staging={len(staging_manifest.get('files', []))}, "
|
|
f"result={len(merged_manifest['files'])}"
|
|
)
|
|
|
|
dist_dir = project_root / "dist"
|
|
if not dist_dir.exists():
|
|
print(f"ERROR: dist/ directory not found. Run build first.", file=sys.stderr)
|
|
return 1
|
|
|
|
errors = False
|
|
|
|
# Загрузка всех файлов из dist/ с сохранением относительных путей
|
|
files = sorted(f for f in dist_dir.rglob("*") if f.is_file())
|
|
if not files:
|
|
print("ERROR: dist/ is empty, nothing to upload.", file=sys.stderr)
|
|
return 1
|
|
|
|
for local_path in files:
|
|
rel_path = local_path.relative_to(dist_dir).as_posix()
|
|
if PATCH_LAYOUT.is_external_graphics_patch(rel_path):
|
|
print(f"Skipping externally managed graphics patch: {rel_path}")
|
|
continue
|
|
s3_key = f"{S3_PREFIX}/{rel_path}"
|
|
size_mb = local_path.stat().st_size / (1024 * 1024)
|
|
print(f"Uploading {rel_path} ({size_mb:.1f} MB) -> s3://{bucket}/{s3_key}")
|
|
try:
|
|
s3.upload_file(str(local_path), bucket, s3_key)
|
|
print(f" OK")
|
|
except Exception as e:
|
|
print(f" ERROR: {e}", file=sys.stderr)
|
|
errors = True
|
|
|
|
# Загрузка манифеста
|
|
if errors:
|
|
print("\nFinished with errors; production manifest was not changed.")
|
|
return 1
|
|
|
|
json_content_type = "application/json"
|
|
no_cache = "no-cache, no-store, must-revalidate"
|
|
|
|
print(
|
|
f"Uploading managed package inventory -> "
|
|
f"s3://{bucket}/{MANAGED_MANIFEST_S3_KEY}"
|
|
)
|
|
try:
|
|
s3.put_object(
|
|
Bucket=bucket,
|
|
Key=MANAGED_MANIFEST_S3_KEY,
|
|
Body=(json.dumps(staging_manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
|
|
ContentType=json_content_type,
|
|
CacheControl=no_cache,
|
|
)
|
|
print(" OK")
|
|
except Exception as e:
|
|
print(f" ERROR: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"Uploading manifest.json -> s3://{bucket}/{MANIFEST_S3_KEY}")
|
|
try:
|
|
s3.put_object(
|
|
Bucket=bucket,
|
|
Key=MANIFEST_S3_KEY,
|
|
Body=(json.dumps(merged_manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
|
|
ContentType=json_content_type,
|
|
CacheControl=no_cache,
|
|
)
|
|
print(f" OK")
|
|
except Exception as e:
|
|
print(f" ERROR: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
if stale_managed_paths:
|
|
print(f"Deleting {len(stale_managed_paths)} stale managed object(s)...")
|
|
for relative_path in stale_managed_paths:
|
|
s3_key = f"{S3_PREFIX}/{relative_path.replace(chr(92), '/')}"
|
|
try:
|
|
s3.delete_object(Bucket=bucket, Key=s3_key)
|
|
print(f" Deleted {s3_key}")
|
|
except Exception as e:
|
|
print(f" ERROR deleting {s3_key}: {e}", file=sys.stderr)
|
|
errors = True
|
|
|
|
if errors:
|
|
print("\nFinished with errors.")
|
|
return 1
|
|
|
|
print("\nAll uploads completed successfully.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|