diff --git a/scripts/teardown-instance.mjs b/scripts/teardown-instance.mjs index f3754bd..3808f5e 100644 --- a/scripts/teardown-instance.mjs +++ b/scripts/teardown-instance.mjs @@ -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 --api-token [--keep w1,w2] * node scripts/teardown-instance.mjs apply --account-id --api-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::`。少了這道, + // `central-apply --account-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 = {};