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:
@@ -106,6 +106,19 @@
|
|||||||
* @property {string} binding
|
* @property {string} binding
|
||||||
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
|
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
|
||||||
* @property {string} createName
|
* @property {string} createName
|
||||||
|
* @property {boolean} [createNameIsOurs]
|
||||||
|
* 呼叫端在此**聲明**:`createName` 是我們自己用可重現的方式替**這一台實例**算出來的名字
|
||||||
|
* ⇒ 帳號上若已經有一顆**恰好同名**的資源,它只可能是我們上一次沒裝完留下的(Arcrun#123)。
|
||||||
|
*
|
||||||
|
* 🔴 這個聲明是「接管同名資源」的**唯一**依據,預設 false(fail-closed)。
|
||||||
|
* 只有在名字**推導得出、而且推導的輸入是使用者自己的身分**時才准聲明 true——
|
||||||
|
* 安裝器的 `arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 就是這種
|
||||||
|
* (slug = `SHA-256('arcrun-rag:' + email)` 取前 8 碼,同一個 email 每次算出同一組名字,
|
||||||
|
* 別人算不到、也不會不小心撞上)。
|
||||||
|
*
|
||||||
|
* ⚠️ **不准**因為「名字看起來像我們的」就聲明 true。`acr` 那條從 wrangler.toml 讀到的
|
||||||
|
* createName 是裸 binding 名(`WEBHOOKS`)或 toml 宣告的庫名(`arcrun-kbdb`)——
|
||||||
|
* 那種名字使用者自己也可能拿去用,**證明不了是我們的**,所以那條路一律不聲明。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,7 +126,12 @@
|
|||||||
* @property {ResourceKind} kind
|
* @property {ResourceKind} kind
|
||||||
* @property {string} binding
|
* @property {string} binding
|
||||||
* @property {string} value
|
* @property {string} value
|
||||||
* @property {string} from 從哪顆已部署的 worker 上讀到的
|
* @property {string} from 從哪顆已部署的 worker 上讀到的。`reclaimed` 時為空字串——
|
||||||
|
* **沒有任何 worker 綁著它正是接收它的前提**(Arcrun#123),不是漏填。
|
||||||
|
* @property {boolean} [reclaimed]
|
||||||
|
* true = 這顆不是從某顆 worker 的綁定讀出來的,而是「帳號上已經有一顆我們自己命名的同名資源、
|
||||||
|
* 卻沒有人綁著」⇒ 上一次沒裝完留下的,這次把它接回來用(Arcrun#123)。
|
||||||
|
* 給呼叫端做診斷/統計用;**使用者不必知道「殘骸」這個詞**,對外一律講「沿用你原本的資源」。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,7 +162,11 @@
|
|||||||
* @property {string} binding
|
* @property {string} binding
|
||||||
* @property {string} value
|
* @property {string} value
|
||||||
* @property {'adopted' | 'created'} origin
|
* @property {'adopted' | 'created'} origin
|
||||||
|
* 🔴 接回上次沒裝完留下的那顆(`reclaimed`)**仍然算 `adopted`**,不另開第三種值——
|
||||||
|
* 它本來就是「沿用既有資源」,而且呼叫端現有的 `origin === 'adopted' / 'created'` 統計
|
||||||
|
* (安裝器那句「沿用你原本的 N 項資源」)不會因為多一種值就悄悄漏數。
|
||||||
* @property {string} [from]
|
* @property {string} [from]
|
||||||
|
* @property {boolean} [reclaimed] 見 PlannedAdopt.reclaimed(Arcrun#123)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,19 +273,25 @@ export async function planResources(api, requirements, mode) {
|
|||||||
else byKey.set(key, [req]);
|
else byKey.set(key, [req]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {Map<ResourceKind, Set<string>>} */
|
// 帳號上現有的資源,一種只查一次。**名字 → 身分**(KV/D1 是 id,Vectorize 的身分就是名字)。
|
||||||
|
//
|
||||||
|
// 為什麼連名字都收下來(本來只留 `.values()`):
|
||||||
|
// · 2b 要問的是「這顆綁著的資源還在不在」→ 只需要 values(身分)。
|
||||||
|
// · 2c 要問的是「這個**名字**是不是已經被佔走了」→ 需要 key。
|
||||||
|
// 同一份 API 回應裡兩個問題都答得出來,不必多打一次。
|
||||||
|
/** @type {Map<ResourceKind, Map<string, string>>} */
|
||||||
const existingCache = new Map();
|
const existingCache = new Map();
|
||||||
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
|
/** @param {ResourceKind} kind @returns {Promise<Map<string, string>>} */
|
||||||
const listExisting = async (kind) => {
|
const listExistingByName = async (kind) => {
|
||||||
const hit = existingCache.get(kind);
|
const hit = existingCache.get(kind);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
/** @type {Set<string>} */
|
/** @type {Map<string, string>} */
|
||||||
let set;
|
let map;
|
||||||
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
|
if (kind === 'kv_namespace') map = await api.listKvNamespaces();
|
||||||
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
|
else if (kind === 'd1') map = await api.listD1Databases();
|
||||||
else set = new Set(await api.listVectorizeIndexes());
|
else map = new Map((await api.listVectorizeIndexes()).map((n) => [n, n]));
|
||||||
existingCache.set(kind, set);
|
existingCache.set(kind, map);
|
||||||
return set;
|
return map;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [, reqs] of byKey) {
|
for (const [, reqs] of byKey) {
|
||||||
@@ -291,10 +319,10 @@ export async function planResources(api, requirements, mode) {
|
|||||||
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
|
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
|
||||||
if (distinct.length === 1) {
|
if (distinct.length === 1) {
|
||||||
const value = distinct[0];
|
const value = distinct[0];
|
||||||
/** @type {Set<string>} */
|
/** @type {Map<string, string>} */
|
||||||
let existing;
|
let existing;
|
||||||
try {
|
try {
|
||||||
existing = await listExisting(kind);
|
existing = await listExistingByName(kind);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
blockers.push(
|
blockers.push(
|
||||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
||||||
@@ -302,7 +330,7 @@ export async function planResources(api, requirements, mode) {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!existing.has(value)) {
|
if (![...existing.values()].includes(value)) {
|
||||||
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
|
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
|
||||||
blockers.push(
|
blockers.push(
|
||||||
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value},` +
|
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value},` +
|
||||||
@@ -316,12 +344,62 @@ export async function planResources(api, requirements, mode) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
|
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding、全新帳號,
|
||||||
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
|
// **或者上一次安裝建到一半死掉**(Arcrun#123)。
|
||||||
|
//
|
||||||
|
// 🔴 原本這裡直接 `create.push()`,理由寫「本來就沒有東西可丟」。**那句話漏了一種狀態**:
|
||||||
|
// 資源已經建在帳號上、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。那個當下:
|
||||||
|
// 名字已存在 ✅ / 有 worker 綁著 ❌ ⇒ 舊邏輯判「可以新建」⇒ CF 回
|
||||||
|
// `a namespace with this account ID and title already exists` ⇒ **這個帳號從此裝不起來**。
|
||||||
|
// 封測者 1.4.45 實撞;youlin 拆除時也親眼看到 8 顆「一個 worker 都沒裝出來就被砍」的空殼。
|
||||||
|
//
|
||||||
|
// ⚠️ **這不是把 Arcrun#97 刪掉的 `ensureKvNamespace` 搬回來**,兩者差在三個地方:
|
||||||
|
// ① #97 是「照名字找 → **找不到就新建一顆頂上去**」;這裡是「照名字找 →
|
||||||
|
// **找到才沿用那一顆,找不到就照舊新建**」。**永遠不會拿新的空資源去頂替既有的**
|
||||||
|
// ——會弄丟資料的是那個動作,不是這個。
|
||||||
|
// ② #97 的比對凌駕於「worker 綁著誰」之上;這裡在 2b 之後,**已部署的綁定仍然絕對優先**,
|
||||||
|
// 只有在「確定沒有任何 worker 綁過它」時才輪得到名字說話。
|
||||||
|
// ③ #97 無條件相信名字;這裡要呼叫端**先聲明這個名字推導自使用者自己的身分**
|
||||||
|
// (`createNameIsOurs`),沒聲明就停手。
|
||||||
|
/** @type {Map<string, string>} */
|
||||||
|
let existingByName;
|
||||||
|
try {
|
||||||
|
existingByName = await listExistingByName(kind);
|
||||||
|
} catch (e) {
|
||||||
|
blockers.push(
|
||||||
|
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${reqs[0].createName}」這個名字是不是已經被用掉了` +
|
||||||
|
`(${msg(e)})。不確定就不建——停手。`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createName = reqs[0].createName;
|
||||||
|
const sameName = existingByName.get(createName);
|
||||||
|
if (sameName !== undefined) {
|
||||||
|
if (!reqs[0].createNameIsOurs) {
|
||||||
|
// 名字被佔走,而呼叫端證明不了那顆是我們的 ⇒ 接管它可能蓋掉使用者自己的東西。
|
||||||
|
// #97 的反向災情(安靜地接管一顆別人的)跟正向一樣糟 ⇒ fail-closed。
|
||||||
|
// 訊息不准叫使用者自己去 Cloudflare 後台動手(#121/D88:機器做得到的事不要丟回給人)。
|
||||||
|
blockers.push(
|
||||||
|
`你的 Cloudflare 帳號上已經有一個叫「${createName}」的 ${KIND_LABEL[kind]},` +
|
||||||
|
`但沒有任何 worker 綁著它,我也無法證明那顆是這次安裝建的。` +
|
||||||
|
`直接拿來用有可能蓋掉你自己的東西,所以停手了——沒有建立或改動任何資源。` +
|
||||||
|
`請把這則訊息回報給我們(錯誤碼 RES-NAME-TAKEN/${kind}/${binding}),這需要我們處理。`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 名字是我們替這台實例算出來的(見 createNameIsOurs 的推導條件)⇒ 這顆只可能是
|
||||||
|
// 我們上一次沒裝完留下的。沿用它=把上次做到一半的進度接回來,**不會有任何損失**:
|
||||||
|
// · 它若是空的(最常見)→ 等同於新建一顆,只是省下 CF 那個「名字已存在」的拒絕。
|
||||||
|
// · 它若有資料(更早裝過、後來 worker 被拆掉)→ 沿用正是把使用者的東西接回來。
|
||||||
|
adopt.push({ kind, binding, value: sameName, from: '', reclaimed: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
create.push({
|
create.push({
|
||||||
kind,
|
kind,
|
||||||
binding,
|
binding,
|
||||||
createName: reqs[0].createName,
|
createName,
|
||||||
wantedBy: [...new Set(reqs.map((r) => r.worker))],
|
wantedBy: [...new Set(reqs.map((r) => r.worker))],
|
||||||
alsoBind: [],
|
alsoBind: [],
|
||||||
});
|
});
|
||||||
@@ -401,6 +479,7 @@ export async function applyResourcePlan(api, plan) {
|
|||||||
value: a.value,
|
value: a.value,
|
||||||
origin: 'adopted',
|
origin: 'adopted',
|
||||||
from: a.from,
|
from: a.from,
|
||||||
|
...(a.reclaimed ? { reclaimed: true } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
|
|||||||
@@ -135,6 +135,13 @@ class FakeCloudflare implements ResourceApi {
|
|||||||
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
|
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
|
||||||
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
|
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
|
||||||
async createKvNamespace(title: string): Promise<string> {
|
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}`;
|
const id = `NEW-kvid-${this.createdKv.length}`;
|
||||||
this.kv.set(title, id);
|
this.kv.set(title, id);
|
||||||
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
|
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
|
||||||
@@ -486,6 +493,100 @@ test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某
|
|||||||
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
|
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()(走 toml,createName 是裸 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 後台處理(#121/D88)');
|
||||||
|
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.getScriptBindings:404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
|
test('CfAccountClient.getScriptBindings:404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
|
||||||
const orig = globalThis.fetch;
|
const orig = globalThis.fetch;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,6 +20,27 @@
|
|||||||
`planResources()`(不寫入,只出計畫)與 `applyResourcePlan()`(有 blocker 就拒絕執行)分兩段,
|
`planResources()`(不寫入,只出計畫)與 `applyResourcePlan()`(有 blocker 就拒絕執行)分兩段,
|
||||||
所以「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,不是靠誰記得寫 early return。
|
所以「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,不是靠誰記得寫 early return。
|
||||||
|
|
||||||
|
### 1.1 第 2 條的例外:上一次裝到一半死掉(Arcrun#123)
|
||||||
|
|
||||||
|
「沒有人綁過它」有**兩種**成因,第 2 條原本只想到第一種:
|
||||||
|
|
||||||
|
| | 名字在帳號上 | 有 worker 綁著 | 該怎麼做 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 新版本新增的 binding/全新帳號 | ❌ | ❌ | 新建(照舊) |
|
||||||
|
| **上次安裝建到一半就中斷** | ✅ | ❌ | **接回那一顆**(否則 CF 回「title already exists」,這個帳號**永遠裝不起來**) |
|
||||||
|
|
||||||
|
接管同名資源的**唯一**依據是呼叫端在 `BindingRequirement` 上聲明 `createNameIsOurs: true`,
|
||||||
|
意思是「這個名字是我用**使用者自己的身分**可重現地算出來的」——安裝器的
|
||||||
|
`arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 合格;`acr` 從 wrangler.toml 讀到的裸 binding 名
|
||||||
|
(`WEBHOOKS`)**不合格**,因為使用者自己也可能拿那個名字去建東西。
|
||||||
|
|
||||||
|
沒聲明就撞名 ⇒ **停手**(訊息帶 `RES-NAME-TAKEN` 錯誤碼讓使用者回報,
|
||||||
|
**不叫他自己去 Cloudflare 後台動手**)。
|
||||||
|
|
||||||
|
🔴 這**不是**把 #97 刪掉的「照名字 ensure」搬回來。差別:#97 是**找不到就新建一顆頂上去**
|
||||||
|
(會把活著的實例洗成空的);這裡是**找到才沿用、找不到才照舊新建**,而且排在
|
||||||
|
「已部署的綁定=事實」之後——名字永遠只在「確定沒有任何綁定可看」時才有發言權。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 為什麼在這裡,不在 cypher-executor 的 API
|
## 2. 為什麼在這裡,不在 cypher-executor 的 API
|
||||||
|
|||||||
@@ -106,6 +106,19 @@
|
|||||||
* @property {string} binding
|
* @property {string} binding
|
||||||
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
|
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
|
||||||
* @property {string} createName
|
* @property {string} createName
|
||||||
|
* @property {boolean} [createNameIsOurs]
|
||||||
|
* 呼叫端在此**聲明**:`createName` 是我們自己用可重現的方式替**這一台實例**算出來的名字
|
||||||
|
* ⇒ 帳號上若已經有一顆**恰好同名**的資源,它只可能是我們上一次沒裝完留下的(Arcrun#123)。
|
||||||
|
*
|
||||||
|
* 🔴 這個聲明是「接管同名資源」的**唯一**依據,預設 false(fail-closed)。
|
||||||
|
* 只有在名字**推導得出、而且推導的輸入是使用者自己的身分**時才准聲明 true——
|
||||||
|
* 安裝器的 `arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 就是這種
|
||||||
|
* (slug = `SHA-256('arcrun-rag:' + email)` 取前 8 碼,同一個 email 每次算出同一組名字,
|
||||||
|
* 別人算不到、也不會不小心撞上)。
|
||||||
|
*
|
||||||
|
* ⚠️ **不准**因為「名字看起來像我們的」就聲明 true。`acr` 那條從 wrangler.toml 讀到的
|
||||||
|
* createName 是裸 binding 名(`WEBHOOKS`)或 toml 宣告的庫名(`arcrun-kbdb`)——
|
||||||
|
* 那種名字使用者自己也可能拿去用,**證明不了是我們的**,所以那條路一律不聲明。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,7 +126,12 @@
|
|||||||
* @property {ResourceKind} kind
|
* @property {ResourceKind} kind
|
||||||
* @property {string} binding
|
* @property {string} binding
|
||||||
* @property {string} value
|
* @property {string} value
|
||||||
* @property {string} from 從哪顆已部署的 worker 上讀到的
|
* @property {string} from 從哪顆已部署的 worker 上讀到的。`reclaimed` 時為空字串——
|
||||||
|
* **沒有任何 worker 綁著它正是接收它的前提**(Arcrun#123),不是漏填。
|
||||||
|
* @property {boolean} [reclaimed]
|
||||||
|
* true = 這顆不是從某顆 worker 的綁定讀出來的,而是「帳號上已經有一顆我們自己命名的同名資源、
|
||||||
|
* 卻沒有人綁著」⇒ 上一次沒裝完留下的,這次把它接回來用(Arcrun#123)。
|
||||||
|
* 給呼叫端做診斷/統計用;**使用者不必知道「殘骸」這個詞**,對外一律講「沿用你原本的資源」。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,7 +162,11 @@
|
|||||||
* @property {string} binding
|
* @property {string} binding
|
||||||
* @property {string} value
|
* @property {string} value
|
||||||
* @property {'adopted' | 'created'} origin
|
* @property {'adopted' | 'created'} origin
|
||||||
|
* 🔴 接回上次沒裝完留下的那顆(`reclaimed`)**仍然算 `adopted`**,不另開第三種值——
|
||||||
|
* 它本來就是「沿用既有資源」,而且呼叫端現有的 `origin === 'adopted' / 'created'` 統計
|
||||||
|
* (安裝器那句「沿用你原本的 N 項資源」)不會因為多一種值就悄悄漏數。
|
||||||
* @property {string} [from]
|
* @property {string} [from]
|
||||||
|
* @property {boolean} [reclaimed] 見 PlannedAdopt.reclaimed(Arcrun#123)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,19 +273,25 @@ export async function planResources(api, requirements, mode) {
|
|||||||
else byKey.set(key, [req]);
|
else byKey.set(key, [req]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {Map<ResourceKind, Set<string>>} */
|
// 帳號上現有的資源,一種只查一次。**名字 → 身分**(KV/D1 是 id,Vectorize 的身分就是名字)。
|
||||||
|
//
|
||||||
|
// 為什麼連名字都收下來(本來只留 `.values()`):
|
||||||
|
// · 2b 要問的是「這顆綁著的資源還在不在」→ 只需要 values(身分)。
|
||||||
|
// · 2c 要問的是「這個**名字**是不是已經被佔走了」→ 需要 key。
|
||||||
|
// 同一份 API 回應裡兩個問題都答得出來,不必多打一次。
|
||||||
|
/** @type {Map<ResourceKind, Map<string, string>>} */
|
||||||
const existingCache = new Map();
|
const existingCache = new Map();
|
||||||
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
|
/** @param {ResourceKind} kind @returns {Promise<Map<string, string>>} */
|
||||||
const listExisting = async (kind) => {
|
const listExistingByName = async (kind) => {
|
||||||
const hit = existingCache.get(kind);
|
const hit = existingCache.get(kind);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
/** @type {Set<string>} */
|
/** @type {Map<string, string>} */
|
||||||
let set;
|
let map;
|
||||||
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
|
if (kind === 'kv_namespace') map = await api.listKvNamespaces();
|
||||||
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
|
else if (kind === 'd1') map = await api.listD1Databases();
|
||||||
else set = new Set(await api.listVectorizeIndexes());
|
else map = new Map((await api.listVectorizeIndexes()).map((n) => [n, n]));
|
||||||
existingCache.set(kind, set);
|
existingCache.set(kind, map);
|
||||||
return set;
|
return map;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [, reqs] of byKey) {
|
for (const [, reqs] of byKey) {
|
||||||
@@ -291,10 +319,10 @@ export async function planResources(api, requirements, mode) {
|
|||||||
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
|
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
|
||||||
if (distinct.length === 1) {
|
if (distinct.length === 1) {
|
||||||
const value = distinct[0];
|
const value = distinct[0];
|
||||||
/** @type {Set<string>} */
|
/** @type {Map<string, string>} */
|
||||||
let existing;
|
let existing;
|
||||||
try {
|
try {
|
||||||
existing = await listExisting(kind);
|
existing = await listExistingByName(kind);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
blockers.push(
|
blockers.push(
|
||||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
||||||
@@ -302,7 +330,7 @@ export async function planResources(api, requirements, mode) {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!existing.has(value)) {
|
if (![...existing.values()].includes(value)) {
|
||||||
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
|
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
|
||||||
blockers.push(
|
blockers.push(
|
||||||
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value},` +
|
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value},` +
|
||||||
@@ -316,12 +344,62 @@ export async function planResources(api, requirements, mode) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
|
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding、全新帳號,
|
||||||
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
|
// **或者上一次安裝建到一半死掉**(Arcrun#123)。
|
||||||
|
//
|
||||||
|
// 🔴 原本這裡直接 `create.push()`,理由寫「本來就沒有東西可丟」。**那句話漏了一種狀態**:
|
||||||
|
// 資源已經建在帳號上、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。那個當下:
|
||||||
|
// 名字已存在 ✅ / 有 worker 綁著 ❌ ⇒ 舊邏輯判「可以新建」⇒ CF 回
|
||||||
|
// `a namespace with this account ID and title already exists` ⇒ **這個帳號從此裝不起來**。
|
||||||
|
// 封測者 1.4.45 實撞;youlin 拆除時也親眼看到 8 顆「一個 worker 都沒裝出來就被砍」的空殼。
|
||||||
|
//
|
||||||
|
// ⚠️ **這不是把 Arcrun#97 刪掉的 `ensureKvNamespace` 搬回來**,兩者差在三個地方:
|
||||||
|
// ① #97 是「照名字找 → **找不到就新建一顆頂上去**」;這裡是「照名字找 →
|
||||||
|
// **找到才沿用那一顆,找不到就照舊新建**」。**永遠不會拿新的空資源去頂替既有的**
|
||||||
|
// ——會弄丟資料的是那個動作,不是這個。
|
||||||
|
// ② #97 的比對凌駕於「worker 綁著誰」之上;這裡在 2b 之後,**已部署的綁定仍然絕對優先**,
|
||||||
|
// 只有在「確定沒有任何 worker 綁過它」時才輪得到名字說話。
|
||||||
|
// ③ #97 無條件相信名字;這裡要呼叫端**先聲明這個名字推導自使用者自己的身分**
|
||||||
|
// (`createNameIsOurs`),沒聲明就停手。
|
||||||
|
/** @type {Map<string, string>} */
|
||||||
|
let existingByName;
|
||||||
|
try {
|
||||||
|
existingByName = await listExistingByName(kind);
|
||||||
|
} catch (e) {
|
||||||
|
blockers.push(
|
||||||
|
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${reqs[0].createName}」這個名字是不是已經被用掉了` +
|
||||||
|
`(${msg(e)})。不確定就不建——停手。`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createName = reqs[0].createName;
|
||||||
|
const sameName = existingByName.get(createName);
|
||||||
|
if (sameName !== undefined) {
|
||||||
|
if (!reqs[0].createNameIsOurs) {
|
||||||
|
// 名字被佔走,而呼叫端證明不了那顆是我們的 ⇒ 接管它可能蓋掉使用者自己的東西。
|
||||||
|
// #97 的反向災情(安靜地接管一顆別人的)跟正向一樣糟 ⇒ fail-closed。
|
||||||
|
// 訊息不准叫使用者自己去 Cloudflare 後台動手(#121/D88:機器做得到的事不要丟回給人)。
|
||||||
|
blockers.push(
|
||||||
|
`你的 Cloudflare 帳號上已經有一個叫「${createName}」的 ${KIND_LABEL[kind]},` +
|
||||||
|
`但沒有任何 worker 綁著它,我也無法證明那顆是這次安裝建的。` +
|
||||||
|
`直接拿來用有可能蓋掉你自己的東西,所以停手了——沒有建立或改動任何資源。` +
|
||||||
|
`請把這則訊息回報給我們(錯誤碼 RES-NAME-TAKEN/${kind}/${binding}),這需要我們處理。`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 名字是我們替這台實例算出來的(見 createNameIsOurs 的推導條件)⇒ 這顆只可能是
|
||||||
|
// 我們上一次沒裝完留下的。沿用它=把上次做到一半的進度接回來,**不會有任何損失**:
|
||||||
|
// · 它若是空的(最常見)→ 等同於新建一顆,只是省下 CF 那個「名字已存在」的拒絕。
|
||||||
|
// · 它若有資料(更早裝過、後來 worker 被拆掉)→ 沿用正是把使用者的東西接回來。
|
||||||
|
adopt.push({ kind, binding, value: sameName, from: '', reclaimed: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
create.push({
|
create.push({
|
||||||
kind,
|
kind,
|
||||||
binding,
|
binding,
|
||||||
createName: reqs[0].createName,
|
createName,
|
||||||
wantedBy: [...new Set(reqs.map((r) => r.worker))],
|
wantedBy: [...new Set(reqs.map((r) => r.worker))],
|
||||||
alsoBind: [],
|
alsoBind: [],
|
||||||
});
|
});
|
||||||
@@ -401,6 +479,7 @@ export async function applyResourcePlan(api, plan) {
|
|||||||
value: a.value,
|
value: a.value,
|
||||||
origin: 'adopted',
|
origin: 'adopted',
|
||||||
from: a.from,
|
from: a.from,
|
||||||
|
...(a.reclaimed ? { reclaimed: true } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
|
|||||||
@@ -45,14 +45,21 @@ export function requirements() {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 安裝器替這台實例算出來的名字前綴(`arcrun-rag-<slugFromEmail(email)>`)。 */
|
||||||
|
export const BASE_NAME = 'arcrun-rag-yuga3bse';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 三種情境。`titleFor` 決定「使用者帳號上那顆資源實際叫什麼名字」——
|
* 四種情境。`titleFor` 決定「使用者帳號上那顆資源實際叫什麼名字」——
|
||||||
* 這正是 #97 的病根所在:規則**不准**拿名字當識別。
|
* 這正是 #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 = {
|
export const SCENARIOS = {
|
||||||
fresh: {
|
fresh: {
|
||||||
label: '沒裝過(全新帳號,一顆 worker 都沒有)',
|
label: '沒裝過(全新帳號,一顆 worker 都沒有)',
|
||||||
@@ -62,7 +69,7 @@ export const SCENARIOS = {
|
|||||||
installed: {
|
installed: {
|
||||||
label: '裝過了(安裝器命名慣例 arcrun-rag-<instance>-kv-<binding>)',
|
label: '裝過了(安裝器命名慣例 arcrun-rag-<instance>-kv-<binding>)',
|
||||||
deployed: true,
|
deployed: true,
|
||||||
titleFor: (b) => `arcrun-rag-yuga3bse-kv-${b.toLowerCase()}`,
|
titleFor: (b) => `${BASE_NAME}-kv-${b.toLowerCase()}`,
|
||||||
},
|
},
|
||||||
renamed: {
|
renamed: {
|
||||||
label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)',
|
label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)',
|
||||||
@@ -70,8 +77,43 @@ export const SCENARIOS = {
|
|||||||
// 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。
|
// 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。
|
||||||
titleFor: (b) => `kv-${[...b].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`,
|
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.toml,createName 是裸 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` 替身。
|
* 建一個假帳號 + 對應的 `fetch` 替身。
|
||||||
*
|
*
|
||||||
@@ -109,15 +151,18 @@ export function makeAccount(scenario) {
|
|||||||
const kvIdByBinding = new Map();
|
const kvIdByBinding = new Map();
|
||||||
const D1_ID = 'd1id-kbdb-REAL';
|
const D1_ID = 'd1id-kbdb-REAL';
|
||||||
|
|
||||||
if (spec.deployed) {
|
// 資源存不存在,與 worker 部署了沒,是**兩件事**(#123:中斷的安裝會讓前者為真、後者為假)。
|
||||||
|
if (spec.resourcesExist ?? spec.deployed) {
|
||||||
// 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分)
|
// 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分)
|
||||||
for (const b of KV_BINDINGS) {
|
for (const b of KV_BINDINGS) {
|
||||||
const id = `kvid-${b.toLowerCase()}-REAL`;
|
const id = `kvid-${b.toLowerCase()}-REAL`;
|
||||||
kv.set(spec.titleFor(b), id);
|
kv.set(spec.titleFor(b), id);
|
||||||
kvIdByBinding.set(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 上綁著它們——**這才是規則要看的事實**
|
// 已部署的 worker 上綁著它們——**這才是規則要看的事實**
|
||||||
for (const [script, need] of Object.entries(WORKER_NEEDS)) {
|
for (const [script, need] of Object.entries(WORKER_NEEDS)) {
|
||||||
const bindings = [];
|
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 後台、不准叫他跑指令、不准要他懂 namespace/binding。
|
||||||
|
*
|
||||||
|
* 【為什麼這個狀態逃過了 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 binding(CREDENTIALS_DB/DB,兩個指向同一顆庫)。
|
||||||
|
// 這裡數的是**綁定**,不是資源顆數——底下那條 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);
|
||||||
Reference in New Issue
Block a user