"""Seal per-node NODE_TOKENS GitHub Actions secrets from the merged pool. Deterministic even slices: node i (index in fleet_deploy ok-list) gets pool[(i*S) % len : ...] wrapping, so with S=25 over 1391 nodes every one of the 2446 alive tokens is used ~14 times — "юзал все гх токены" without committing any of them to git (Secrets API + libsodium seal). imba.yml writes NODE_TOKENS *appended after* the node's own committed PAT (line 1 of gh_tokens.txt stays the push credential). usage: python fleet_seal_tokens.py [--limit N] [--node NAME] [--slice 25] [--apply] Never prints token values. """ import argparse import base64 import json import sys import time from pathlib import Path import requests from nacl import encoding from nacl.public import PublicKey, SealedBox ROOT = Path(__file__).parent DEPLOY = ROOT / "data" / "fleet_deploy.json" POOL_FILE = ROOT / "gh_tokens.txt" H = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} S = requests.Session() def load_pool(): toks = [] for l in open(POOL_FILE, encoding="utf-8"): t = l.strip() if t.startswith(("ghp_", "github_pat_")) and t not in toks: toks.append(t) if not toks: sys.exit("REFUSING: pool is empty — nothing to seal") return toks def slice_for(i, size, pool): n = len(pool) return [pool[(i * size + k) % n] for k in range(min(size, n))] def seal_node(rec, idx, size, pool, apply): full = f"{rec['login']}/{rec['repo']}" tok = rec.get("pat") if not tok: return full, False, "no node pat" hdr = {**H, "Authorization": f"Bearer {tok}"} try: r = S.get(f"https://api.github.com/repos/{full}/actions/secrets/public-key", headers=hdr, timeout=30) if r.status_code != 200: return full, False, f"pubkey {r.status_code}" pj = r.json() sl = slice_for(idx, size, pool) if not apply: return full, True, f"would seal {len(sl)} tokens (key_id={pj['key_id'][:8]}…)" box = SealedBox(PublicKey(pj["key"], encoding.Base64Encoder)) sealed = base64.b64encode(box.encrypt("\n".join(sl).encode())).decode() r = S.put(f"https://api.github.com/repos/{full}/actions/secrets/NODE_TOKENS", headers=hdr, timeout=30, json={"encrypted_value": sealed, "key_id": pj["key_id"]}) if r.status_code in (201, 204): return full, True, f"sealed {len(sl)} tokens" return full, False, f"put {r.status_code}: {r.text[:80]}" except Exception as e: return full, False, f"{type(e).__name__}: {str(e)[:60]}" def main(): ap = argparse.ArgumentParser() ap.add_argument("--limit", type=int, default=0) ap.add_argument("--node", default="") ap.add_argument("--slice", type=int, default=25) ap.add_argument("--apply", action="store_true") a = ap.parse_args() pool = load_pool() recs = [r for r in json.load(open(DEPLOY, encoding="utf-8")) if r.get("ok")] print(f"pool={len(pool)} nodes={len(recs)} slice={a.slice} apply={a.apply}") targets = list(enumerate(recs)) if a.node: targets = [(i, r) for i, r in targets if r.get("repo") == a.node or f"{r['login']}/{r['repo']}" == a.node] elif a.limit: step = max(1, len(recs) // a.limit) # uniform sample, not one owner targets = targets[::step][:a.limit] if not targets: sys.exit("no nodes selected") ok = fails = 0 t0 = time.time() for i, rec in targets: full, good, info = seal_node(rec, i, a.slice, pool, a.apply) ok += good fails += (not good) print(f" {'ok ' if good else 'FAIL'} {full}: {info}", flush=True) cov = min(len(pool), a.slice * len(targets)) print(f"\nDONE {time.time()-t0:.0f}s ok={ok} fail={fails} " f"pool coverage this batch: ~{cov}/{len(pool)} tokens") if __name__ == "__main__": main()