3f2e45f5dc
封測者 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
636 lines
33 KiB
TypeScript
636 lines
33 KiB
TypeScript
/**
|
||
* Arcrun#97 迴歸守衛 —— 「跑完更新,使用者的東西還在原地」
|
||
*
|
||
* 2026-08-12 實害:leo 跑了一次例行更新,跑完工作流一支都沒有、portal 把他登出、
|
||
* 總圖是空的、子庫全不見。資料沒被刪,但 worker 被重新綁到 9 顆新建的空 KV + 1 顆空 D1 上。
|
||
*
|
||
* 根因:更新「照名字」確保資源存在——拿 binding 名(WEBHOOKS)當 CF 上的資源標題去找,
|
||
* 安裝器建的資源叫 `arcrun-rag-<instance>-kv-webhooks`,永遠對不上 ⇒ 每次更新都新建一顆綁上去。
|
||
*
|
||
* 這份測試用一台**照安裝器命名慣例**的假實例(不是 leo 的實例,不碰 leo21c)跑真正的
|
||
* 解析程式碼(planResources / applyResourcePlan / renderWranglerToml),對照更新前後:
|
||
* ① 工作流數、登入狀態、子庫數 ② 帳號上的資源顆數 ③ 找不到既有資源時要停手
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import {
|
||
planResources,
|
||
applyResourcePlan,
|
||
parseWranglerRequirements,
|
||
bindingKey,
|
||
ResourcePlanBlocked,
|
||
type BindingRequirement,
|
||
type ResourceApi,
|
||
type ScriptBindings,
|
||
type LiveBinding,
|
||
type ResolvedResource,
|
||
} from '../src/lib/resource-resolver.ts';
|
||
import {
|
||
renderWranglerToml,
|
||
REQUIRED_KV_NAMESPACES,
|
||
type DeployContext,
|
||
} from '../src/lib/deploy.ts';
|
||
import { CfAccountClient } from '../src/lib/cf-api.ts';
|
||
|
||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||
|
||
/** 這台假實例上跑著的四顆 worker(有資源綁定的那幾顆)。 */
|
||
const WORKER_TOMLS = [
|
||
'cypher-executor/wrangler.toml',
|
||
'registry/wrangler.toml',
|
||
'mcp/wrangler.toml',
|
||
'kbdb/wrangler.toml',
|
||
];
|
||
|
||
const CTX: DeployContext = {
|
||
accountId: 'acct-test',
|
||
apiToken: 'tok-test',
|
||
workerSubdomain: 'yuga3bse',
|
||
selfHosted: true,
|
||
kbdbEmbed: true,
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 假的 Cloudflare 帳號:完全照「安裝器裝出來」的樣子命名
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const INSTANCE = 'yuga3bse';
|
||
|
||
interface FakeOpts {
|
||
/** 讓某顆 worker 的綁定讀取失敗(模擬 API 掛掉 / 權限不足)。 */
|
||
failBindingsFor?: string;
|
||
/** 從帳號上「弄不見」某顆 KV,但 worker 上還綁著它(模擬資源被刪)。 */
|
||
deleteKvTitle?: string;
|
||
/** 完全沒有任何已部署的 worker(模擬名字對不上 / token 看錯帳號)。 */
|
||
nothingDeployed?: boolean;
|
||
}
|
||
|
||
class FakeCloudflare implements ResourceApi {
|
||
/** title → id */
|
||
kv = new Map<string, string>();
|
||
/** name → uuid */
|
||
d1 = new Map<string, string>();
|
||
vectorize: string[] = [];
|
||
/** script → bindings */
|
||
scripts = new Map<string, LiveBinding[]>();
|
||
|
||
/** 使用者的東西:kvId → (key → value) */
|
||
kvData = new Map<string, Map<string, string>>();
|
||
/** d1Id → 子庫名單 */
|
||
d1Libraries = new Map<string, string[]>();
|
||
|
||
/** 這趟總共建立了什麼(驗「顆數不增加」用)。 */
|
||
createdKv: string[] = [];
|
||
createdD1: string[] = [];
|
||
createdVectorize: string[] = [];
|
||
|
||
constructor(private opts: FakeOpts = {}) {
|
||
// 安裝器的命名慣例:arcrun-rag-<instance>-kv-<binding 小寫>
|
||
for (const binding of REQUIRED_KV_NAMESPACES) {
|
||
const title = `arcrun-rag-${INSTANCE}-kv-${binding.toLowerCase()}`;
|
||
const id = `kvid-${binding.toLowerCase()}`;
|
||
this.kv.set(title, id);
|
||
this.kvData.set(id, new Map());
|
||
}
|
||
this.d1.set(`arcrun-rag-${INSTANCE}-kbdb`, 'd1id-kbdb');
|
||
this.vectorize.push(`arcrun-rag-${INSTANCE}-embed`);
|
||
|
||
// 使用者的東西
|
||
this.kvData.get('kvid-webhooks')!.set('webhook:leo:daily-digest', '{}');
|
||
this.kvData.get('kvid-webhooks')!.set('webhook:leo:inbox-sync', '{}');
|
||
this.kvData.get('kvid-webhooks')!.set('webhook:leo:rag-ingest', '{}');
|
||
this.kvData.get('kvid-sessions_kv')!.set('session:leo-abc123', '{"user":"leo"}');
|
||
this.d1Libraries.set('d1id-kbdb', ['general', '課程', '客戶', '研究']);
|
||
|
||
if (!opts.nothingDeployed) {
|
||
const kvB = (b: string): LiveBinding =>
|
||
({ kind: 'kv_namespace', binding: b, value: `kvid-${b.toLowerCase()}` });
|
||
this.scripts.set('arcrun-cypher-executor', [
|
||
kvB('EXEC_CONTEXT'), kvB('WEBHOOKS'), kvB('CREDENTIALS_KV'), kvB('ANALYTICS_KV'),
|
||
kvB('RECIPES'), kvB('USERS_KV'), kvB('SESSIONS_KV'),
|
||
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1id-kbdb' },
|
||
]);
|
||
this.scripts.set('arcrun-registry', [kvB('SUBMISSIONS_KV'), kvB('ANALYTICS_KV')]);
|
||
this.scripts.set('arcrun-mcp', [kvB('OAUTH_KV')]);
|
||
this.scripts.set('arcrun-kbdb', [
|
||
{ kind: 'd1', binding: 'DB', value: 'd1id-kbdb' },
|
||
{ kind: 'vectorize', binding: 'VECTORIZE', value: `arcrun-rag-${INSTANCE}-embed` },
|
||
]);
|
||
}
|
||
|
||
if (opts.deleteKvTitle) this.kv.delete(opts.deleteKvTitle);
|
||
}
|
||
|
||
async getScriptBindings(script: string): Promise<ScriptBindings> {
|
||
if (this.opts.failBindingsFor === script) throw new Error('HTTP 500 (CF API 暫時掛掉)');
|
||
const b = this.scripts.get(script);
|
||
return b ? { deployed: true, bindings: b } : { deployed: false, bindings: [] };
|
||
}
|
||
async listKvNamespaces(): Promise<Map<string, string>> { return new Map(this.kv); }
|
||
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()); // 新建的是**空的**——災情就是綁到這種東西上
|
||
this.createdKv.push(title);
|
||
return id;
|
||
}
|
||
async createD1Database(name: string): Promise<string> {
|
||
const id = `NEW-d1id-${this.createdD1.length}`;
|
||
this.d1.set(name, id);
|
||
this.d1Libraries.set(id, []);
|
||
this.createdD1.push(name);
|
||
return id;
|
||
}
|
||
async createVectorizeIndex(name: string): Promise<string> {
|
||
this.vectorize.push(name);
|
||
this.createdVectorize.push(name);
|
||
return name;
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 共用:從真的 wrangler.toml 解析需求(走與 downloadAndDeploy 相同的路徑)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
function collectRequirements(): { requirements: BindingRequirement[]; tomls: Map<string, string> } {
|
||
const requirements: BindingRequirement[] = [];
|
||
const tomls = new Map<string, string>();
|
||
for (const rel of WORKER_TOMLS) {
|
||
const raw = readFileSync(join(REPO, rel), 'utf8');
|
||
tomls.set(rel, raw);
|
||
const parsed = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
|
||
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
|
||
}
|
||
return { requirements, tomls };
|
||
}
|
||
|
||
/** 模擬「部署」:把解析結果注入 toml,再從注入後的 toml 讀回 worker 實際會綁到的資源。 */
|
||
function deployAndReadBindings(
|
||
tomls: Map<string, string>,
|
||
resolved: Map<string, ResolvedResource>,
|
||
): Map<string, Map<string, string>> {
|
||
const out = new Map<string, Map<string, string>>();
|
||
for (const [rel, raw] of tomls) {
|
||
const rendered = renderWranglerToml(raw, CTX, resolved);
|
||
const script = parseWranglerRequirements(rendered).script;
|
||
const bound = new Map<string, string>();
|
||
let kind: string | null = null;
|
||
let binding = '';
|
||
for (const line of rendered.split('\n')) {
|
||
const t = line.trim();
|
||
if (t.startsWith('#')) continue;
|
||
const table = t.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
|
||
if (table) { kind = table[1]; binding = ''; continue; }
|
||
const m = t.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||
if (!m) continue;
|
||
if (m[1] === 'binding') binding = m[2];
|
||
else if (binding && (
|
||
(kind === 'kv_namespaces' && m[1] === 'id')
|
||
|| (kind === 'd1_databases' && m[1] === 'database_id')
|
||
|| (kind === 'vectorize' && m[1] === 'index_name')
|
||
)) bound.set(binding, m[2]);
|
||
}
|
||
out.set(script, bound);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// ① 更新前後:工作流數、登入狀態、子庫數 —— 三個都不能少
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('#97 ①:安裝器裝出來的實例跑更新——工作流/登入/子庫更新前後完全一致', async () => {
|
||
const cf = new FakeCloudflare();
|
||
const { requirements, tomls } = collectRequirements();
|
||
|
||
const before = {
|
||
workflows: cf.kvData.get('kvid-webhooks')!.size,
|
||
sessions: cf.kvData.get('kvid-sessions_kv')!.size,
|
||
libraries: cf.d1Libraries.get('d1id-kbdb')!.length,
|
||
};
|
||
assert.deepEqual(before, { workflows: 3, sessions: 1, libraries: 4 }, '前置資料要先擺好');
|
||
|
||
const plan = await planResources(cf, requirements, 'update');
|
||
assert.deepEqual(plan.blockers, [], '一台健康的實例不該有任何 blocker');
|
||
const resolved = await applyResourcePlan(cf, plan);
|
||
|
||
const bound = deployAndReadBindings(tomls, resolved);
|
||
|
||
// 更新後,worker 綁到的還是使用者原本那幾顆(名字完全沒對上,但那不重要)
|
||
const cypher = bound.get('arcrun-cypher-executor')!;
|
||
assert.equal(cypher.get('WEBHOOKS'), 'kvid-webhooks');
|
||
assert.equal(cypher.get('SESSIONS_KV'), 'kvid-sessions_kv');
|
||
assert.equal(cypher.get('CREDENTIALS_DB'), 'd1id-kbdb');
|
||
assert.equal(bound.get('arcrun-kbdb')!.get('DB'), 'd1id-kbdb');
|
||
assert.equal(bound.get('arcrun-mcp')!.get('OAUTH_KV'), 'kvid-oauth_kv');
|
||
assert.equal(bound.get('arcrun-registry')!.get('SUBMISSIONS_KV'), 'kvid-submissions_kv');
|
||
assert.equal(bound.get('arcrun-kbdb')!.get('VECTORIZE'), `arcrun-rag-${INSTANCE}-embed`);
|
||
|
||
const after = {
|
||
workflows: cf.kvData.get(cypher.get('WEBHOOKS')!)!.size,
|
||
sessions: cf.kvData.get(cypher.get('SESSIONS_KV')!)!.size,
|
||
libraries: cf.d1Libraries.get(bound.get('arcrun-kbdb')!.get('DB')!)!.length,
|
||
};
|
||
assert.deepEqual(after, before, '更新後使用者看到的東西必須跟更新前一模一樣');
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// ② 帳號上的資源顆數不增加(災情當天:9 顆 KV → 18 顆、1 顆 D1 → 2 顆)
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('#97 ②:更新不會在帳號上多生任何資源', async () => {
|
||
const cf = new FakeCloudflare();
|
||
const kvBefore = cf.kv.size;
|
||
const d1Before = cf.d1.size;
|
||
const vecBefore = cf.vectorize.length;
|
||
assert.deepEqual([kvBefore, d1Before, vecBefore], [9, 1, 1]);
|
||
|
||
const { requirements } = collectRequirements();
|
||
const plan = await planResources(cf, requirements, 'update');
|
||
await applyResourcePlan(cf, plan);
|
||
|
||
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
|
||
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
|
||
assert.deepEqual(cf.createdVectorize, [], '不該新建任何 Vectorize index');
|
||
assert.deepEqual([cf.kv.size, cf.d1.size, cf.vectorize.length], [9, 1, 1]);
|
||
});
|
||
|
||
test('#97 ②對照組:舊的「照名字 ensure」在同一台實例上會生 9 顆 KV + 1 顆 D1', async () => {
|
||
// 這段是**修好之前**的演算法(commit e69d6bb 時的 cli/src/commands/update.ts:52-68 與
|
||
// cf-api.ts 的 ensureKvNamespace/ensureD1Database),照原樣重寫在這裡當對照組。
|
||
// 目的:把「災情是怎麼發生的」釘成可執行的事實,而不是只留在 issue 的文字裡。
|
||
const cf = new FakeCloudflare();
|
||
const existing = await cf.listKvNamespaces();
|
||
for (const title of REQUIRED_KV_NAMESPACES) {
|
||
if (!existing.get(title)) await cf.createKvNamespace(title); // ← 名字對不上 ⇒ 每個都新建
|
||
}
|
||
const d1s = await cf.listD1Databases();
|
||
if (!d1s.get('arcrun-kbdb')) await cf.createD1Database('arcrun-kbdb');
|
||
|
||
assert.equal(cf.createdKv.length, 9, '舊做法:9 顆 KV 全部重建(對上災情當天的數字)');
|
||
assert.equal(cf.createdD1.length, 1, '舊做法:D1 也重建一顆');
|
||
assert.equal(cf.kv.size, 18, '9 → 18');
|
||
assert.equal(cf.d1.size, 2, '1 → 2');
|
||
// 而且新建的那幾顆是空的 —— 使用者的工作流就是這樣「不見」的
|
||
assert.equal(cf.kvData.get(cf.kv.get('WEBHOOKS')!)!.size, 0);
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// ③ 反向驗證:找不到既有資源 → 停下來說清楚,不是安靜新建一顆綁上去
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('#97 ③-a:worker 綁著的 KV 在帳號上不見了 → 停手,一顆都不建', async () => {
|
||
const cf = new FakeCloudflare({ deleteKvTitle: `arcrun-rag-${INSTANCE}-kv-webhooks` });
|
||
const { requirements } = collectRequirements();
|
||
|
||
const plan = await planResources(cf, requirements, 'update');
|
||
assert.ok(plan.blockers.length > 0, '要有 blocker');
|
||
const said = plan.blockers.join('\n');
|
||
assert.match(said, /WEBHOOKS/, '要指名是哪個綁定');
|
||
assert.match(said, /kvid-webhooks/, '要指名是哪一顆資源');
|
||
assert.match(said, /找不到/, '要說清楚發生什麼事');
|
||
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), '絕不能把它排進「要新建」');
|
||
|
||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||
assert.deepEqual(cf.createdKv, [], '被擋下時一顆資源都不能被建出來');
|
||
assert.deepEqual(cf.createdD1, []);
|
||
});
|
||
|
||
test('#97 ③-b:讀不到某顆 worker 現在綁什麼 → 當「我不知道」而不是「它沒有」', async () => {
|
||
const cf = new FakeCloudflare({ failBindingsFor: 'arcrun-cypher-executor' });
|
||
const { requirements } = collectRequirements();
|
||
|
||
const plan = await planResources(cf, requirements, 'update');
|
||
assert.match(plan.blockers.join('\n'), /arcrun-cypher-executor/);
|
||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||
assert.deepEqual(cf.createdKv, []);
|
||
});
|
||
|
||
test('#97 ③-c:update 卻一顆 worker 都找不到 → 停手,不當成全新安裝重建一整套', async () => {
|
||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||
const { requirements } = collectRequirements();
|
||
|
||
const plan = await planResources(cf, requirements, 'update');
|
||
assert.match(plan.blockers.join('\n'), /找不到任何一顆要更新的 worker/);
|
||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||
assert.deepEqual(cf.createdKv, []);
|
||
});
|
||
|
||
test('#97 ③-d:同一個 binding 在不同 worker 上指向不同資源 → 不猜,停手', async () => {
|
||
const cf = new FakeCloudflare();
|
||
// registry 的 ANALYTICS_KV 被指到別顆(真實情境:有人手動改過其中一邊)
|
||
cf.scripts.get('arcrun-registry')!.find((b) => b.binding === 'ANALYTICS_KV')!.value = 'kvid-other';
|
||
cf.kv.set('some-other-kv', 'kvid-other');
|
||
const { requirements } = collectRequirements();
|
||
|
||
const plan = await planResources(cf, requirements, 'update');
|
||
assert.match(plan.blockers.join('\n'), /ANALYTICS_KV/);
|
||
assert.deepEqual(cf.createdKv, []);
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// 合法的新建:只有「確定沒人綁過」時才准
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('#97:新版本新增的 binding(沒有任何已部署 worker 綁過)才准新建', async () => {
|
||
const cf = new FakeCloudflare();
|
||
cf.scripts.set('arcrun-mcp', []); // mcp 已部署,但還沒有 OAUTH_KV(舊版本裝的)
|
||
cf.kv.delete(`arcrun-rag-${INSTANCE}-kv-oauth_kv`);
|
||
const { requirements } = collectRequirements();
|
||
|
||
const plan = await planResources(cf, requirements, 'update');
|
||
assert.deepEqual(plan.blockers, []);
|
||
assert.deepEqual(plan.create.map((c) => c.binding), ['OAUTH_KV'], '只有這一個該建');
|
||
await applyResourcePlan(cf, plan);
|
||
assert.deepEqual(cf.createdKv, ['OAUTH_KV']);
|
||
assert.equal(cf.kv.size, 9, '刪掉一顆、補建一顆 → 還是 9 顆');
|
||
});
|
||
|
||
test('#97:全新帳號跑 init → 該建的都建(不會被 update 的停手規則卡住)', async () => {
|
||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0;
|
||
const { requirements } = collectRequirements();
|
||
|
||
const plan = await planResources(cf, requirements, 'init');
|
||
assert.deepEqual(plan.blockers, [], 'init 在空帳號上不該停手');
|
||
await applyResourcePlan(cf, plan);
|
||
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length);
|
||
assert.deepEqual(cf.createdD1, ['arcrun-kbdb']);
|
||
assert.equal(cf.createdVectorize.length, 1);
|
||
});
|
||
|
||
test('#97:一邊已部署一邊沒有 → 跟著沿用同一顆,不要另外建一顆空的', async () => {
|
||
const cf = new FakeCloudflare();
|
||
cf.scripts.delete('arcrun-cypher-executor'); // cypher 還沒部(kbdb 已部,DB → d1id-kbdb)
|
||
const { requirements, tomls } = collectRequirements();
|
||
|
||
const plan = await planResources(cf, requirements, 'update');
|
||
assert.deepEqual(plan.blockers, []);
|
||
assert.ok(!plan.create.some((c) => c.kind === 'd1'), 'CREDENTIALS_DB 不該被當成新資源建一顆');
|
||
|
||
const resolved = await applyResourcePlan(cf, plan);
|
||
const bound = deployAndReadBindings(tomls, resolved);
|
||
assert.equal(bound.get('arcrun-cypher-executor')!.get('CREDENTIALS_DB'), 'd1id-kbdb',
|
||
'credential 目錄要跟 KBDB 在同一顆庫');
|
||
assert.deepEqual(cf.createdD1, []);
|
||
});
|
||
|
||
test('#97:部署出去的 toml 不得殘留官方 prod 的資源 id(自架寫進官方庫 = 跨租戶外洩)', async () => {
|
||
// repo 的 toml 裡 database_id 預設是官方 prod D1。舊版在「D1 解析失敗」時只是把它跳過不注入,
|
||
// 於是自架用戶的 kbdb worker 就這樣綁著官方那顆庫部署出去。現在不是失敗就跳過,是整趟停手。
|
||
const cf = new FakeCloudflare();
|
||
const { requirements, tomls } = collectRequirements();
|
||
const resolved = await applyResourcePlan(cf, await planResources(cf, requirements, 'update'));
|
||
|
||
const OFFICIAL_D1 = '0c580910-e00b-4f8e-9c57-ac54ea52242f';
|
||
for (const [rel, raw] of tomls) {
|
||
const rendered = renderWranglerToml(raw, CTX, resolved);
|
||
assert.doesNotMatch(rendered, new RegExp(OFFICIAL_D1), `${rel} 還帶著官方 prod D1 的 id`);
|
||
assert.doesNotMatch(rendered, /REPLACE_WITH_REAL_KV_ID/, `${rel} 還留著占位 KV id`);
|
||
}
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// 做法本身的看守:不准再出現「照名字 ensure」這種原語
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('#97 紅線:cf-api 不得再提供任何「找不到同名就順手建一顆」的 ensure 原語', () => {
|
||
const src = readFileSync(join(REPO, 'cli/src/lib/cf-api.ts'), 'utf8');
|
||
assert.doesNotMatch(src, /\bensureKvNamespace\b|\bensureD1Database\b|\bensureVectorizeIndex\b/,
|
||
'ensure* 是 #97 的凶器:把「查不到」當成「不存在」再自作主張新建。'
|
||
+ '要建資源一律先過 resource-resolver 的 planResources。');
|
||
});
|
||
|
||
test('#97 紅線:只有 resource-resolver 能決定「要不要建」,指令層不得自己呼叫 create*', () => {
|
||
for (const rel of ['cli/src/commands/init.ts', 'cli/src/commands/update.ts']) {
|
||
const src = readFileSync(join(REPO, rel), 'utf8');
|
||
assert.doesNotMatch(src, /\.create(KvNamespace|D1Database|VectorizeIndex)\s*\(/,
|
||
`${rel} 不該自己建資源——那樣就繞過了「先看已部署的 worker 綁著什麼」這道判斷。`);
|
||
}
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// 底層零件
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('parseWranglerRequirements:讀得出 script 名與三種資源綁定,且不把註解掉的區塊當需求', () => {
|
||
const toml = [
|
||
'name = "arcrun-kbdb" # 註解不影響',
|
||
'',
|
||
'[[d1_databases]]',
|
||
'binding = "DB"',
|
||
'database_name = "arcrun-kbdb"',
|
||
'database_id = "placeholder"',
|
||
'',
|
||
'[vars]',
|
||
'ENVIRONMENT = "production"',
|
||
'',
|
||
'# [[vectorize]]',
|
||
'# binding = "VECTORIZE"',
|
||
'# index_name = "arcrun-kbdb-embed-m3"',
|
||
].join('\n');
|
||
const r = parseWranglerRequirements(toml);
|
||
assert.equal(r.script, 'arcrun-kbdb');
|
||
assert.deepEqual(r.bindings, [{ kind: 'd1', binding: 'DB', createName: 'arcrun-kbdb' }]);
|
||
});
|
||
|
||
test('KV 沒有 title 欄位 → 真要新建時用 binding 名', () => {
|
||
const r = parseWranglerRequirements('name = "w"\n[[kv_namespaces]]\nbinding = "WEBHOOKS"\nid = "x"');
|
||
assert.deepEqual(r.bindings, [{ kind: 'kv_namespace', binding: 'WEBHOOKS', createName: 'WEBHOOKS' }]);
|
||
});
|
||
|
||
test('注入是照 binding 對號,不是盲換「檔案裡第一個 database_id」', () => {
|
||
const cypher = readFileSync(join(REPO, 'cypher-executor/wrangler.toml'), 'utf8');
|
||
const resolved = new Map<string, ResolvedResource>([
|
||
[bindingKey('d1', 'CREDENTIALS_DB'), { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'MINE', origin: 'adopted' }],
|
||
[bindingKey('kv_namespace', 'WEBHOOKS'), { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'KV-MINE', origin: 'adopted' }],
|
||
]);
|
||
const out = renderWranglerToml(cypher, CTX, resolved);
|
||
const bound = parseWranglerRequirements(out);
|
||
assert.ok(bound.bindings.some((b) => b.binding === 'CREDENTIALS_DB'));
|
||
assert.match(out, /binding = "CREDENTIALS_DB"\ndatabase_name = "arcrun-kbdb"\ndatabase_id = "MINE"/);
|
||
assert.match(out, /binding = "WEBHOOKS"\nid = "KV-MINE"/);
|
||
// 沒被解析到的綁定不能被亂改(EXEC_CONTEXT 這次沒進 resolved)
|
||
assert.match(out, /binding = "EXEC_CONTEXT"\nid = "616967a852eb450a8c01731f71ac8edd"/);
|
||
});
|
||
|
||
test('renderWranglerToml 帶空 map = 預覽:解析看到的 binding 與注入後的完全一致', () => {
|
||
for (const rel of WORKER_TOMLS) {
|
||
const raw = readFileSync(join(REPO, rel), 'utf8');
|
||
const preview = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
|
||
const resolved = new Map<string, ResolvedResource>(
|
||
preview.bindings.map((b) => [
|
||
bindingKey(b.kind, b.binding),
|
||
{ kind: b.kind, binding: b.binding, value: `v-${b.binding}`, origin: 'adopted' as const },
|
||
]),
|
||
);
|
||
const after = parseWranglerRequirements(renderWranglerToml(raw, CTX, resolved));
|
||
assert.deepEqual(
|
||
after.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
|
||
preview.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
|
||
`${rel}: 預覽與實際注入看到的綁定必須一致`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某顆 worker 部署失敗)', () => {
|
||
const { requirements } = collectRequirements();
|
||
const kv = [...new Set(requirements.filter((r) => r.kind === 'kv_namespace').map((r) => r.binding))];
|
||
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 () => {
|
||
const orig = globalThis.fetch;
|
||
try {
|
||
globalThis.fetch = (async () =>
|
||
new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
|
||
) as typeof fetch;
|
||
const cf = new CfAccountClient('a', 't');
|
||
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [], vars: {} });
|
||
|
||
globalThis.fetch = (async () =>
|
||
new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
|
||
) as typeof fetch;
|
||
await assert.rejects(() => new CfAccountClient('a', 't').getScriptBindings('x'), /boom/);
|
||
} finally {
|
||
globalThis.fetch = orig;
|
||
}
|
||
});
|
||
|
||
test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize 三種綁定形狀', async () => {
|
||
const orig = globalThis.fetch;
|
||
try {
|
||
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||
success: true,
|
||
result: {
|
||
bindings: [
|
||
{ type: 'kv_namespace', name: 'WEBHOOKS', namespace_id: 'kv1' },
|
||
{ type: 'd1', name: 'DB', id: 'db1' },
|
||
{ type: 'vectorize', name: 'VECTORIZE', index_name: 'idx1' },
|
||
{ type: 'plain_text', name: 'ENVIRONMENT', text: 'production' },
|
||
{ type: 'service', name: 'SVC_SET', service: 'arcrun-set' },
|
||
],
|
||
},
|
||
}), { status: 200 })) as typeof fetch;
|
||
const res = await new CfAccountClient('a', 't').getScriptBindings('arcrun-cypher-executor');
|
||
assert.equal(res.deployed, true);
|
||
assert.deepEqual(res.bindings, [
|
||
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv1' },
|
||
{ kind: 'd1', binding: 'DB', value: 'db1' },
|
||
{ kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
|
||
]);
|
||
// #106:plain_text 也要收下來(service 這種不認得的仍略過)。
|
||
assert.deepEqual(res.vars, { ENVIRONMENT: 'production' });
|
||
} finally {
|
||
globalThis.fetch = orig;
|
||
}
|
||
});
|