53b05c6d3d
leo 08-12 實撞:更新完 leo21c,Portal 設定頁的「版本」變成 「無法讀取目前版本(知識庫服務可能正在啟動)」。版本號是 leo 唯一的驗收介面, 看不到就等於他無法自己確認任何一次更新有沒有生效。 根因(Arcrun#106):`bundle_version` 來自部署時注入的 plain_text var `ARCRUN_BUNDLE_VERSION`,而**只有安裝器會注入**。wrangler deploy 是整份覆蓋, toml 沒寫的 var 直接消失 ⇒ CLI 更新那條路每跑一次就把標籤洗掉一次。 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),沒修「櫃子上的標籤」。 修法(兩種 var 走相反的規則,這是本次的判斷): · 設定類 var = 使用者實例的事實 → **沿用**(讀綁定時同一份回應就帶回來,不多打 API) ——把 #97「已部署的 worker 上綁著什麼就是事實」原封不動套用到 plain_text var。 · 版本標籤 = 這份成品的屬性 → **每趟重烙,絕不沿用舊值**。 沿用舊值會得到一個永遠停在安裝當天的假標籤——比沒有標籤更糟, 因為它會讓人以為驗收過了。 版號取部署當下發行頻道公告的 release(Portal/daemon 就是拿它當「最新版」比), 另外把**真正部署的 commit** 一起烙上去(/health 多吐 `bundle_commit`)→ 漂掉查得出來。 查不到 release 就誠實退成 `YYYY-MM-DD+<commit7>`,不掰一個 semver 假裝已是最新。 順帶(都是同一條路上的東西): · ref 先解析成 commit sha 再用 sha 下載 archive——不可變,順手解掉 branch tarball 被快取的老病 · Portal 版本行接受帶 build metadata 的 semver(`1.4.41+d61` 這種先前一律被當成「較舊版本」) · cli 測試在 node 22 上本來一支都跑不起來(.js→.ts 解析 + parameter property),補上 resolve hook ——#97 那份「使用者的東西還在不在」的迴歸守衛也在其中,跑不起來的守衛等於沒有守衛 · types.ts 的 ARCRUN_BUNDLE_VERSION 重複宣告(TS2300)併回一處 驗證見 PR:cli 49/49 綠、cypher health 4/4 綠、Portal 版本行原始碼實跑五種情境、 對真實已部署 worker 的唯讀 dry-run。**未做**:真實實例上的 acr update 端到端 (本機唯一有憑證的帳號是 leo21c=紅線禁碰,youlin 無憑證)。 Refs: Leo/Arcrun#106, #97, #95 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
244 lines
14 KiB
TypeScript
244 lines
14 KiB
TypeScript
/**
|
||
* Arcrun#106 迴歸守衛 —— 「更新完,設定頁還看得到版本號,而且是**這次**的版本號」
|
||
*
|
||
* 2026-08-12 實害:leo 更新完 leo21c,Portal 設定頁的版本欄變成
|
||
* 「無法讀取目前版本(知識庫服務可能正在啟動)」。
|
||
* 根因:`ARCRUN_BUNDLE_VERSION` 是部署時注入的 plain_text var,**只有安裝器會注入**;
|
||
* CLI 這條路重部署時 wrangler 整份覆蓋 toml,沒寫的 var 直接消失 ⇒ 標籤被洗掉。
|
||
* #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),**沒修「櫃子上的標籤」**。
|
||
*
|
||
* 這份測試守兩件相反的事(本次的核心判斷):
|
||
* · 設定類 var(安裝器注入的 PORTAL_MAIL_RELAY_BASE 之類)=使用者實例的事實 → **沿用**
|
||
* · 版本標籤 ARCRUN_BUNDLE_VERSION =這份成品的屬性 → **每趟重烙,絕不沿用舊值**
|
||
* (沿用舊值 = 一個永遠停在安裝當天的假標籤,比沒有標籤更糟)
|
||
*
|
||
* 全部離線跑:真的 wrangler.toml + 真的 render/inject 程式碼,fetch 用假的,不碰任何實例。
|
||
*/
|
||
|
||
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 {
|
||
renderWranglerToml,
|
||
preservedVars,
|
||
applyVars,
|
||
resolveBundleStamp,
|
||
CLI_MANAGED_VARS,
|
||
VERSION_STAMP_WORKER,
|
||
type DeployContext,
|
||
} from '../src/lib/deploy.ts';
|
||
import { planResources, type ResourceApi, type ScriptBindings } from '../src/lib/resource-resolver.ts';
|
||
|
||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
|
||
|
||
const CTX: DeployContext = {
|
||
accountId: 'acc-user-123',
|
||
apiToken: 'token',
|
||
workerSubdomain: 'user-sub',
|
||
selfHosted: true,
|
||
kbdbEmbed: true,
|
||
};
|
||
|
||
/** 一台「安裝器裝出來、已經跑過的」實例上,cypher worker 現在掛著的 plain_text var。 */
|
||
const LIVE_VARS: Record<string, string> = {
|
||
ARCRUN_BUNDLE_VERSION: '1.4.29', // 安裝當時的舊標籤
|
||
PORTAL_MAIL_RELAY_BASE: 'https://mail.example.com', // 安裝器注入、repo toml 沒有 → 洗掉就寄不出信
|
||
CONSOLE_TENANT: 'someone-else', // repo toml 寫死 "leo",不能拿官方值蓋掉人家的
|
||
WORKER_SUBDOMAIN: 'user-sub', // CLI 自己算
|
||
CF_ACCOUNT_ID: 'acc-user-123', // CLI 自己算
|
||
MULTI_TENANT: 'false', // CLI 自己算
|
||
ENVIRONMENT: 'production', // 與 toml 同值 → 不必重寫
|
||
};
|
||
|
||
/** 從 render 過的 toml 讀 [vars] 區塊(只看未註解的行)。 */
|
||
function readVars(toml: string): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
let inVars = false;
|
||
for (const raw of toml.split('\n')) {
|
||
const line = raw.trim();
|
||
if (/^\[\[?[A-Za-z0-9_]+\]?\]$/.test(line)) { inVars = line === '[vars]'; continue; }
|
||
if (!inVars || line.startsWith('#')) continue;
|
||
const m = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||
if (m) out[m[1]] = m[2];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// ① 病灶本身:舊行為會把標籤洗掉
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('#106 ①:repo 的 cypher toml 本來就沒有 ARCRUN_BUNDLE_VERSION——不補就是洗掉(病灶重現)', () => {
|
||
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map());
|
||
assert.equal(
|
||
readVars(rendered).ARCRUN_BUNDLE_VERSION,
|
||
undefined,
|
||
'若這行開始有值,表示 toml 自己帶了版本標籤,本測試的前提要重寫',
|
||
);
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// ② 設定類 var:沿用實例上的事實
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('#106 ②:安裝器注入、repo toml 沒有的 var 會被沿用(不再被重部署洗掉)', () => {
|
||
const keep = preservedVars(LIVE_VARS, CYPHER_TOML);
|
||
assert.equal(keep.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||
// repo toml 寫死的是官方值,使用者實例上的值才是事實
|
||
assert.equal(keep.CONSOLE_TENANT, 'someone-else');
|
||
// 與 toml 同值 → 不需要重寫進去(雜訊)
|
||
assert.equal(keep.ENVIRONMENT, undefined);
|
||
});
|
||
|
||
test('#106 ③:CLI 自己算的 var 一律不沿用(沿用等於拿舊值蓋掉這趟的正解)', () => {
|
||
const keep = preservedVars({ ...LIVE_VARS, WORKER_SUBDOMAIN: 'OLD-sub', CF_ACCOUNT_ID: 'OLD-acc' }, CYPHER_TOML);
|
||
for (const managed of CLI_MANAGED_VARS) {
|
||
assert.equal(keep[managed], undefined, `${managed} 不該被沿用`);
|
||
}
|
||
// 而且注入完的 toml 裡,這些值仍是這趟算出來的那個
|
||
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map(), keep);
|
||
const vars = readVars(rendered);
|
||
assert.equal(vars.WORKER_SUBDOMAIN, 'user-sub');
|
||
assert.equal(vars.CF_ACCOUNT_ID, 'acc-user-123');
|
||
assert.equal(vars.MULTI_TENANT, 'false');
|
||
assert.equal(vars.KBDB_BASE_URL, 'https://arcrun-kbdb.user-sub.workers.dev');
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// ③ 版本標籤:重烙,不沿用
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('#106 ④:版本標籤取「發行頻道公告的 release」+ 實際 commit,不是沿用舊值', async () => {
|
||
const fakeFetch = (async () =>
|
||
new Response(JSON.stringify({ release: '1.4.41', pin: 'ba81439' }), { status: 200 })) as typeof fetch;
|
||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||
assert.equal(stamp.version, '1.4.41');
|
||
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION); // ← 這就是本 issue
|
||
assert.equal(stamp.commit, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||
assert.match(stamp.version, /^\d+\.\d+\.\d+$/, 'Portal 拿它跟 /api/latest 比 semver,必須是純 semver');
|
||
});
|
||
|
||
test('#106 ⑤:查不到發行版號時誠實標成 commit 版,**不**沿用舊值、也不掰一個 semver', async () => {
|
||
const fakeFetch = (async () => { throw new Error('offline'); }) as typeof fetch;
|
||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+f87d0e9$/);
|
||
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION);
|
||
assert.doesNotMatch(stamp.version, /^\d+\.\d+\.\d+$/, '掰一個 semver 會讓 Portal 假裝「已是最新版」');
|
||
});
|
||
|
||
test('#106 ⑥:發行頻道回了不是 semver 的東西 → 當成查不到(不把垃圾當版號烙上去)', async () => {
|
||
const fakeFetch = (async () =>
|
||
new Response(JSON.stringify({ release: 'latest' }), { status: 200 })) as typeof fetch;
|
||
const stamp = await resolveBundleStamp('main', 'abc1234def', fakeFetch);
|
||
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+abc1234$/);
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// ④ 端到端(離線):一台已安裝的實例跑一次更新,Portal 讀得到的那個欄位長什麼樣
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('#106 ⑦:模擬更新——版本標籤變新、設定 var 一個不少、資源沿用不受影響', async () => {
|
||
const api: ResourceApi = {
|
||
async getScriptBindings(script: string): Promise<ScriptBindings> {
|
||
if (script !== VERSION_STAMP_WORKER) return { deployed: false, bindings: [], vars: {} };
|
||
return {
|
||
deployed: true,
|
||
bindings: [
|
||
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv-webhooks' },
|
||
{ kind: 'kv_namespace', binding: 'CREDENTIALS_KV', value: 'kv-creds' },
|
||
{ kind: 'kv_namespace', binding: 'RECIPES', value: 'kv-recipes' },
|
||
{ kind: 'kv_namespace', binding: 'USERS_KV', value: 'kv-users' },
|
||
{ kind: 'kv_namespace', binding: 'SESSIONS_KV', value: 'kv-sessions' },
|
||
{ kind: 'kv_namespace', binding: 'ANALYTICS_KV', value: 'kv-analytics' },
|
||
{ kind: 'kv_namespace', binding: 'EXEC_CONTEXT', value: 'kv-exec' },
|
||
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1-kbdb' },
|
||
],
|
||
vars: LIVE_VARS,
|
||
};
|
||
},
|
||
async listKvNamespaces() {
|
||
return new Map([
|
||
['a', 'kv-webhooks'], ['b', 'kv-creds'], ['c', 'kv-recipes'], ['d', 'kv-users'],
|
||
['e', 'kv-sessions'], ['f', 'kv-analytics'], ['g', 'kv-exec'],
|
||
]);
|
||
},
|
||
async listD1Databases() { return new Map([['arcrun-kbdb', 'd1-kbdb']]); },
|
||
async listVectorizeIndexes() { return []; },
|
||
async createKvNamespace() { throw new Error('這趟不該新建任何 KV'); },
|
||
async createD1Database() { throw new Error('這趟不該新建 D1'); },
|
||
async createVectorizeIndex() { throw new Error('這趟不該新建 Vectorize'); },
|
||
};
|
||
|
||
const preview = renderWranglerToml(CYPHER_TOML, CTX, new Map());
|
||
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
|
||
const parsed = parseWranglerRequirements(preview);
|
||
const plan = await planResources(
|
||
api,
|
||
parsed.bindings.map((b) => ({ ...b, worker: parsed.script })),
|
||
'update',
|
||
);
|
||
assert.deepEqual(plan.blockers, []);
|
||
// 讀綁定時順手把 var 帶回來——不另外打一次 API
|
||
assert.equal(plan.liveVars.get(VERSION_STAMP_WORKER)?.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||
|
||
const fakeFetch = (async () =>
|
||
new Response(JSON.stringify({ release: '1.4.41' }), { status: 200 })) as typeof fetch;
|
||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||
const extra = {
|
||
...preservedVars(plan.liveVars.get(parsed.script), CYPHER_TOML),
|
||
ARCRUN_BUNDLE_VERSION: stamp.version,
|
||
ARCRUN_BUNDLE_COMMIT: stamp.commit!,
|
||
};
|
||
|
||
const deployed = readVars(renderWranglerToml(CYPHER_TOML, CTX, new Map(), extra));
|
||
|
||
// ① Portal 設定頁讀的就是這個欄位——更新完必須有值,且是**這趟**的版本
|
||
assert.equal(deployed.ARCRUN_BUNDLE_VERSION, '1.4.41');
|
||
assert.equal(deployed.ARCRUN_BUNDLE_COMMIT, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||
// ② 安裝器注入的設定沒有在更新中消失
|
||
assert.equal(deployed.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||
assert.equal(deployed.CONSOLE_TENANT, 'someone-else');
|
||
// ③ CLI 自己算的仍然是這趟算出來的
|
||
assert.equal(deployed.WORKER_SUBDOMAIN, 'user-sub');
|
||
assert.equal(deployed.MULTI_TENANT, 'false');
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// ⑤ applyVars 的三種既有狀態 + 不弄壞別的區塊
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('#106 ⑧:applyVars——改既有行/取消註解/插進 [vars]/連 [vars] 都沒有時新開一段', () => {
|
||
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'new' }), /^\[vars\]\nA = "new"\n$/);
|
||
assert.match(applyVars('[vars]\n# A = "old"\n', { A: 'new' }), /A = "new"/);
|
||
assert.match(applyVars('[vars]\nB = "b"\n', { A: 'a' }), /\[vars\]\nA = "a"\nB = "b"/);
|
||
const noVars = applyVars('name = "w"\n', { A: 'a' });
|
||
assert.match(noVars, /\[vars\]\nA = "a"/);
|
||
assert.match(noVars, /^name = "w"/);
|
||
});
|
||
|
||
test('#106 ⑨:var 值裡的引號/反斜線會被轉義(不會產生壞掉的 toml)', () => {
|
||
const out = applyVars('[vars]\n', { A: 'say "hi"\\path' });
|
||
assert.match(out, /A = "say \\"hi\\"\\\\path"/);
|
||
});
|
||
|
||
test('#106 ⑨b:值裡有 $& / $1 也照原樣寫出(replace 反向參照陷阱)', () => {
|
||
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'x$&y$1z' }), /A = "x\$&y\$1z"/);
|
||
assert.match(applyVars('[vars]\n', { A: 'x$&y' }), /A = "x\$&y"/);
|
||
// 怪名字不寫進去(不拿它組正規式)
|
||
assert.equal(applyVars('[vars]\n', { 'BAD NAME': 'v' }), '[vars]\n');
|
||
});
|
||
|
||
test('#106 ⑩:注入 var 不影響資源綁定解析(預覽與實際寫入看到的是同一份需求)', async () => {
|
||
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
|
||
const withoutVars = parseWranglerRequirements(renderWranglerToml(CYPHER_TOML, CTX, new Map()));
|
||
const withVars = parseWranglerRequirements(
|
||
renderWranglerToml(CYPHER_TOML, CTX, new Map(), { ARCRUN_BUNDLE_VERSION: '1.4.41', X: 'y' }),
|
||
);
|
||
assert.equal(withVars.script, withoutVars.script);
|
||
assert.deepEqual(withVars.bindings, withoutVars.bindings);
|
||
});
|