fix(teardown): 中心側補上帳號白名單——原本 central-* 碰得到 leo21c

實測破口(2026-08-14,修補前):
  central-plan --account-id 51a01bfa…(leo21c)
  → 一路暢通,列出 deployed:51a01bfa…:leo21c
  加 --yes 就會刪掉 leo 的安裝紀錄 ⇒ 他下次重裝撞 Arcrun#120 死結。

根因是兩個問題被當成同一個:
  ① 動的是「誰的」紀錄 → assertAccountAllowed(帳號側有,中心側沒有)
  ② 以「誰的身分」動手   → assertWranglerIsCentralAccount(D88 補的那道)
c543aba 的註解寫「這個不對稱現在補齊」,但只補了②。註解說補齊、程式補一半,
後來讀的人會相信註解——所以順手把那段註解改成實測講法。

另修 listAllScripts 沒翻頁(ops-facts 記過的同款坑)。在這支工具裡漏看一顆
worker 有兩層傷害:拆不乾淨,以及「共用資源保護」看不到那個 owner,
把還在用的資源判成沒人用而刪掉。

並補一條已知限制:資源清單是從 worker binding 反推、不掃帳號,
所以沒人綁的孤兒殘骸不會被列也不會被刪(這正是 drill-a/drill-b 不受影響的原因,
但代價是拆完重裝可能撞 Arcrun#123,要自己再列一次帳號)。

驗證(youlin,全唯讀,未執行 apply):
- plan:6 顆 worker + 10 個資源(8 顆 yuga3bse KV/yuga3bse-db/embed-m3),
  drill-a、drill-b 不在清單
- leo21c 帳號側、中心側 兩條路徑皆 exit 1 拒絕
- youlin 中心側仍正常:staging 通道有 deployed:1129efd7…:youlin-hsieh-dev 一筆

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-14 19:51:31 +08:00
parent c543abaa8a
commit 5d441aa7d3
+44 -5
View File
@@ -58,6 +58,13 @@
* (是 → 一併排入拆除;不是 → 照紅線問人再決定要不要動它),**拆完後如果選了半殘留,
* 要有心理準備下一步的 fail-closed 檢查會擋下重裝**。
*
* - 🔴 **只拆「有人綁著的」,看不見孤兒資源**:資源清單是從「要刪的 worker 的 binding」反推的,
* 不是掃帳號。所以**沒有任何 worker 綁著的殘骸**(例:上一次安裝到一半死掉留下的 KV/D1)
* 本工具**不會列、也不會刪**。這個取捨是刻意的——它同時是「不誤刪」的來源
* 2026-08-14 youlin 上的 `arcrun-rag-drill-a``drill-b` 兩顆演練用 D1 因此原封不動)——
* 但代價是:拆完之後帳號上可能仍留著同名殘骸,重裝時撞 Arcrun#123(同名資源已存在)。
* ⇒ 拆完要重裝之前,**自己再列一次帳號**確認沒有同租戶前綴的孤兒。
*
* 用法:
* node scripts/teardown-instance.mjs plan --account-id <id> --api-token <token> [--keep w1,w2]
* node scripts/teardown-instance.mjs apply --account-id <id> --api-token <token> [--keep w1,w2] --yes
@@ -161,11 +168,25 @@ function scanKnownWorkerNames() {
// ── 二、帳號側 discovery ─────────────────────────────────────────────────────
/** 列出帳號上所有 worker script 名字。 */
/**
* 列出帳號上所有 worker script 名字。
*
* 🔴 一定要翻頁。漏看一顆 worker 在這支工具裡是**兩層**傷害:
* ① 那顆是實例的一部分卻沒被刪 ⇒ 拆不乾淨;
* ② 更糟——那顆若綁著某顆 KV/D1,「共用資源保護」就看不到這個 owner,
* 於是把還在用的資源判成「只有要刪的 worker 綁著」而**刪掉別人在用的東西**。
* (同款坑 ops-facts 記過:`cf-resource-api.mjs` 寫死 per_page=100 又不翻頁。)
*/
async function listAllScripts(api) {
const { ok, result, error } = await api.cfRaw('/workers/scripts?per_page=100');
if (!ok) throw new Error(`列 worker scripts 失敗:${error}`);
return /** @type {Array<{id:string}>} */ (result).map((s) => s.id);
const names = [];
for (let page = 1; ; page++) {
const { ok, result, error } = await api.cfRaw(`/workers/scripts?per_page=100&page=${page}`);
if (!ok) throw new Error(`列 worker scripts 失敗(page ${page}):${error}`);
const batch = /** @type {Array<{id:string}>} */ (result) ?? [];
for (const s of batch) names.push(s.id);
if (batch.length < 100) break;
}
return names;
}
/**
@@ -313,7 +334,15 @@ async function applyTeardown(plan, { accountId, apiToken }) {
// 靠操作者自己每次手動 `wrangler whoami` 核對——那個核對動作是對的,只是長錯地方
// (長在人身上)。失敗模式是最壞的一種:wrangler 的 OAuth 登入態如果哪天換成別的帳號,
// 會**靜默**對錯的帳號動中心 KV,沒有任何東西會叫。帳號側(youlin)有 ALLOWED_ACCOUNT_IDS
// 白名單擋,中心側原本完全沒有——這個不對稱本身就是設計缺口,現在補齊
// 白名單擋,中心側原本完全沒有——這個不對稱本身就是設計缺口。
//
// ⚠️ 2026-08-14 稍晚訂正:上面那句原本寫「現在補齊」,**但當時只補了一半**——
// 補的是「以誰的身分動手」(wrangler 身分),沒補「動的是誰的紀錄」(accountId 白名單)。
// 實測證明破口是真的:`central-plan --account-id 51a01bfa…`leo21c)一路暢通,
// 直接列出 `deployed:51a01bfa…:leo21c`,再加 `--yes` 就會刪掉 leo 的安裝紀錄。
// ⇒ 現在 planCentralCleanup() 兩道都跑:assertAccountAllowed assertWranglerIsCentralAccount。
// 📌 教訓:**「補齊了」這句話要用實測講,不要用註解講**——註解說補齊,程式只補一半,
// 而後來讀的人(包括我)會相信註解。
// assertWranglerIsCentralAccount() 在任何中心 KV 讀寫前跑,核對 wrangler 現在的 OAuth
// 登入態的帳號清單裡有沒有 CENTRAL_ACCOUNT_ID,沒有就直接丟錯、不放行。
@@ -384,6 +413,16 @@ function wranglerKvDelete(namespaceId, key) {
}
function planCentralCleanup(accountId) {
// 🔴 兩道,缺一不可,而且問的是**不同的問題**:
// ① assertAccountAllowed(accountId):「我要清掉的是**誰的**紀錄」——
// 中心 KV 裡每個帳號一筆,`deployed:<accountId>:`。少了這道,
// `central-apply --account-id <leo21c 的 id>` 會清掉 leo21c 的安裝紀錄,
// leo 下次重裝就會撞上 Arcrun#120 的死結。**帳號側擋得住 leo21c,中心側原本擋不住。**
// 2026-08-14 實測:`central-plan --account-id 51a01bfa…` 直接列出了
// `deployed:51a01bfa…:leo21c`,一路暢通。)
// ② assertWranglerIsCentralAccount():「我現在**以誰的身分**在動中心 KV」(D88 那道)。
// ①問對象、②問身分。原本只有②,於是「白名單」只保護了帳號側的一半路徑。
assertAccountAllowed(accountId);
assertWranglerIsCentralAccount();
const prefix = `deployed:${accountId}:`;
const plan = {};