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
+51 -6
View File
@@ -45,14 +45,21 @@ export function requirements() {
return out;
}
/** 安裝器替這台實例算出來的名字前綴(`arcrun-rag-<slugFromEmail(email)>`)。 */
export const BASE_NAME = 'arcrun-rag-yuga3bse';
/**
* 種情境。`titleFor` 決定「使用者帳號上那顆資源實際叫什麼名字」——
* 種情境。`titleFor` 決定「使用者帳號上那顆資源實際叫什麼名字」——
* 這正是 #97 的病根所在:規則**不准**拿名字當識別。
*
* @typedef {'fresh' | 'installed' | 'renamed'} Scenario
* `resourcesExist` 與 `deployed` **刻意拆開**:兩者不一致的那一格
* (資源在、worker 不在)就是 Arcrun#123 ——「上一次裝到一半死掉」的帳號。
* 本檔原本只有 `deployed` 一個旗標,所以那個狀態**表達不出來,也就沒被測到**。
*
* @typedef {'fresh' | 'installed' | 'renamed' | 'half-finished'} Scenario
*/
/** @type {Record<Scenario, {label: string, deployed: boolean, titleFor: (binding: string) => string}>} */
/** @type {Record<Scenario, {label: string, deployed: boolean, resourcesExist?: boolean, titleFor: (binding: string) => string}>} */
export const SCENARIOS = {
fresh: {
label: '沒裝過(全新帳號,一顆 worker 都沒有)',
@@ -62,7 +69,7 @@ export const SCENARIOS = {
installed: {
label: '裝過了(安裝器命名慣例 arcrun-rag-<instance>-kv-<binding>',
deployed: true,
titleFor: (b) => `arcrun-rag-yuga3bse-kv-${b.toLowerCase()}`,
titleFor: (b) => `${BASE_NAME}-kv-${b.toLowerCase()}`,
},
renamed: {
label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)',
@@ -70,8 +77,43 @@ export const SCENARIOS = {
// 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。
titleFor: (b) => `kv-${[...b].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`,
},
'half-finished': {
label: '上一次裝到一半死掉(KV/D1 已建在帳號上,一顆 worker 都還沒部署)— Arcrun#123',
deployed: false,
resourcesExist: true,
titleFor: (b) => `${BASE_NAME}-kv-${b.toLowerCase()}`,
},
};
/**
* 安裝器那條路的需求清單:`createName` 是**安裝器自己替這台實例算出來的**,
* 所以它有資格聲明 `createNameIsOurs`(見 rule.mjs 該欄位的推導條件)。
*
* 對照 `requirements()`(走 wrangler.tomlcreateName 是裸 binding 名 ⇒ **不得**聲明)。
*
* @param {boolean} [claimOwnership] 預設 true;傳 false 就是「安裝器忘了聲明」的對照組。
*/
export function installerRequirements(claimOwnership = true) {
const out = [];
for (const [worker, need] of Object.entries(WORKER_NEEDS)) {
for (const b of need.kv) {
out.push({
kind: 'kv_namespace', binding: b, worker,
createName: `${BASE_NAME}-kv-${b.toLowerCase()}`,
...(claimOwnership ? { createNameIsOurs: true } : {}),
});
}
for (const d of need.d1) {
out.push({
kind: 'd1', binding: d.binding, worker,
createName: `${BASE_NAME}-kbdb`,
...(claimOwnership ? { createNameIsOurs: true } : {}),
});
}
}
return out;
}
/**
* 建一個假帳號 + 對應的 `fetch` 替身。
*
@@ -109,15 +151,18 @@ export function makeAccount(scenario) {
const kvIdByBinding = new Map();
const D1_ID = 'd1id-kbdb-REAL';
if (spec.deployed) {
// 資源存不存在,與 worker 部署了沒,是**兩件事**(#123:中斷的安裝會讓前者為真、後者為假)。
if (spec.resourcesExist ?? spec.deployed) {
// 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分)
for (const b of KV_BINDINGS) {
const id = `kvid-${b.toLowerCase()}-REAL`;
kv.set(spec.titleFor(b), id);
kvIdByBinding.set(b, id);
}
d1.set('arcrun-rag-yuga3bse-kbdb', D1_ID);
d1.set(`${BASE_NAME}-kbdb`, D1_ID);
}
if (spec.deployed) {
// 已部署的 worker 上綁著它們——**這才是規則要看的事實**
for (const [script, need] of Object.entries(WORKER_NEEDS)) {
const bindings = [];
@@ -0,0 +1,181 @@
// @ts-check
/**
* half-finished-install.mjs — Arcrun#123 的迴歸守衛。
*
* node shared/resource-rule/tests/half-finished-install.mjs
*
* 【要證的那句話(leo 2026-08-14 的驗收線)】
* 「一個**上次裝到一半死掉**的帳號,用戶只做一件事——回安裝器再按一次——就要能裝成功。」
* ⇒ 不准叫用戶開 Cloudflare 後台、不准叫他跑指令、不准要他懂 namespacebinding。
*
* 【為什麼這個狀態逃過了 1.4.45 之前所有驗證】
* 我們測的是「乾淨帳號 + 完整安裝」。而這個 bug 只在
* **資源已建、worker 未部署** 這一格才撞得到——`fixture-account.mjs` 原本
* 只有 `deployed` 一個旗標,連表達這個狀態的能力都沒有。
*
* 零依賴、零建置:跟 demo.mjs 一樣,跑得起來本身就是
* 「安裝器把 repo archive 拉下來就能直接用」的證據。
*/
import { planResources, applyResourcePlan, ResourcePlanBlocked, bindingKey } from '../rule.mjs';
import { createCloudflareResourceApi } from '../cf-resource-api.mjs';
import { makeAccount, installerRequirements, requirements, KV_BINDINGS, BASE_NAME } from './fixture-account.mjs';
let failed = 0;
/** @param {boolean} cond @param {string} what */
function check(cond, what) {
console.log(` ${cond ? '✅' : '❌'} ${what}`);
if (!cond) failed++;
}
/** @param {string} title */
function section(title) {
console.log(`\n━━━ ${title} ━━━`);
}
/**
* 真實 CF 的行為:**同名 KV 建不出來**。這一行就是封測者撞到的那道牆——
* fixture 過去沒有模擬它,所以「重建一批孤兒」這個當年的假設從來沒有被戳破。
* @param {ReturnType<typeof makeAccount>} account
*/
function cfRejectsDuplicateNames(account) {
const inner = account.fetch;
/** @type {typeof globalThis.fetch} */
// @ts-expect-error — 測試替身
return async (input, init) => {
const url = new URL(typeof input === 'string' ? input : String(input));
const path = url.pathname.replace(/^\/client\/v4\/accounts\/[^/]+/, '');
if (path === '/storage/kv/namespaces' && (init?.method ?? 'GET').toUpperCase() === 'POST') {
const title = JSON.parse(String(init?.body)).title;
const listed = await (await inner(`https://api.cloudflare.com/client/v4/accounts/x/storage/kv/namespaces`, {})).json();
if (listed.result.some((/** @type {{title: string}} */ n) => n.title === title)) {
return new Response(JSON.stringify({
success: false, result: null,
errors: [{ message: 'a namespace with this account ID and title already exists' }],
}), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
}
return inner(input, init);
};
}
/** @param {ReturnType<typeof makeAccount>} account */
const apiFor = (account, fetchImpl) =>
createCloudflareResourceApi({ accountId: 'acct-123', apiToken: 'tok-123', fetch: fetchImpl ?? account.fetch });
// ═══════════════════════════════════════════════════════════════════════════
section('① 驗收線本身:半殘帳號 + 安裝器再按一次 → 裝得起來,且一顆資源都不必新建');
// ═══════════════════════════════════════════════════════════════════════════
{
const account = makeAccount('half-finished');
const api = apiFor(account, cfRejectsDuplicateNames(account));
// mode='init':安裝器看的是自己的紀錄(`deployed:<account>:`),上次沒裝完就沒有那筆
// ⇒ 這一輪照定義是「新裝」。半殘狀態必須在 init 這條路上就被處理掉。
const plan = await planResources(api, installerRequirements(), 'init');
check(plan.blockers.length === 0, `不該有任何 blocker(實得 ${plan.blockers.length} 條)`);
if (plan.blockers.length) console.log(plan.blockers.map((b) => ` · ${b}`).join('\n'));
check(plan.create.length === 0, `一顆都不該新建(實得 ${plan.create.length} 顆要建)`);
// 11 9 個 KV binding + 2 個 D1 bindingCREDENTIALS_DBDB,兩個指向同一顆庫)。
// 這裡數的是**綁定**,不是資源顆數——底下那條 D1 斷言才在證「兩個綁定指到同一顆」。
check(plan.adopt.length === 11, `11 個綁定全部接回來(實得 ${plan.adopt.length}`);
check(plan.adopt.every((a) => a.reclaimed === true), '每一顆都標記為「接回上次留下的」');
const resolved = await applyResourcePlan(api, plan);
check(account.created.kv.length === 0, `帳號上不該多出任何 KV(實得 ${account.created.kv.length}`);
check(account.created.d1.length === 0, `帳號上不該多出任何 D1(實得 ${account.created.d1.length}`);
// 綁到的必須是**上次留下的那幾顆本尊**,不是新的空殼
const webhooks = resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'));
check(webhooks?.value === account.kvIdFor('WEBHOOKS'), 'WEBHOOKS 綁回上次建的那一顆本尊');
check(webhooks?.origin === 'adopted', `origin 仍是 adopted(實得 ${webhooks?.origin})——` +
'安裝器現有的「沿用你原本的 N 項資源」統計不會漏數');
const everyBindingResolved = KV_BINDINGS.every((b) => resolved.has(bindingKey('kv_namespace', b)));
check(everyBindingResolved, '9 個 KV binding 全部都有著落(安裝可以繼續往下走)');
check(resolved.get(bindingKey('d1', 'DB'))?.value === account.d1Id
&& resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value === account.d1Id,
'kbdb 與 cypher 兩個 D1 binding 指到同一顆(維持「整台一顆 D1」的形狀)');
}
// ═══════════════════════════════════════════════════════════════════════════
section('② 對照組:修好之前是什麼下場(沒有聲明 createNameIsOurs ⇒ 走舊行為)');
// ═══════════════════════════════════════════════════════════════════════════
{
// 不聲明所有權時,規則不准接管 ⇒ 停手。這也是舊版**會撞牆**的那條路:
// 舊版會直接送 POST,然後被 CF 用「title already exists」打回來。
const account = makeAccount('half-finished');
const api = apiFor(account, cfRejectsDuplicateNames(account));
const plan = await planResources(api, installerRequirements(false), 'init');
check(plan.blockers.length > 0, '證明不了是自己的 → 一定要停手(fail-closed');
check(plan.blockers.join('\n').includes('RES-NAME-TAKEN'), '錯誤碼要在訊息裡(讓用戶回報,而不是自己去後台動手)');
const said = plan.blockers.join('\n');
check(!/Cloudflare 後台|dashboard|自己刪|去刪/.test(said), '訊息不准叫使用者自己去 CF 後台處理(#121/D88');
await assertRejects(() => applyResourcePlan(api, plan));
check(account.created.kv.length === 0 && account.created.d1.length === 0,
'被擋下時一顆資源都不能被建出來(plan/apply 兩段的結構保證)');
}
// ═══════════════════════════════════════════════════════════════════════════
section('③ 紅線:不准接管「真的不是我們的」同名資源');
// ═══════════════════════════════════════════════════════════════════════════
{
// 使用者自己在帳號上建了一顆叫 WEBHOOKS 的 KV。走 wrangler.toml 那條路(acr)時
// createName 就是裸 binding 名 `WEBHOOKS` ⇒ 撞名。那顆**可能真的是他自己的**
// ⇒ 規則不准接管,也不准新建一顆頂上去。
const account = makeAccount('fresh');
const api = apiFor(account);
await api.createKvNamespace('WEBHOOKS'); // ← 使用者自己的東西
account.created.kv.length = 0; // 歸零,只算「規則這一趟建了什麼」
const plan = await planResources(api, requirements(), 'init');
check(plan.blockers.length > 0, '撞到不能證明是我們的同名資源 → 停手');
check(!plan.create.some((c) => c.binding === 'WEBHOOKS'), 'WEBHOOKS 不准被排進「要新建」');
await assertRejects(() => applyResourcePlan(api, plan));
check(account.created.kv.length === 0, '一顆都沒建');
}
// ═══════════════════════════════════════════════════════════════════════════
section('④ D82 三步不可退化:既有的三種情境行為完全不變');
// ═══════════════════════════════════════════════════════════════════════════
{
// 全新帳號:照舊該建的全建出來
const fresh = makeAccount('fresh');
const freshPlan = await planResources(apiFor(fresh), installerRequirements(), 'init');
check(freshPlan.blockers.length === 0, '全新帳號:無 blocker');
check(freshPlan.adopt.length === 0, '全新帳號:沒有東西可沿用');
// 11 個綁定 → 10 顆要建:兩個 D1 綁定宣告同一個 createName,被 shareSameResource 收斂成一顆。
check(freshPlan.create.length === 10, `全新帳號:11 個綁定收斂成 10 顆要建(實得 ${freshPlan.create.length}`);
await applyResourcePlan(apiFor(fresh), freshPlan);
check(fresh.created.kv.length === 9 && fresh.created.d1.length === 1,
`全新帳號:實際建出 9 KV + 1 D1(實得 ${fresh.created.kv.length} / ${fresh.created.d1.length}`);
// 已裝好的實例跑更新:#97 的核心保證——沿用既有、一顆都不新建
const installed = makeAccount('installed');
const upPlan = await planResources(apiFor(installed), installerRequirements(), 'update');
check(upPlan.blockers.length === 0, '已裝好:無 blocker');
check(upPlan.create.length === 0, '已裝好:一顆都不新建(#97');
check(upPlan.adopt.every((a) => !a.reclaimed), '已裝好:全部來自 worker 綁定,沒有一顆走「接回殘骸」那條路');
await applyResourcePlan(apiFor(installed), upPlan);
check(installed.created.kv.length === 0 && installed.created.d1.length === 0, '已裝好:帳號上顆數不變');
// 使用者把資源改過名:規則不看名字,照樣沿用 worker 綁著的那幾顆(#97 的另一面)
const renamed = makeAccount('renamed');
const rnPlan = await planResources(apiFor(renamed), installerRequirements(), 'update');
check(rnPlan.blockers.length === 0, '改過名:無 blocker');
check(rnPlan.create.length === 0, '改過名:一顆都不新建——名字對不上也不影響(規則只看綁定)');
check(rnPlan.adopt.every((a) => !a.reclaimed), '改過名:沒有一顆是靠名字對上的');
}
/** @param {() => Promise<unknown>} fn */
async function assertRejects(fn) {
try {
await fn();
check(false, 'applyResourcePlan 應該要丟 ResourcePlanBlocked,但它沒有');
} catch (e) {
check(e instanceof ResourcePlanBlocked, 'applyResourcePlan 丟 ResourcePlanBlocked');
}
}
console.log(`\n${failed === 0 ? '✅ 全部通過' : `${failed} 項失敗`}`);
process.exit(failed === 0 ? 0 : 1);