From 7498911856ca7b39e3c3450862f0337b67f052fb Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 14 Aug 2026 13:25:39 +0800 Subject: [PATCH 1/5] =?UTF-8?q?WIP:=20=E5=85=A7=E9=83=A8=20uninstaller=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=EF=BC=88=E6=8B=86=E5=B8=B3=E8=99=9F=E5=81=B4?= =?UTF-8?q?=20+=20=E4=B8=AD=E5=BF=83=20KV=EF=BC=89=EF=BC=8C=E5=B0=9A?= =?UTF-8?q?=E6=9C=AA=E8=B7=91=E9=81=8E=20apply=EF=BC=88Arcrun#119/#120/#12?= =?UTF-8?q?2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 交辦:先做出「拆乾淨→重裝」的內部工具,在 youlin 帳號上自己能用即可, 不是產品(對外版另計)。D82 三步驗收(全新安裝→更新→解除安裝)需要拆的能力才測得到, 沒有它就永遠測不到 #119/#120 這類「只有全新用戶才撞得到」的坑。 目前做到: - scripts/teardown-instance.mjs: - plan/apply 兩階段(預設唯讀只印清單,真要刪要 apply --yes) - 帳號白名單(只認 youlin,明確擋 leo21c) - 歸屬判準:worker 名字要出現在本 repo wrangler*.toml 或官方 bundle manifest.core[] 才算「已知」,不認得的一律列出不動(例如 kbdb-graph-plugin,不屬於這台實例) - 共用資源保護:同一顆 KV/D1/Vectorize 若也被「保留中的 worker」綁著,排除不刪 - central-plan/central-apply:清中心 INSTALLER_KV/PEER_INSTALLER_KV 的 deployed:: 紀錄(#120 死結的另一半,帳號側清了但這裡沒清會卡死重裝) - 已用 `plan` 在 youlin 帳號實跑過(唯讀),清單看起來合理,貼在 issue/回報裡 還沒做: - apply(真的刪)、central-apply、以及刪完後的重裝驗證,全部還沒跑 - 已知限制列在檔頭註解:判準是「repo 認不認得」不是「屬於哪一台實例」, 同帳號多台同名慣例實例分不出來(今天 youlin 上短暫同時有正式實例與 ship-stage-119 的 freshtest 測試實例,此時就是靠人工 --keep 排除) Co-Authored-By: Claude Opus 5 --- scripts/teardown-instance.mjs | 426 ++++++++++++++++++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 scripts/teardown-instance.mjs diff --git a/scripts/teardown-instance.mjs b/scripts/teardown-instance.mjs new file mode 100644 index 0000000..26c527f --- /dev/null +++ b/scripts/teardown-instance.mjs @@ -0,0 +1,426 @@ +#!/usr/bin/env node +// @ts-check +/** + * teardown-instance.mjs — 把一個 Cloudflare 帳號上的 Arcrun self-hosted 安裝拆乾淨。 + * + * 🔴 這是內部工具,不是產品(leo 2026-08-14:「簡單先做出 uninstaller⋯先產出你自己可用的 + * 版本,在 youlin 使用,對外的後面提供」)。目的:讓「拆掉重裝」變成隨時做得到的事, + * 配套 D82 三步驗收(Arcrun#119/#120)——沒有拆的能力,「重裝從乾淨狀態開始」就永遠測不到。 + * + * 背景(Arcrun#120):安裝器判斷 init/update 看的是**中心 KV** 的 `deployed::`, + * 不是「帳號上現在有什麼」。只清帳號上的 worker/KV/D1/Vectorize、漏清中心那筆 ⇒ 重裝被判定 + * 「已經裝過」卡死。本工具因此分兩段:① 帳號側(Cloudflare 資源)② 中心側(安裝器的 + * INSTALLER_KV/PEER_INSTALLER_KV `deployed:` 紀錄)——兩段都做才算「不再殘留」。 + * + * 安全設計(這是刪除工具,寫錯會毀掉別人的東西): + * 1. 帳號白名單——只認 ALLOWED_ACCOUNT_IDS 裡的帳號,其餘一律拒絕執行。 + * 2. 兩階段——預設 `plan`(唯讀,只印清單);要真的刪必須 `apply --yes`。 + * 3. 歸屬判準——「這個帳號上的 worker 是不是這台 Arcrun 實例的」不是猜名字前綴,是 + * 「這個 worker 名字有沒有出現在本 repo(matrix/arcrun)任何 wrangler*.toml 的 + * `name = "..."`」。不認得的 worker 一律「不動、列出來讓人看」,不會被當成「安全,可刪」。 + * 4. 共用資源保護——同一顆 KV/D1/Vectorize 若同時被「要刪的 worker」與「沒被列入要刪的 + * worker」(不論是不認得的、還是被 --keep 排除的)綁著,一律排除、不刪、印警告。 + * 這是為了 youlin 帳號上可能同時存在多台實例(例:一次性測試用的 + * arcrun-cypher-executor-freshtest)而設——見下「已知限制」。 + * + * 已知限制(誠實列,不要假裝這個判準萬能): + * - 判準是「repo 認不認得這個 worker 名字」,不是「這個 worker 屬於哪一台實例」。同帳號上 + * 兩台同名慣例的實例(理論上不該發生,因為 worker 名字是全帳號唯一)用這個判準分不出來; + * 能分的是「已知元件 vs 陌生 worker」以及「資源有沒有被別的 worker 共用」。 + * - `--keep ` 需要呼叫者自己知道帳號上有哪些 worker 該排除(本工具不會自動偵測 + * 「這個 worker 是不是正在被別人使用」——那是 main/coordinator 層級的協調,不是機器能判斷的)。 + * - 中心 KV 清理走 `wrangler kv key`,用的是本機既有的 wrangler OAuth 登入態(不是 CF API + * token),因為 INSTALLER_KV 住在另一個帳號(uncle6,官方安裝器所在),本工具沒有那把帳號 + * 的 API token。呼叫前務必 `npx wrangler whoami` 確認登入態是 uncle6,不是別的帳號。 + * + * 用法: + * 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 + * node scripts/teardown-instance.mjs central-plan --account-id + * node scripts/teardown-instance.mjs central-apply --account-id --yes + */ + +import { createCloudflareResourceApi } from '../shared/resource-rule/cf-resource-api.mjs'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); + +// ── 紅線:帳號白名單(Cloudflare 帳號側)──────────────────────────────────────── +// 只准對這裡列出的帳號動手。要加新帳號=人類決定,不是機器自己判斷。 +const ALLOWED_ACCOUNT_IDS = new Map([ + ['1129efd7df2e8899d537e9c8fbabb6cb', 'youlin(stage,可自由拆裝)'], +]); +// 明確擋掉的帳號(即使有人想放進 ALLOWED 也留這份對照,錯誤訊息才講得清楚是撞了哪條紅線)。 +const FORBIDDEN_ACCOUNT_IDS = new Map([ + ['51a01bfa2665bd7bc3fd080dc40cf3e1', 'leo21c —— leo 正在上面工作,任何情況都不准動'], +]); + +function assertAccountAllowed(accountId) { + if (FORBIDDEN_ACCOUNT_IDS.has(accountId)) { + throw new Error( + `拒絕執行:帳號 ${accountId} 是明確禁止清單(${FORBIDDEN_ACCOUNT_IDS.get(accountId)})。` + ); + } + if (!ALLOWED_ACCOUNT_IDS.has(accountId)) { + throw new Error( + `拒絕執行:帳號 ${accountId} 不在白名單(${[...ALLOWED_ACCOUNT_IDS.keys()].join(', ')})。` + + ` 這是刪除工具,新帳號要人類明確加進 ALLOWED_ACCOUNT_IDS 才能用,不接受呼叫端臨時指定。` + ); + } +} + +// ── 一、掃描本 repo,建立「已知 Arcrun worker 名字」清單 ──────────────────────── +// +// 只掃 matrix/arcrun(本 repo)看不到 `arcrun-rag-ui`——那顆是 arcrun-rag 產品的前端, +// 由 products/arcrun-rag 的安裝器一起裝進同一個帳號(manifest.core 裡有它),但它的 +// wrangler 設定不住在本 repo。單靠本 repo 掃描會把它誤判成「不認得,不動」,導致 +// 「拆乾淨」漏掉一顆——因此額外把安裝器實際使用的 bundle manifest.json(manifest.core[].name) +// 併入已知清單。manifest.json 是公開端點、本工具的目的正是「重裝後長出一樣的東西」, +// 用它當第二個真相源與目的一致。抓不到(離線/端點掛了)就退回只用 repo 掃描,印警告, +// 不讓網路失敗擋住整個工具。 + +const DEFAULT_MANIFEST_URL = + 'https://cdn.jsdelivr.net/gh/youlinhsieh/arcrun-rag-bundles@20d4ad6900f51e02242815402a70890fc5b12712/manifest.json'; + +/** @returns {Promise} */ +async function fetchManifestCoreNames(manifestUrl = DEFAULT_MANIFEST_URL) { + try { + const res = await fetch(manifestUrl); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const manifest = await res.json(); + return (manifest.core ?? []).map((c) => c.name).filter(Boolean); + } catch (e) { + console.warn(`⚠️ 抓 bundle manifest 失敗(${e instanceof Error ? e.message : e}),只用本 repo 掃描的清單。`); + return []; + } +} + +/** @returns {Set} */ +function scanKnownWorkerNames() { + const names = new Set(); + const nameRe = /^\s*name\s*=\s*"([^"]+)"/gm; + + /** @param {string} dir */ + function walk(dir) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.wrangler') continue; + walk(full); + } else if (entry.isFile() && /^wrangler(\..+)?\.toml$/.test(entry.name)) { + let content; + try { + content = readFileSync(full, 'utf8'); + } catch { + continue; + } + for (const m of content.matchAll(nameRe)) { + names.add(m[1]); + } + } + } + } + walk(REPO_ROOT); + return names; +} + +// ── 二、帳號側 discovery ───────────────────────────────────────────────────── + +/** 列出帳號上所有 worker script 名字。 */ +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); +} + +/** + * 對帳號上每一顆 worker 查它的 binding。 + * 回傳 workerBindings(worker 名字對應到 binding 陣列) + * 與 resourceOwners(資源 key 對應到綁著它的 worker 名字集合)。 + */ +async function discoverAccount(api, allScripts) { + const workerBindings = new Map(); + const resourceOwners = new Map(); + + for (const name of allScripts) { + const sb = await api.getScriptBindings(name); + const bindings = sb.deployed ? sb.bindings : []; + workerBindings.set(name, bindings); + for (const b of bindings) { + const key = `${b.kind}:${b.value}`; + if (!resourceOwners.has(key)) resourceOwners.set(key, new Set()); + resourceOwners.get(key).add(name); + } + } + return { workerBindings, resourceOwners }; +} + +/** + * 算出拆除計畫。 + * @param {{accountId:string, apiToken:string, keep:string[]}} opts + */ +async function planTeardown({ accountId, apiToken, keep }) { + assertAccountAllowed(accountId); + const api = createCloudflareResourceApi({ accountId, apiToken }); + const knownNames = scanKnownWorkerNames(); + for (const n of await fetchManifestCoreNames()) knownNames.add(n); + const allScripts = await listAllScripts(api); + const { workerBindings, resourceOwners } = await discoverAccount(api, allScripts); + + const keepSet = new Set(keep); + const knownOnAccount = allScripts.filter((n) => knownNames.has(n)); + const unknownOnAccount = allScripts.filter((n) => !knownNames.has(n)); + const workersToDelete = knownOnAccount.filter((n) => !keepSet.has(n)); + const workersKeptExplicit = knownOnAccount.filter((n) => keepSet.has(n)); + const toDeleteSet = new Set(workersToDelete); + + const resourcesToDelete = []; + const resourcesExcludedShared = []; + const seenResourceKeys = new Set(); + for (const name of workersToDelete) { + for (const b of workerBindings.get(name) ?? []) { + const key = `${b.kind}:${b.value}`; + if (seenResourceKeys.has(key)) continue; + seenResourceKeys.add(key); + const owners = [...resourceOwners.get(key)]; + const sharedWithSurvivor = owners.some((o) => !toDeleteSet.has(o)); + if (sharedWithSurvivor) { + resourcesExcludedShared.push({ ...b, key, owners }); + } else { + resourcesToDelete.push({ ...b, key, owners }); + } + } + } + + return { + accountId, + accountLabel: ALLOWED_ACCOUNT_IDS.get(accountId), + knownNames, + allScripts, + unknownOnAccount, + workersToDelete, + workersKeptExplicit, + resourcesToDelete, + resourcesExcludedShared, + }; +} + +function printPlan(plan) { + console.log(`\n=== 拆除計畫:${plan.accountId}(${plan.accountLabel})===\n`); + console.log(`帳號上共 ${plan.allScripts.length} 顆 worker。`); + console.log(`\n【會被刪除的 worker】(${plan.workersToDelete.length} 顆——名字出現在本 repo wrangler*.toml,且沒被 --keep 排除)`); + for (const n of plan.workersToDelete) console.log(` - ${n}`); + + if (plan.workersKeptExplicit.length) { + console.log(`\n【明確排除、保留的已知 worker】(${plan.workersKeptExplicit.length} 顆——用 --keep 指定)`); + for (const n of plan.workersKeptExplicit) console.log(` - ${n}`); + } + + if (plan.unknownOnAccount.length) { + console.log(`\n【不認得的 worker,不動】(${plan.unknownOnAccount.length} 顆——名字沒出現在本 repo 任何 wrangler*.toml)`); + for (const n of plan.unknownOnAccount) console.log(` - ${n}`); + } + + console.log(`\n【會被刪除的資源】(${plan.resourcesToDelete.length} 個——只被「要刪的 worker」綁著)`); + for (const r of plan.resourcesToDelete) { + console.log(` - [${r.kind}] ${r.value}(binding ${r.binding},owner: ${r.owners.join(', ')})`); + } + + if (plan.resourcesExcludedShared.length) { + console.log(`\n⚠️ 【排除、不刪的共用資源】(${plan.resourcesExcludedShared.length} 個——同時被保留中的 worker 綁著)`); + for (const r of plan.resourcesExcludedShared) { + console.log(` - [${r.kind}] ${r.value}(binding ${r.binding},owner: ${r.owners.join(', ')})`); + } + } + console.log(''); +} + +async function applyTeardown(plan, { accountId, apiToken }) { + assertAccountAllowed(accountId); + const api = createCloudflareResourceApi({ accountId, apiToken }); + const results = { workers: [], resources: [] }; + + for (const name of plan.workersToDelete) { + const { ok, status, error } = await api.cfRaw(`/workers/scripts/${encodeURIComponent(name)}`, { + method: 'DELETE', + }); + results.workers.push({ name, ok, status, error }); + console.log(ok ? ` ok 刪除 worker ${name}` : ` FAIL 刪除 worker ${name}(${status}):${error}`); + } + + for (const r of plan.resourcesToDelete) { + let res; + if (r.kind === 'kv_namespace') { + res = await api.cfRaw(`/storage/kv/namespaces/${r.value}`, { method: 'DELETE' }); + } else if (r.kind === 'd1') { + res = await api.cfRaw(`/d1/database/${r.value}`, { method: 'DELETE' }); + } else if (r.kind === 'vectorize') { + res = await api.cfRaw(`/vectorize/v2/indexes/${encodeURIComponent(r.value)}`, { method: 'DELETE' }); + } else { + res = { ok: false, status: 0, error: `未知資源類型 ${r.kind}` }; + } + results.resources.push({ ...r, ok: res.ok, status: res.status, error: res.error }); + console.log( + res.ok + ? ` ok 刪除 ${r.kind} ${r.value}` + : ` FAIL 刪除 ${r.kind} ${r.value}(${res.status}):${res.error}` + ); + } + + return results; +} + +// ── 三、中心 KV(安裝器 deployed 紀錄)清理 ──────────────────────────────────── +// 這兩顆 KV 住在 uncle6 帳號(官方安裝器 arcrun-installer 所在),不是 youlin 的帳號。 +// 沒有這顆帳號的 CF API token,走本機已登入的 wrangler OAuth(`npx wrangler whoami` 需顯示 +// uncle6.me@gmail.com's Account,58309bb90fd93ad6d0fe0aae99170e9d)。 + +const CENTRAL_KV = { + prod: { id: '2ff9a845e7f646dab7b1e3dd6707b77f', label: 'INSTALLER_KV(prod 通道)' }, + staging: { id: '750c23faf0794d3db317c32f596df9a9', label: 'PEER_INSTALLER_KV(staging 通道,t157 雙通道問題)' }, +}; +const CENTRAL_ACCOUNT_ID = '58309bb90fd93ad6d0fe0aae99170e9d'; // uncle6 + +function wranglerKvList(namespaceId, prefix) { + const out = execFileSync( + 'npx', + ['wrangler', 'kv', 'key', 'list', '--namespace-id', namespaceId, '--prefix', prefix, '--remote'], + { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ); + const jsonStart = out.indexOf('['); + const jsonText = jsonStart >= 0 ? out.slice(jsonStart) : out; + try { + return JSON.parse(jsonText); + } catch { + throw new Error(`解析 wrangler kv key list 輸出失敗:${out.slice(0, 500)}`); + } +} + +function wranglerKvDelete(namespaceId, key) { + execFileSync( + 'npx', + ['wrangler', 'kv', 'key', 'delete', '--namespace-id', namespaceId, key, '--remote', '--force'], + { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ); +} + +function planCentralCleanup(accountId) { + const prefix = `deployed:${accountId}:`; + const plan = {}; + for (const [chan, kv] of Object.entries(CENTRAL_KV)) { + const keys = wranglerKvList(kv.id, prefix).map((k) => k.name); + plan[chan] = { ...kv, prefix, keys }; + } + return plan; +} + +function printCentralPlan(plan) { + console.log(`\n=== 中心 KV 拆除計畫(帳號 ${CENTRAL_ACCOUNT_ID}, uncle6)===\n`); + for (const info of Object.values(plan)) { + console.log(`【${info.label}】前綴 ${info.prefix}`); + if (!info.keys.length) { + console.log(' (沒有殘留紀錄)'); + } else { + for (const k of info.keys) console.log(` - ${k}`); + } + } + console.log(''); +} + +function applyCentralCleanup(plan) { + const results = {}; + for (const [chan, info] of Object.entries(plan)) { + results[chan] = []; + for (const k of info.keys) { + try { + wranglerKvDelete(info.id, k); + console.log(` ok 刪除 ${info.label} 的 ${k}`); + results[chan].push({ key: k, ok: true }); + } catch (e) { + console.log(` FAIL 刪除 ${info.label} 的 ${k}:${e instanceof Error ? e.message : e}`); + results[chan].push({ key: k, ok: false, error: String(e) }); + } + } + } + return results; +} + +// ── 四、CLI ──────────────────────────────────────────────────────────────── + +function parseArgs(argv) { + const out = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--yes') out.yes = true; + else if (a === '--account-id') out.accountId = argv[++i]; + else if (a === '--api-token') out.apiToken = argv[++i]; + else if (a === '--keep') out.keep = argv[++i].split(',').map((s) => s.trim()).filter(Boolean); + else out._.push(a); + } + return out; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const cmd = args._[0]; + + if (cmd === 'plan' || cmd === 'apply') { + if (!args.accountId || !args.apiToken) { + console.error('需要 --account-id 與 --api-token'); + process.exit(1); + } + const plan = await planTeardown({ accountId: args.accountId, apiToken: args.apiToken, keep: args.keep ?? [] }); + printPlan(plan); + if (cmd === 'plan') return; + if (!args.yes) { + console.error('apply 需要加 --yes(先看過上面的計畫再加這個 flag)。'); + process.exit(1); + } + console.log('=== 開始刪除 ===\n'); + const results = await applyTeardown(plan, { accountId: args.accountId, apiToken: args.apiToken }); + console.log('\n=== 完成 ==='); + console.log(JSON.stringify(results, null, 2)); + return; + } + + if (cmd === 'central-plan' || cmd === 'central-apply') { + if (!args.accountId) { + console.error('需要 --account-id(要清哪個帳號的 deployed 紀錄)'); + process.exit(1); + } + const plan = planCentralCleanup(args.accountId); + printCentralPlan(plan); + if (cmd === 'central-plan') return; + if (!args.yes) { + console.error('central-apply 需要加 --yes。'); + process.exit(1); + } + console.log('=== 開始刪除中心紀錄 ===\n'); + const results = applyCentralCleanup(plan); + console.log('\n=== 完成 ==='); + console.log(JSON.stringify(results, null, 2)); + return; + } + + console.log(`用法: + 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 + node scripts/teardown-instance.mjs central-plan --account-id + node scripts/teardown-instance.mjs central-apply --account-id --yes`); + process.exit(1); +} + +main().catch((e) => { + console.error('錯誤:', e instanceof Error ? e.message : e); + process.exit(1); +}); From 05e879faa7b68782efbc31555a873d06f7ce9179 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 14 Aug 2026 14:13:39 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(teardown):=20=E6=8B=BF=E6=8E=89=20wrang?= =?UTF-8?q?ler=20kv=20key=20delete=20=E7=9A=84=20--force=EF=BC=88=E9=80=99?= =?UTF-8?q?=E7=89=88=20wrangler=20=E6=B2=92=E9=80=99=E5=80=8B=20flag?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實跑撞到:這台裝的 wrangler 4.100.0 的 `kv key delete` 沒有 --force,帶了就直接 「Unknown argument: force」失敗。central-apply 兩個通道各撞一次才發現。 拿掉 --force 後在真實 youlin 帳號實測:central-apply 兩個通道(prod INSTALLER_KV + staging PEER_INSTALLER_KV)的 deployed:: 紀錄都刪除成功,central-plan 複驗兩邊皆印「沒有殘留紀錄」。這是 Arcrun#120 死結修復鏈路的必要一步。 Co-Authored-By: Claude Opus 5 --- scripts/teardown-instance.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/teardown-instance.mjs b/scripts/teardown-instance.mjs index 26c527f..d0da4e7 100644 --- a/scripts/teardown-instance.mjs +++ b/scripts/teardown-instance.mjs @@ -309,7 +309,7 @@ function wranglerKvList(namespaceId, prefix) { function wranglerKvDelete(namespaceId, key) { execFileSync( 'npx', - ['wrangler', 'kv', 'key', 'delete', '--namespace-id', namespaceId, key, '--remote', '--force'], + ['wrangler', 'kv', 'key', 'delete', '--namespace-id', namespaceId, key, '--remote'], { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } ); } From 49248b18c51bd6797ae5330224fec80017309bc5 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 14 Aug 2026 14:20:56 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(teardown):=20=E8=A8=98=E4=B8=8B?= =?UTF-8?q?=E5=85=A9=E5=B1=A4=E5=92=AC=E5=90=88=E7=9A=84=E5=B7=B2=E7=9F=A5?= =?UTF-8?q?=E9=99=90=E5=88=B6=EF=BC=88=E8=B7=A8=20worker=20=E4=BE=9D?= =?UTF-8?q?=E8=B3=B4=E9=8F=88=EF=BC=8C2026-08-14=20=E5=AF=A6=E6=92=9E?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 今天實跑撞到、也解掉的真事:kbdb-graph-plugin(不屬於這台實例,正確被排除不動) 身上有 service binding 指著 arcrun-kbdb(要刪的)→ CF 拒絕刪除,本工具只回報那次 DELETE 失敗,不會主動點出「有外部依賴卡住」。若選擇半殘留(清資源留殼),下一步 acr init/update 的 resource-rule fail-closed 保護會正確拒絕重裝——這是它該有的行為, 但代表本工具留下的半殘留會讓下一步卡住,不是「拆一半也沒關係」。 本工具目前無法自動偵測跨 worker 依賴鏈,寫進「已知限制」而不是假裝判準完備。 Co-Authored-By: Claude Opus 5 --- scripts/teardown-instance.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/teardown-instance.mjs b/scripts/teardown-instance.mjs index d0da4e7..d3e91cd 100644 --- a/scripts/teardown-instance.mjs +++ b/scripts/teardown-instance.mjs @@ -32,6 +32,20 @@ * - 中心 KV 清理走 `wrangler kv key`,用的是本機既有的 wrangler OAuth 登入態(不是 CF API * token),因為 INSTALLER_KV 住在另一個帳號(uncle6,官方安裝器所在),本工具沒有那把帳號 * 的 API token。呼叫前務必 `npx wrangler whoami` 確認登入態是 uncle6,不是別的帳號。 + * - 🔴 **偵測不到「不屬於這台實例的 worker 反過來依賴這台實例的 worker」這種形狀** + * (2026-08-14 實撞,兩層咬合):`kbdb-graph-plugin`(不認得,正確被排除、不動)身上有 + * service binding 指著 `arcrun-kbdb`(要刪的)。CF 因此拒絕刪 `arcrun-kbdb`—— + * 本工具當下**只會回報那次 DELETE 呼叫失敗**,不會主動告訴你「有一個外部依賴卡住了」, + * 需要人自己讀錯誤訊息裡的 worker 名字去追。 + * ⇒ **第二層**:kbdb-graph-plugin 被移除後 `arcrun-kbdb` 才刪得掉,但如果選擇「先清掉 + * `arcrun-kbdb` 綁著的資源、留著這顆殼」這條路(半殘留),下次呼叫 `acr init`/`acr update` + * 時,`shared/resource-rule` 的 fail-closed 保護(Arcrun#97 那道)會正確地發現 + * 「這顆 worker 的 binding 指向的資源已經找不到」而**拒絕重裝**(這是它該有的行為,不是 + * bug;但代表**本工具留下的半殘留會讓下一步——重裝——卡住**,不是「拆一半也沒關係」)。 + * ⇒ **結論**:本工具目前不會自動偵測或處理跨 worker 的依賴鏈;遇到「刪不掉、被誰誰誰的 + * service binding 卡住」時,操作者要自己判斷那個「誰誰誰」是不是這台實例的一部分 + * (是 → 一併排入拆除;不是 → 照紅線問人再決定要不要動它),**拆完後如果選了半殘留, + * 要有心理準備下一步的 fail-closed 檢查會擋下重裝**。 * * 用法: * node scripts/teardown-instance.mjs plan --account-id --api-token [--keep w1,w2] From c543abaa8a0d13b7a2815ef0c6da1cdf46424069 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 14 Aug 2026 14:29:03 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(teardown):=20central=20KV=20=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=89=8D=E6=A9=9F=E6=A2=B0=E6=A0=B8=E5=B0=8D=20wrangl?= =?UTF-8?q?er=20=E8=BA=AB=E5=88=86=EF=BC=88D88=EF=BC=8Cleo=20=E5=AF=A9?= =?UTF-8?q?=E6=A0=B8=E8=A3=81=E7=A4=BA=E2=91=A0=EF=BC=8C=E5=BF=85=E4=BF=AE?= =?UTF-8?q?=E9=A0=85=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 審核意見:「這條正好違反 D88——閘要長在機器上,不是長在誰的記性上。而且失敗模式 是最壞的那種:靜默打錯帳號的中心 KV,沒有任何東西會叫。帳號側有白名單擋、中心側完全 沒有——這個不對稱本身就是設計缺口。」 修法:assertWranglerIsCentralAccount() 在 planCentralCleanup()(central-plan/ central-apply 共用的入口)最前面跑 `wrangler whoami --json`,核對登入態的帳號清單裡 有 uncle6(58309bb90fd93ad6d0fe0aae99170e9d)才放行,沒有就丟錯拒絕整個操作。 已用模擬錯誤帳號 id 驗證比對邏輯正確拒絕;正常路徑(真的登入 uncle6)central-plan 照常運作,貼在同一次 commit 的測試輸出裡。 同時把 leo 裁示②(apply 的依賴序失敗不擋併、但要記實測輪數)補進「已知限制」段: 兩次實測收斂輪數分別是 2 輪(有外部依賴殘留時)與 3 輪(純內部 service binding 鏈時)。 Co-Authored-By: Claude Opus 5 --- scripts/teardown-instance.mjs | 62 +++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/scripts/teardown-instance.mjs b/scripts/teardown-instance.mjs index d3e91cd..f3754bd 100644 --- a/scripts/teardown-instance.mjs +++ b/scripts/teardown-instance.mjs @@ -31,7 +31,18 @@ * 「這個 worker 是不是正在被別人使用」——那是 main/coordinator 層級的協調,不是機器能判斷的)。 * - 中心 KV 清理走 `wrangler kv key`,用的是本機既有的 wrangler OAuth 登入態(不是 CF API * token),因為 INSTALLER_KV 住在另一個帳號(uncle6,官方安裝器所在),本工具沒有那把帳號 - * 的 API token。呼叫前務必 `npx wrangler whoami` 確認登入態是 uncle6,不是別的帳號。 + * 的 API token。**2026-08-14 起機械強制**:`assertWranglerIsCentralAccount()` 在任何 + * `central-plan`/`central-apply` 動作前跑 `wrangler whoami --json`,核對登入態看得到 + * uncle6 帳號才放行,看不到就丟錯拒絕(D88「閘要長在機器上,不是長在誰的記性上」—— + * 原本這是操作者每次手動核對的紀律,不是結構保證,現在補成機械閘)。 + * - `apply` 對「worker 被別的 worker service binding 卡住刪不掉」**不會自動重試/拓樸排序**。 + * 這是 fail-safe(失敗就是刪不掉,不會刪錯東西),但需要操作者自己重跑 `apply` 到收斂。 + * 2026-08-14 兩次實測的收斂輪數:第一次(帳號上有 kbdb-graph-plugin 這類外部依賴殘留) + * 跑了 2 輪(16 個失敗 → mcp 已刪,cypher-executor 解鎖 → 其餘 13 個跟著解鎖,僅 + * arcrun-kbdb 卡在外部依賴,需另外處理);第二次(帳號上沒有外部依賴殘留,純內部 + * service binding 鏈)跑了 3 輪(16 個失敗 → cypher-executor 解鎖、13 個一次過 → + * 只剩 array-ops 卡 cypher-executor 剛好晚一步刪掉 → 第 3 輪清空)。**下一個人看到 + * 第一輪一半失敗不是工具壞掉,是預期行為,重跑 `apply` 即可,通常 2–3 輪收斂。** * - 🔴 **偵測不到「不屬於這台實例的 worker 反過來依賴這台實例的 worker」這種形狀** * (2026-08-14 實撞,兩層咬合):`kbdb-graph-plugin`(不認得,正確被排除、不動)身上有 * service binding 指著 `arcrun-kbdb`(要刪的)。CF 因此拒絕刪 `arcrun-kbdb`—— @@ -296,14 +307,58 @@ async function applyTeardown(plan, { accountId, apiToken }) { // ── 三、中心 KV(安裝器 deployed 紀錄)清理 ──────────────────────────────────── // 這兩顆 KV 住在 uncle6 帳號(官方安裝器 arcrun-installer 所在),不是 youlin 的帳號。 -// 沒有這顆帳號的 CF API token,走本機已登入的 wrangler OAuth(`npx wrangler whoami` 需顯示 -// uncle6.me@gmail.com's Account,58309bb90fd93ad6d0fe0aae99170e9d)。 +// 沒有這顆帳號的 CF API token,走本機已登入的 wrangler OAuth。 +// +// 🔴 2026-08-14 leo 審核裁示(D88「閘要長在機器上,不是長在誰的記性上」):這裡原本 +// 靠操作者自己每次手動 `wrangler whoami` 核對——那個核對動作是對的,只是長錯地方 +// (長在人身上)。失敗模式是最壞的一種:wrangler 的 OAuth 登入態如果哪天換成別的帳號, +// 會**靜默**對錯的帳號動中心 KV,沒有任何東西會叫。帳號側(youlin)有 ALLOWED_ACCOUNT_IDS +// 白名單擋,中心側原本完全沒有——這個不對稱本身就是設計缺口,現在補齊。 +// assertWranglerIsCentralAccount() 在任何中心 KV 讀寫前跑,核對 wrangler 現在的 OAuth +// 登入態的帳號清單裡有沒有 CENTRAL_ACCOUNT_ID,沒有就直接丟錯、不放行。 const CENTRAL_KV = { prod: { id: '2ff9a845e7f646dab7b1e3dd6707b77f', label: 'INSTALLER_KV(prod 通道)' }, staging: { id: '750c23faf0794d3db317c32f596df9a9', label: 'PEER_INSTALLER_KV(staging 通道,t157 雙通道問題)' }, }; const CENTRAL_ACCOUNT_ID = '58309bb90fd93ad6d0fe0aae99170e9d'; // uncle6 +const CENTRAL_ACCOUNT_LABEL = "uncle6.me@gmail.com's Account"; + +/** 核對本機 wrangler 的 OAuth 登入態確實看得到 uncle6 帳號,不對就丟錯、不放行任何中心 KV 操作。 */ +function assertWranglerIsCentralAccount() { + let out; + try { + out = execFileSync('npx', ['wrangler', 'whoami', '--json'], { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (e) { + throw new Error( + `拒絕執行中心 KV 操作:無法確認 wrangler 登入身分(\`wrangler whoami --json\` 失敗:` + + `${e instanceof Error ? e.message : e})。這是刪除工具,身分核不對就不准動中心 KV。` + ); + } + const jsonStart = out.indexOf('{'); + let who; + try { + who = JSON.parse(jsonStart >= 0 ? out.slice(jsonStart) : out); + } catch { + throw new Error(`拒絕執行中心 KV 操作:解析 \`wrangler whoami --json\` 輸出失敗:${out.slice(0, 300)}`); + } + if (!who.loggedIn) { + throw new Error('拒絕執行中心 KV 操作:wrangler 目前未登入(loggedIn:false)。'); + } + const ids = (who.accounts ?? []).map((a) => a.id); + if (!ids.includes(CENTRAL_ACCOUNT_ID)) { + throw new Error( + `拒絕執行中心 KV 操作:wrangler 目前的 OAuth 登入態(${who.email ?? '未知 email'})看得到的帳號是` + + ` [${ids.join(', ') || '無'}],裡面沒有中心帳號 ${CENTRAL_ACCOUNT_ID}(${CENTRAL_ACCOUNT_LABEL})。` + + ` 這代表你現在的 wrangler 登入態不是 uncle6——INSTALLER_KV/PEER_INSTALLER_KV 只住在那個帳號,` + + ` 繼續執行極可能是對錯的帳號動手(或單純連不到)。請先 \`npx wrangler login\` 切回 uncle6 帳號再重跑。` + ); + } +} function wranglerKvList(namespaceId, prefix) { const out = execFileSync( @@ -329,6 +384,7 @@ function wranglerKvDelete(namespaceId, key) { } function planCentralCleanup(accountId) { + assertWranglerIsCentralAccount(); const prefix = `deployed:${accountId}:`; const plan = {}; for (const [chan, kv] of Object.entries(CENTRAL_KV)) { From 5d441aa7d3be3c5677a864ef5a1a865a5c54e477 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 14 Aug 2026 19:51:31 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(teardown):=20=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E5=81=B4=E8=A3=9C=E4=B8=8A=E5=B8=B3=E8=99=9F=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=96=AE=E2=80=94=E2=80=94=E5=8E=9F=E6=9C=AC=20central-*=20?= =?UTF-8?q?=E7=A2=B0=E5=BE=97=E5=88=B0=20leo21c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實測破口(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 --- scripts/teardown-instance.mjs | 49 +++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) 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 = {};