init
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
#!/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"
|
||||
|
||||
|
||||
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 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)",
|
||||
)
|
||||
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)
|
||||
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("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)
|
||||
replace_tree(src_sync, sync_dir)
|
||||
print("created web/ + sync/")
|
||||
|
||||
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,
|
||||
"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())
|
||||
Reference in New Issue
Block a user