fix(resource-rule): 上次裝到一半死掉的帳號要能再裝一次(Arcrun#123)

封測者 1.4.45 實撞:
  a namespace with this account ID and title already exists
⇒ 那個帳號從此永遠裝不起來,而錯誤訊息對用戶完全無法行動。

根因:rule.mjs 第 2c 段只問「有沒有已部署的 worker 綁著它」,
不問「這個名字在帳號上是不是已經存在」。註解裡「本來就沒有東西可丟」
漏掉一種狀態——資源已建、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。
拆除 youlin 時親眼看到的 8 顆空殼 KV 是同一個形狀。

修法:2c 在 create 之前先查同名。找到同名資源時:
  · 呼叫端聲明了 createNameIsOurs → 接回那一顆(adopt + reclaimed 標記)
  · 沒聲明 → 停手,訊息帶 RES-NAME-TAKEN 錯誤碼讓用戶回報

createNameIsOurs 是接管的唯一依據,預設 false(fail-closed)。只有名字
推導自使用者自己的身分時才准聲明——安裝器的
arcrun-rag-<slugFromEmail(email)>-kv-<binding> 合格;acr 從 toml 讀到的
裸 binding 名(WEBHOOKS)不合格,因為用戶自己也可能用那個名字。

這不是把 #97 刪掉的「照名字 ensure」搬回來:
  ① #97 找不到就新建一顆頂上去(會弄丟資料);這裡找到才沿用,
     找不到照舊新建,永遠不拿新的空資源頂替既有的
  ② 排在「已部署綁定=事實」之後,名字只在沒有任何綁定可看時才有發言權
  ③ #97 無條件相信名字;這裡要呼叫端先證明名字推導自用戶身分

順手補上假帳號的保真度:FakeCloudflare.createKvNamespace 原本不擋同名,
所以半殘帳號在測試裡看起來只是「多幾顆孤兒」,實際上是裝不起來——
少了那一行,這個 bug 測不出來。fixture-account.mjs 也把
resourcesExist 與 deployed 拆開,才表達得出這個狀態。

驗證(皆為實跑):
  node shared/resource-rule/tests/half-finished-install.mjs  → 全部通過(零依賴)
  cd cli && npm test                                          → 73/73 pass
  sync-resource-rule --check                                  → 三份副本皆與原稿一致

⚠️ 只有規則這一半。安裝器要在 manifestRequirements 聲明 createNameIsOurs
才會生效,那一半在 arcrun-rag(D85)。

Refs: inkstone/Arcrun#123
This commit is contained in:
claude-code
2026-08-14 08:08:37 +00:00
parent 94d5452425
commit 3f2e45f5dc
6 changed files with 546 additions and 40 deletions
+101
View File
@@ -135,6 +135,13 @@ class FakeCloudflare implements ResourceApi {
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
async createKvNamespace(title: string): Promise<string> {
// 🔴 Arcrun#123:真的 Cloudflare **不准同名**——
// `a namespace with this account ID and title already exists`。
// 這個假帳號原本沒有模擬這條限制,於是「上一次裝到一半死掉」的帳號在測試裡
// 看起來只是「多建幾顆孤兒」,實際上是**再也裝不起來**。少了這一行,#123 測不出來。
if (this.kv.has(title)) {
throw new Error('a namespace with this account ID and title already exists');
}
const id = `NEW-kvid-${this.createdKv.length}`;
this.kv.set(title, id);
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
@@ -486,6 +493,100 @@ test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
});
// ═════════════════════════════════════════════════════════════════════════════
// Arcrun#123 —— 「上一次裝到一半死掉」的帳號,再裝一次要能成功
//
// 這一格與 #97 的差別:#97 是「worker 綁著資源,卻被重新綁到新建的空殼」(會弄丟資料);
// #123 是「資源建好了、worker 一顆都還沒部署」——**沒有任何綁定可以當事實**,
// 而帳號上偏偏已經有一批同名資源 ⇒ 舊規則判「可以新建」⇒ CF 拒絕 ⇒ 這個帳號從此裝不起來。
//
// 封測者 1.4.45 實撞。逃過驗證的原因:我們只測乾淨帳號與完整安裝。
// ═════════════════════════════════════════════════════════════════════════════
/**
* 安裝器那條路的需求:`createName` 是安裝器**自己替這台實例算出來的**
* `arcrun-rag-<slugFromEmail(email)>-…`)⇒ 有資格聲明 createNameIsOurs。
* 對照 collectRequirements()(走 tomlcreateName 是裸 binding 名 ⇒ 不得聲明)。
*/
function installerRequirements(claimOwnership = true): BindingRequirement[] {
const own = claimOwnership ? { createNameIsOurs: true } : {};
const out: BindingRequirement[] = [];
for (const { requirements } of [collectRequirements()]) {
for (const r of requirements) {
if (r.kind === 'kv_namespace') {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kv-${r.binding.toLowerCase()}`, ...own });
} else if (r.kind === 'd1') {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kbdb`, ...own });
} else {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-embed`, ...own });
}
}
}
return out;
}
test('#123 ①:上次裝到一半死掉的帳號 → 接回上次留下的那批,一顆都不必新建', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true }); // 資源在、worker 一顆都沒有
const kvBefore = cf.kv.size;
const plan = await planResources(cf, installerRequirements(), 'init');
assert.deepEqual(plan.blockers, [], '半殘帳號不該有 blocker——用戶只要再按一次就該裝得起來');
assert.deepEqual(plan.create, [], '一顆都不該新建');
assert.ok(plan.adopt.length > 0 && plan.adopt.every((a) => a.reclaimed === true),
'全部都是「接回上次留下的」');
const resolved = await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
assert.equal(cf.kv.size, kvBefore, '帳號上的顆數不變(沒有再留下一批孤兒)');
// 綁到的是上次建的那幾顆本尊
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.value, 'kvid-webhooks');
assert.equal(resolved.get(bindingKey('d1', 'DB'))!.value, 'd1id-kbdb');
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.origin, 'adopted',
'origin 維持 adopted——呼叫端既有的 adopted/created 統計不會漏數');
});
test('#123 ①對照組:修好之前,同一個帳號會被 CF 用「title already exists」擋死', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
// 不聲明所有權 ⇒ 走的是修好之前的判斷(「沒人綁著就可以新建」)。
// 舊版會直接送 POST → 撞 CF 的同名限制;新版在 plan 階段就停手,兩者都裝不起來,
// 差別在**新版一顆資源都不會被建出來**,而且訊息說得出人話。
const plan = await planResources(cf, installerRequirements(false), 'init');
assert.ok(plan.blockers.length > 0, '證明不了是自己的 → fail-closed 停手');
assert.match(plan.blockers.join('\n'), /RES-NAME-TAKEN/, '要給得出可回報的錯誤碼');
assert.doesNotMatch(plan.blockers.join('\n'), /後台|dashboard/,
'不准叫使用者自己去 Cloudflare 後台處理(#121D88');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '被擋下時一顆都不能被建出來');
});
test('#123 ②紅線:名字真的不是我們的(走 toml 的裸 binding 名)→ 停手,不接管也不新建', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
await cf.createKvNamespace('WEBHOOKS'); // 使用者自己建的、剛好叫這個名字
cf.createdKv.length = 0;
const { requirements } = collectRequirements(); // createName = 裸 binding 名,不得聲明所有權
const plan = await planResources(cf, requirements, 'init');
assert.ok(plan.blockers.length > 0, '撞到不能證明是我們的同名資源 → 停手');
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), 'WEBHOOKS 不准被排進「要新建」');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '一顆都沒建');
});
test('#123 ③:全新帳號照舊建得出整套(D82 第一步不可退化)', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0; // 真正的空帳號
const plan = await planResources(cf, installerRequirements(), 'init');
assert.deepEqual(plan.blockers, [], '全新帳號不該有 blocker');
assert.ok(plan.adopt.length === 0, '全新帳號沒有東西可沿用');
const resolved = await applyResourcePlan(cf, plan);
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length, '9 顆 KV 全部建出來');
assert.equal(cf.createdD1.length, 1, 'D1 建一顆(兩個綁定共用)');
assert.ok([...resolved.values()].every((r) => r.origin === 'created'), '全部都是新建的');
});
test('CfAccountClient.getScriptBindings404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
const orig = globalThis.fetch;
try {