Files
Arcrun/scripts/teardown-instance.mjs
T
uncle6me-web 7498911856 WIP: 內部 uninstaller 工具(拆帳號側 + 中心 KV),尚未跑過 apply(Arcrun#119/#120/#122)
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:<accountId>: 紀錄(#120 死結的另一半,帳號側清了但這裡沒清會卡死重裝)
  - 已用 `plan` 在 youlin 帳號實跑過(唯讀),清單看起來合理,貼在 issue/回報裡

還沒做:
- apply(真的刪)、central-apply、以及刪完後的重裝驗證,全部還沒跑
- 已知限制列在檔頭註解:判準是「repo 認不認得」不是「屬於哪一台實例」,
  同帳號多台同名慣例實例分不出來(今天 youlin 上短暫同時有正式實例與
  ship-stage-119 的 freshtest 測試實例,此時就是靠人工 --keep 排除)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:25:39 +08:00

427 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:<accountId>:`
* 不是「帳號上現在有什麼」。只清帳號上的 worker/KV/D1/Vectorize、漏清中心那筆 ⇒ 重裝被判定
* 「已經裝過」卡死。本工具因此分兩段:① 帳號側(Cloudflare 資源)② 中心側(安裝器的
* INSTALLER_KVPEER_INSTALLER_KV `deployed:` 紀錄)——兩段都做才算「不再殘留」。
*
* 安全設計(這是刪除工具,寫錯會毀掉別人的東西):
* 1. 帳號白名單——只認 ALLOWED_ACCOUNT_IDS 裡的帳號,其餘一律拒絕執行。
* 2. 兩階段——預設 `plan`(唯讀,只印清單);要真的刪必須 `apply --yes`。
* 3. 歸屬判準——「這個帳號上的 worker 是不是這台 Arcrun 實例的」不是猜名字前綴,是
* 「這個 worker 名字有沒有出現在本 repomatrix/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 <name,...>` 需要呼叫者自己知道帳號上有哪些 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 <id> --api-token <token> [--keep w1,w2]
* node scripts/teardown-instance.mjs apply --account-id <id> --api-token <token> [--keep w1,w2] --yes
* node scripts/teardown-instance.mjs central-plan --account-id <id>
* node scripts/teardown-instance.mjs central-apply --account-id <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', 'youlinstage,可自由拆裝)'],
]);
// 明確擋掉的帳號(即使有人想放進 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.jsonmanifest.core[].name
// 併入已知清單。manifest.json 是公開端點、本工具的目的正是「重裝後長出一樣的東西」,
// 用它當第二個真相源與目的一致。抓不到(離線/端點掛了)就退回只用 repo 掃描,印警告,
// 不讓網路失敗擋住整個工具。
const DEFAULT_MANIFEST_URL =
'https://cdn.jsdelivr.net/gh/youlinhsieh/arcrun-rag-bundles@20d4ad6900f51e02242815402a70890fc5b12712/manifest.json';
/** @returns {Promise<string[]>} */
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<string>} */
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。
* 回傳 workerBindingsworker 名字對應到 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 Account58309bb90fd93ad6d0fe0aae99170e9d)。
const CENTRAL_KV = {
prod: { id: '2ff9a845e7f646dab7b1e3dd6707b77f', label: 'INSTALLER_KVprod 通道)' },
staging: { id: '750c23faf0794d3db317c32f596df9a9', label: 'PEER_INSTALLER_KVstaging 通道,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 <id> --api-token <token> [--keep w1,w2]
node scripts/teardown-instance.mjs apply --account-id <id> --api-token <token> [--keep w1,w2] --yes
node scripts/teardown-instance.mjs central-plan --account-id <id>
node scripts/teardown-instance.mjs central-apply --account-id <id> --yes`);
process.exit(1);
}
main().catch((e) => {
console.error('錯誤:', e instanceof Error ? e.message : e);
process.exit(1);
});