#!/usr/bin/env python3 """Build server/public campaign trees: new channel + fixed domains. Seeds are derived deterministically from the domain lists (not user-facing). Same domains → same sync/ and same secondary domain patch; only channel differs. Each run: - creates web// - rebuilds sync/ from source + fixed-domain patch - leaves other web// dirs intact """ 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" APPLY_ROOT = LAB_ROOT / "server" / "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 seeds_from_domains(dep: list[str], rep: list[str]) -> tuple[str, str]: """32-hex seeds stable for a given ordered domain list (pool slots still need seeds).""" 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 main() -> int: parser = argparse.ArgumentParser( description=( "From source/, create server/public/web// with fixed " "Deployment/Reporting domains. Seeds are derived from domains " "(no --*-seed / no reuse-sync shortcut)." ) ) parser.add_argument( "--channel-id", help="optional 32-hex channel id (default: random, unique under web/)", ) parser.add_argument( "--deployment-domains", action="append", default=[], required=True, help="fixed Deployment hosts (comma-separated or repeatable)", ) parser.add_argument( "--reporting-domains", action="append", default=[], required=True, help="fixed Reporting hosts (comma-separated or repeatable)", ) args = parser.parse_args() fixed_dep = parse_domain_list(args.deployment_domains, label="deployment") fixed_rep = parse_domain_list(args.reporting_domains, label="reporting") dep_seed, rep_seed = seeds_from_domains(fixed_dep, fixed_rep) 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) 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" deployment: {', '.join(fixed_dep)}") print(f" reporting: {', '.join(fixed_rep)}") print(f" seeds: derived from domains (stable)") print() print("=== copy campaign template ===") print(f"from: {src_campaign}") print(f"to: {campaign_dir}") copy_campaign_template(campaign_dir) print(f"created web/{channel}/") print("=== reset sync from source ===") replace_tree(src_sync, sync_dir) print(f"copied sync/ -> {sync_dir}") domain_args: list[str] = [] for item in fixed_dep: domain_args += ["--deployment-domains", item] for item in fixed_rep: domain_args += ["--reporting-domains", item] print() print("=== patch_all --apply ===") run( [ sys.executable, str(TOOLS / "patch_all.py"), "--apply", "--root", str(APPLY_ROOT), "--channel-id", channel, "--deployment-seed", dep_seed, "--reporting-seed", rep_seed, *domain_args, ] ) (out_root / "channel.json").write_text( json.dumps( { "channel_id": channel, "deployment_domains": fixed_dep, "reporting_domains": fixed_rep, }, 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() print("served by Laravel public:") print(f" /web/{channel}/support.html") print(" /sync/daily.html") return 0 if __name__ == "__main__": raise SystemExit(main())