#!/usr/bin/env python3 """ kv-generation-merge.py — 兩代 KV 櫃子的「逐把合併」工具(唯讀優先、絕不刪除、可回滾) 背景:arcrun-rag 安裝器對「帳號裡本來就有庫」的實例會另開一整套 KV (`arcrun-rag--kv-*`),把 worker 綁到新櫃子,舊櫃子原封不動但沒人讀。 本工具處理「決定用哪一代 + 把另一代獨有的東西搬過來 + 把 worker 綁定指到同一代」。 設計硬規則 1. **永遠不刪除任何 key、任何 namespace。**(沒有 delete 子指令,寫不出來) 2. **預設不覆蓋**已存在的 key;要覆蓋必須用 `--overwrite` 逐把點名。 3. **可重跑**:copy 是冪等的(值相同就跳過),中途死掉重跑即可,不會產生半套狀態。 4. **綁定先快照再改**:`bindings-snapshot` 產生的 JSON 就是 rollback 的唯一依據。 5. **不印任何值**:診斷只印 key 名、長度、sha256 前 12 碼。 用法(token 只用「環境變數名字」傳入,值不進指令列——D36) export CF_TOKEN_ENV=CLOUDFLARE_API_TOKEN_leo21c export CF_ACCOUNT=51a01bfa2665bd7bc3fd080dc40cf3e1 # 1) 盤點:兩代逐把 key 級比對(唯讀) python3 kv-generation-merge.py diff --old --new # 2) 搬 key(只搬點名的那幾把;不點名什麼都不做) python3 kv-generation-merge.py copy --from --to --key K1 --key K2 # 要覆蓋目標已存在的 key(例:把新版 notify_leo 蓋進舊櫃) python3 kv-generation-merge.py copy --from --to --key K --overwrite # 3) 綁定:先快照,再逐把重指,壞了用快照還原 python3 kv-generation-merge.py bindings-snapshot --script arcrun-cypher-executor -o snap.json python3 kv-generation-merge.py bindings-repoint --script arcrun-cypher-executor \ --set WEBHOOKS= --set RECIPES= ... python3 kv-generation-merge.py bindings-rollback --script arcrun-cypher-executor -i snap.json # 4) 驗收:綁定實況 + 每把櫃子的內容統計 python3 kv-generation-merge.py audit --script arcrun-cypher-executor --script arcrun-registry ... """ from __future__ import annotations import argparse import hashlib import json import os import sys import time import urllib.parse import urllib.request API = "https://api.cloudflare.com/client/v4" def _token() -> str: name = os.environ.get("CF_TOKEN_ENV") if not name: sys.exit("需要 CF_TOKEN_ENV=<存放 token 的環境變數名字>(只傳名字,不傳值)") val = os.environ.get(name) if not val: sys.exit(f"環境變數 {name} 是空的") return val def _account() -> str: acc = os.environ.get("CF_ACCOUNT") if not acc: sys.exit("需要 CF_ACCOUNT=(明示,絕不靠 CLOUDFLARE_ACCOUNT_ID 預設值)") return acc def req(method: str, path: str, body=None, raw=False, retries=3): url = f"{API}/accounts/{_account()}{path}" data = None headers = {"Authorization": f"Bearer {_token()}"} if body is not None: data = body if isinstance(body, bytes) else json.dumps(body).encode() headers["Content-Type"] = "application/json" last = None for attempt in range(retries): r = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(r, timeout=60) as resp: payload = resp.read() return payload if raw else json.loads(payload) except Exception as e: # noqa: BLE001 — 網路類錯誤一律重試 last = e body_txt = "" if hasattr(e, "read"): try: body_txt = e.read().decode()[:400] except Exception: pass if attempt == retries - 1: sys.exit(f"CF API {method} {path} 失敗:{e} {body_txt}") time.sleep(1.5 * (attempt + 1)) raise AssertionError(last) def list_keys(ns: str) -> list[dict]: out, cursor = [], "" while True: q = f"?limit=1000{'&cursor=' + urllib.parse.quote(cursor) if cursor else ''}" d = req("GET", f"/storage/kv/namespaces/{ns}/keys{q}") if not d.get("success"): sys.exit(f"列 key 失敗:{d.get('errors')}") out += d["result"] cursor = (d.get("result_info") or {}).get("cursor") or "" if not cursor: return out def get_value(ns: str, key: str) -> bytes | None: try: return req("GET", f"/storage/kv/namespaces/{ns}/values/{urllib.parse.quote(key, safe='')}", raw=True, retries=2) except SystemExit: return None def put_value(ns: str, key: str, value: bytes, expiration: int | None, metadata) -> None: # multipart/form-data:CF KV 的 value+metadata 只吃這一種 boundary = "----arcrunkvmerge" + hashlib.sha256(key.encode()).hexdigest()[:16] parts = [] def field(name, content: bytes, ctype=None): h = f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n' if ctype: h += f"Content-Type: {ctype}\r\n" parts.append(h.encode() + b"\r\n" + content + b"\r\n") field("value", value) field("metadata", json.dumps(metadata or {}).encode(), "application/json") parts.append(f"--{boundary}--\r\n".encode()) payload = b"".join(parts) q = f"?expiration={expiration}" if expiration else "" url = (f"{API}/accounts/{_account()}/storage/kv/namespaces/{ns}" f"/values/{urllib.parse.quote(key, safe='')}{q}") r = urllib.request.Request( url, data=payload, method="PUT", headers={"Authorization": f"Bearer {_token()}", "Content-Type": f"multipart/form-data; boundary={boundary}"}) with urllib.request.urlopen(r, timeout=60) as resp: d = json.loads(resp.read()) if not d.get("success"): sys.exit(f"寫入失敗 {key}:{d.get('errors')}") def sha(b: bytes | None) -> str: return "-" if b is None else hashlib.sha256(b).hexdigest()[:12] def patch_settings(script: str, settings: dict) -> dict: """PATCH /workers/scripts/{name}/settings —— 只改 metadata,不重傳程式碼。 ⚠️ 這支端點**只吃 multipart/form-data**(送 application/json 會回 415 / code 10001)。 這是 2026-08-10 在 stage 演練時實撞出來的,別改回 json。 """ boundary = "----arcrunsettings" + hashlib.sha256(script.encode()).hexdigest()[:16] payload = (f'--{boundary}\r\nContent-Disposition: form-data; name="settings"\r\n' f"Content-Type: application/json\r\n\r\n").encode() payload += json.dumps(settings).encode() + f"\r\n--{boundary}--\r\n".encode() url = f"{API}/accounts/{_account()}/workers/scripts/{script}/settings" r = urllib.request.Request( url, data=payload, method="PATCH", headers={"Authorization": f"Bearer {_token()}", "Content-Type": f"multipart/form-data; boundary={boundary}"}) try: with urllib.request.urlopen(r, timeout=60) as resp: return json.loads(resp.read()) except Exception as e: # noqa: BLE001 detail = e.read().decode()[:500] if hasattr(e, "read") else "" sys.exit(f"改綁定失敗:{e} {detail}") # ── 子指令 ──────────────────────────────────────────────────────────────────── def cmd_diff(a): o = {k["name"]: k for k in list_keys(a.old)} n = {k["name"]: k for k in list_keys(a.new)} both = sorted(set(o) & set(n)) print(f"舊 {len(o)} 把|新 {len(n)} 把|共有 {len(both)}|只在舊 {len(set(o)-set(n))}|只在新 {len(set(n)-set(o))}") print("\n[只在舊櫃子]") for k in sorted(set(o) - set(n)): print(" +old", k) print("\n[只在新櫃子]") for k in sorted(set(n) - set(o)): print(" +new", k) print("\n[兩代都有 → 逐把比對內容]") same = diff = 0 for k in both: vo, vn = get_value(a.old, k), get_value(a.new, k) if sha(vo) == sha(vn): same += 1 if a.verbose: print(f" = {k} {sha(vo)}") else: diff += 1 print(f" ≠ {k} old={sha(vo)}({len(vo or b'')}B) new={sha(vn)}({len(vn or b'')}B)") print(f"\n共有的 {len(both)} 把:內容相同 {same}、**內容不同 {diff}**(不同的那些才是會咬人的)") def cmd_copy(a): src_keys = {k["name"]: k for k in list_keys(getattr(a, "from"))} dst_keys = {k["name"]: k for k in list_keys(a.to)} todo, skip = [], [] for k in a.key: if k not in src_keys: sys.exit(f"來源櫃子沒有這把 key:{k}(先跑 diff 對一次名字)") if k in dst_keys: vs, vd = get_value(getattr(a, "from"), k), get_value(a.to, k) if sha(vs) == sha(vd): skip.append((k, "目標已有且內容相同")) continue if not a.overwrite: skip.append((k, "目標已有且內容不同 → 需 --overwrite 才動")) continue todo.append(k) for k, why in skip: print(f" 跳過 {k}:{why}") if a.dry_run: for k in todo: print(f" [dry-run] 會寫入 {k}") print(f"\ndry-run:會寫 {len(todo)} 把、跳過 {len(skip)} 把。加 --apply 才真的寫。") return for k in todo: meta = src_keys[k] v = get_value(getattr(a, "from"), k) if v is None: sys.exit(f"讀不到來源值:{k}(中止,已寫入的部分不受影響,重跑即可續)") put_value(a.to, k, v, meta.get("expiration"), meta.get("metadata")) print(f" 寫入 {k} {sha(v)} ({len(v)}B)") # 立即回讀複驗 bad = [] for k in todo: if sha(get_value(getattr(a, "from"), k)) != sha(get_value(a.to, k)): bad.append(k) print(f"\n寫入 {len(todo)} 把、跳過 {len(skip)} 把;回讀複驗不符 {len(bad)} 把 {bad}") if bad: sys.exit(1) def _script_settings(script: str): d = req("GET", f"/workers/scripts/{script}/settings") if not d.get("success"): sys.exit(f"讀 {script} settings 失敗:{d.get('errors')}") return d["result"] def cmd_bindings_snapshot(a): snap = {} for s in a.script: snap[s] = _script_settings(s) out = json.dumps(snap, ensure_ascii=False, indent=2) if a.out: open(a.out, "w", encoding="utf-8").write(out) print(f"快照已存 → {a.out}") else: print(out) for s, st in snap.items(): for b in st.get("bindings", []): if b.get("type") == "kv_namespace": print(f" {s:26} {b['name']:16} {b['namespace_id']}") def _sanitize(bindings: list[dict]) -> list[dict]: """把讀回來的 bindings 變成可以寫回去的形狀:secret 用 inherit(不碰真身,D36)。""" out = [] for b in bindings: t = b.get("type") if t in ("secret_text", "secret_key"): out.append({"type": "inherit", "name": b["name"]}) else: out.append({k: v for k, v in b.items() if v is not None}) return out def cmd_bindings_repoint(a): want = dict(s.split("=", 1) for s in a.set) st = _script_settings(a.script) bindings = st.get("bindings", []) names = {b["name"] for b in bindings if b.get("type") == "kv_namespace"} missing = set(want) - names if missing: sys.exit(f"{a.script} 上沒有這些 KV 綁定名:{sorted(missing)}") new_bindings, changes = [], [] for b in _sanitize(bindings): if b.get("type") == "kv_namespace" and b["name"] in want: old = b["namespace_id"] if old != want[b["name"]]: changes.append((b["name"], old, want[b["name"]])) b = {**b, "namespace_id": want[b["name"]]} new_bindings.append(b) for n, o, w in changes: print(f" {a.script}: {n} {o} → {w}") if not changes: print(" (沒有任何綁定需要變更——已經是目標狀態,冪等)") return if a.dry_run: print("\ndry-run:加 --apply 才真的改。") return keep = ("compatibility_date", "compatibility_flags", "logpush", "placement", "tail_consumers", "observability", "limits", "migrations") settings = {k: v for k, v in st.items() if k in keep and v is not None} settings["bindings"] = new_bindings d = patch_settings(a.script, settings) if not d.get("success"): sys.exit(f"改綁定失敗:{d.get('errors')}") after = {b["name"]: b["namespace_id"] for b in _script_settings(a.script).get("bindings", []) if b.get("type") == "kv_namespace"} bad = [n for n, v in want.items() if after.get(n) != v] print(f"\n改完複驗:{'✅ 全部到位' if not bad else '❌ 沒到位 ' + str(bad)}") if bad: sys.exit(1) def cmd_bindings_rollback(a): snap = json.load(open(a.inp, encoding="utf-8")) st = snap.get(a.script) if not st: sys.exit(f"快照裡沒有 {a.script}") want = {b["name"]: b["namespace_id"] for b in st["bindings"] if b.get("type") == "kv_namespace"} ns = argparse.Namespace(script=a.script, set=[f"{k}={v}" for k, v in want.items()], dry_run=a.dry_run) cmd_bindings_repoint(ns) def cmd_audit(a): ns_title = {} d = req("GET", "/storage/kv/namespaces?per_page=100") for n in d.get("result", []): ns_title[n["id"]] = n["title"] seen = {} for s in a.script: st = _script_settings(s) kv = [b for b in st.get("bindings", []) if b.get("type") == "kv_namespace"] if not kv: continue print(f"\n{s}") for b in kv: title = ns_title.get(b["namespace_id"], "?") gen = "新" if title.startswith("arcrun-rag-") else "舊" cnt = len(list_keys(b["namespace_id"])) print(f" {b['name']:16} {b['namespace_id']} [{gen}] {title:38} {cnt:>4} 把") seen.setdefault(b["name"], set()).add(b["namespace_id"]) print("\n── 分裂檢查(同一個綁定名被指到不同 namespace = 分裂)──") split = {k: v for k, v in seen.items() if len(v) > 1} print("✅ 沒有分裂:所有 worker 的同名綁定都指到同一顆" if not split else f"❌ 分裂:{split}") if split: sys.exit(1) def main(): p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = p.add_subparsers(dest="cmd", required=True) d = sub.add_parser("diff", help="兩代逐把 key 級比對(唯讀)") d.add_argument("--old", required=True) d.add_argument("--new", required=True) d.add_argument("--verbose", action="store_true") d.set_defaults(func=cmd_diff) c = sub.add_parser("copy", help="把點名的 key 從一個 namespace 搬到另一個(冪等、預設不覆蓋、永不刪除)") c.add_argument("--from", required=True, dest="from") c.add_argument("--to", required=True) c.add_argument("--key", action="append", required=True) c.add_argument("--overwrite", action="store_true", help="目標已有且內容不同時才需要,逐把點名") c.add_argument("--apply", dest="dry_run", action="store_false", default=True) c.set_defaults(func=cmd_copy) s = sub.add_parser("bindings-snapshot", help="把 worker 現況綁定存成 JSON(rollback 的唯一依據)") s.add_argument("--script", action="append", required=True) s.add_argument("-o", "--out") s.set_defaults(func=cmd_bindings_snapshot) r = sub.add_parser("bindings-repoint", help="把某個 worker 的 KV 綁定改指到別顆 namespace(不動程式碼、不動 secret)") r.add_argument("--script", required=True) r.add_argument("--set", action="append", required=True, help="BINDING_NAME=namespace_id") r.add_argument("--apply", dest="dry_run", action="store_false", default=True) r.set_defaults(func=cmd_bindings_repoint) b = sub.add_parser("bindings-rollback", help="用快照把綁定還原") b.add_argument("--script", required=True) b.add_argument("-i", "--inp", required=True) b.add_argument("--apply", dest="dry_run", action="store_false", default=True) b.set_defaults(func=cmd_bindings_rollback) a = sub.add_parser("audit", help="列出每顆 worker 綁到哪一代、各幾把 key,並檢查有沒有分裂") a.add_argument("--script", action="append", required=True) a.set_defaults(func=cmd_audit) args = p.parse_args() args.func(args) if __name__ == "__main__": main()