fix: make Actions deployments portable / 修复跨账户模板部署
This commit is contained in:
1 parent
14ce519aca
commit
2916d69799
10 files changed
+431
-11
No files matched your search
@@ -19,8 +19,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
WORKER_NAME: ${{ vars.WORKER_NAME }}
|
||||
D1_DATABASE_NAME: ${{ vars.D1_DATABASE_NAME }}
|
||||
D1_DATABASE_ID: ${{ vars.D1_DATABASE_ID }}
|
||||
CUSTOM_DOMAIN: ${{ vars.CUSTOM_DOMAIN }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -34,11 +38,20 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# 先校验配置,避免构建完成后才发现缺少 Secret,或创建不完整的远端资源。
|
||||
- name: Validate deployment settings
|
||||
run: npm run deploy:validate
|
||||
env:
|
||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||
ACCESS_TEAM_DOMAIN: ${{ secrets.ACCESS_TEAM_DOMAIN }}
|
||||
ACCESS_AUD: ${{ secrets.ACCESS_AUD }}
|
||||
|
||||
- name: Check project
|
||||
run: npm run check
|
||||
|
||||
- name: Apply D1 migrations
|
||||
run: npx wrangler d1 migrations apply DB --remote
|
||||
|
||||
- name: Deploy Worker
|
||||
- name: Provision D1, migrate, sync secrets and deploy Worker
|
||||
run: npm run deploy
|
||||
env:
|
||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||
ACCESS_TEAM_DOMAIN: ${{ secrets.ACCESS_TEAM_DOMAIN }}
|
||||
ACCESS_AUD: ${{ secrets.ACCESS_AUD }}
|
||||
@@ -11,3 +11,4 @@ AGENTS.md
|
||||
deliverables/
|
||||
frontend/dist-*
|
||||
scripts-tmp/
|
||||
.wrangler.generated.toml
|
||||
Generated
+32
@@ -17,7 +17,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^5.20260727.1",
|
||||
"@types/node": "^22.20.4",
|
||||
"flag-icons": "^7.5.0",
|
||||
"smol-toml": "^1.8.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.0.6",
|
||||
"wrangler": "^4.134.0"
|
||||
@@ -1571,6 +1573,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
|
||||
"integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
@@ -1967,6 +1979,19 @@
|
||||
"node": ">=0.12.18"
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
|
||||
"integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cyyynthia"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2039,6 +2064,13 @@
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unenv": {
|
||||
"version": "2.0.0-rc.24",
|
||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/unenv/-/unenv-2.0.0-rc.24.tgz",
|
||||
|
||||
+5
-2
@@ -8,10 +8,11 @@
|
||||
"dev": "wrangler dev",
|
||||
"dev:web": "vite --config vite.config.ts",
|
||||
"build:web": "vite build --config vite.config.ts",
|
||||
"typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit",
|
||||
"typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit && tsc -p tsconfig.scripts.json --noEmit",
|
||||
"test": "node --test --experimental-strip-types tests/*.test.ts",
|
||||
"check": "npm run typecheck && npm run test && wrangler deploy --dry-run --outdir .wrangler/build",
|
||||
"deploy": "wrangler deploy"
|
||||
"deploy:validate": "node --experimental-strip-types scripts/deploy.ts --validate-only",
|
||||
"deploy": "node --experimental-strip-types scripts/deploy.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@@ -23,7 +24,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^5.20260727.1",
|
||||
"@types/node": "^22.20.4",
|
||||
"flag-icons": "^7.5.0",
|
||||
"smol-toml": "^1.8.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.0.6",
|
||||
"wrangler": "^4.134.0"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Database, DeploymentSettings } from './deployment-config.ts';
|
||||
|
||||
export async function ensureDatabase(
|
||||
settings: Pick<DeploymentSettings, 'accountId' | 'apiToken' | 'databaseName' | 'databaseId'>,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<Database> {
|
||||
const base = `https://api.cloudflare.com/client/v4/accounts/${settings.accountId}/d1/database`;
|
||||
async function request<T>(suffix: string, method = 'GET', body?: unknown): Promise<T> {
|
||||
const response = await fetcher(`${base}${suffix}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${settings.apiToken}`, 'Content-Type': 'application/json' },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
// 不回显 API 响应体,防止远端诊断信息意外包含凭据或内部细节。
|
||||
if (!response.ok) throw new Error(`D1 ${method} 请求失败(HTTP ${response.status})。请检查目标账户、数据库 ID 和 Token 的 D1 权限。`);
|
||||
const payload = await response.json() as { success: boolean; result: T };
|
||||
if (!payload.success) throw new Error(`D1 ${method} 请求未成功,请检查 Token 权限与账户资源配额。`);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
// 显式 ID 必须属于目标账户;不存在或无权限时直接失败,不能偷偷创建空库替代。
|
||||
if (settings.databaseId) return await request<Database>(`/${settings.databaseId}`);
|
||||
|
||||
// 按名称复用并读取全部分页,避免已有数据库在后续页时误创建同名资源。
|
||||
const pageSize = 100;
|
||||
for (let page = 1; ; page++) {
|
||||
const databases = await request<Database[]>(`?per_page=${pageSize}&page=${page}`);
|
||||
const existing = databases.find((database) => database.name === settings.databaseName);
|
||||
if (existing) return existing;
|
||||
if (databases.length < pageSize) break;
|
||||
}
|
||||
return await request<Database>('', 'POST', { name: settings.databaseName });
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse, stringify } from 'smol-toml';
|
||||
import { ensureDatabase } from './cloudflare-d1.ts';
|
||||
import { createDeploymentConfig, readDeploymentSettings } from './deployment-config.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const generatedConfig = '.wrangler.generated.toml';
|
||||
const wrangler = fileURLToPath(new URL('../node_modules/wrangler/bin/wrangler.js', import.meta.url));
|
||||
|
||||
async function runWrangler(args: string[], input?: string): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [wrangler, ...args, '--config', generatedConfig], {
|
||||
cwd: root,
|
||||
env: { ...process.env, CI: 'true' },
|
||||
stdio: [input === undefined ? 'ignore' : 'pipe', 'inherit', 'inherit'],
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Wrangler ${args[0]} 失败(退出码 ${code})。`)));
|
||||
if (input !== undefined) {
|
||||
child.stdin!.on('error', reject);
|
||||
child.stdin!.end(input);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const template = parse(await readFile(new URL('../wrangler.toml', import.meta.url), 'utf8'));
|
||||
const settings = readDeploymentSettings(template, process.env);
|
||||
if (process.argv.includes('--validate-only')) {
|
||||
console.log('部署配置校验通过。');
|
||||
return;
|
||||
}
|
||||
|
||||
const database = await ensureDatabase(settings);
|
||||
const config = createDeploymentConfig(template, settings, database);
|
||||
// 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。
|
||||
await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8');
|
||||
console.log(`部署 Worker ${settings.workerName},使用 D1 ${database.name}(${database.uuid})。`);
|
||||
await runWrangler(['d1', 'migrations', 'apply', 'DB', '--remote']);
|
||||
// Secret 只通过标准输入发送,不写临时文件或命令行参数;首次部署由 Wrangler 创建草稿 Worker。
|
||||
await runWrangler(['secret', 'bulk'], JSON.stringify(settings.secrets));
|
||||
await runWrangler(['deploy']);
|
||||
}
|
||||
|
||||
main().catch((error: Error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { TomlTable } from 'smol-toml';
|
||||
|
||||
export const runtimeSecretNames = ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'] as const;
|
||||
export type RuntimeSecrets = Record<(typeof runtimeSecretNames)[number], string>;
|
||||
|
||||
export interface DeploymentSettings {
|
||||
accountId: string;
|
||||
apiToken: string;
|
||||
workerName: string;
|
||||
databaseName: string;
|
||||
databaseId?: string;
|
||||
customDomain?: string;
|
||||
secrets: RuntimeSecrets;
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function readDeploymentSettings(
|
||||
template: TomlTable,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): DeploymentSettings {
|
||||
const required = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN', ...runtimeSecretNames];
|
||||
const missing = required.filter((name) => !env[name]?.trim());
|
||||
if (missing.length) throw new Error(`缺少部署配置:${missing.join(', ')}。请在 GitHub Actions 中配置。`);
|
||||
|
||||
const accountId = env.CLOUDFLARE_ACCOUNT_ID!.trim();
|
||||
if (!/^[a-f0-9]{32}$/i.test(accountId)) throw new Error('CLOUDFLARE_ACCOUNT_ID 必须是 32 位十六进制账户 ID。');
|
||||
const workerName = env.WORKER_NAME?.trim() || String(template.name);
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(workerName)) throw new Error('WORKER_NAME 必须是 1–63 位小写字母、数字或连字符,且不能以连字符开头。');
|
||||
|
||||
const databases = template.d1_databases as TomlTable[] | undefined;
|
||||
const database = databases?.find((binding) => binding.binding === 'DB');
|
||||
if (!database) throw new Error('wrangler.toml 缺少 DB 数据库绑定。');
|
||||
// 改 Worker 名时默认隔离数据库;只有显式指定名称或 ID 才复用另一实例的数据。
|
||||
const databaseName = env.D1_DATABASE_NAME?.trim()
|
||||
|| (workerName === template.name ? String(database.database_name) : `${workerName}-accounts`);
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(databaseName)) throw new Error('D1_DATABASE_NAME 必须是 1–64 位字母、数字、下划线或连字符,且以字母或数字开头。');
|
||||
const databaseId = env.D1_DATABASE_ID?.trim() || (database.database_id as string | undefined);
|
||||
if (databaseId && !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(databaseId)) {
|
||||
throw new Error('D1_DATABASE_ID 必须是有效的数据库 UUID。');
|
||||
}
|
||||
const customDomain = env.CUSTOM_DOMAIN?.trim();
|
||||
if (customDomain && !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(customDomain)) {
|
||||
throw new Error('CUSTOM_DOMAIN 只能填写完整域名,不能包含协议、路径或通配符。');
|
||||
}
|
||||
|
||||
const secrets = Object.fromEntries(runtimeSecretNames.map((name) => [name, env[name]!.trim()])) as RuntimeSecrets;
|
||||
const key = Buffer.from(secrets.ENCRYPTION_KEY, 'base64');
|
||||
if (!/^[A-Za-z0-9+/]{43}=?$/.test(secrets.ENCRYPTION_KEY)
|
||||
|| key.length !== 32
|
||||
|| key.toString('base64').replace(/=+$/, '') !== secrets.ENCRYPTION_KEY.replace(/=+$/, '')) {
|
||||
throw new Error('ENCRYPTION_KEY 必须是 Base64 编码的 32 字节密钥;已有数据时必须继续使用原密钥。');
|
||||
}
|
||||
if (!/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(secrets.ACCESS_TEAM_DOMAIN)) {
|
||||
throw new Error('ACCESS_TEAM_DOMAIN 必须形如 team.cloudflareaccess.com,不包含 https://。');
|
||||
}
|
||||
if (runtimeSecretNames.some((name) => name in ((template.vars as TomlTable | undefined) ?? {}))) {
|
||||
throw new Error('运行时 Secret 不得放在 wrangler.toml 的 vars 中,请改用 GitHub Actions Secrets。');
|
||||
}
|
||||
|
||||
return {
|
||||
accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName,
|
||||
databaseName, databaseId, customDomain, secrets,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDeploymentConfig(
|
||||
template: TomlTable,
|
||||
settings: DeploymentSettings,
|
||||
database: Database,
|
||||
): TomlTable {
|
||||
return {
|
||||
...template,
|
||||
account_id: settings.accountId,
|
||||
name: settings.workerName,
|
||||
...(settings.customDomain ? { routes: [{ pattern: settings.customDomain, custom_domain: true }] } : {}),
|
||||
d1_databases: (template.d1_databases as TomlTable[]).map((binding) => binding.binding === 'DB'
|
||||
? { ...binding, database_name: database.name, database_id: database.uuid }
|
||||
: binding),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { test } from 'node:test';
|
||||
import { parse, stringify } from 'smol-toml';
|
||||
import { ensureDatabase } from '../scripts/cloudflare-d1.ts';
|
||||
import { createDeploymentConfig, readDeploymentSettings } from '../scripts/deployment-config.ts';
|
||||
|
||||
const templateText = await readFile(new URL('../wrangler.toml', import.meta.url), 'utf8');
|
||||
const template = parse(templateText);
|
||||
const env = {
|
||||
CLOUDFLARE_ACCOUNT_ID: 'a'.repeat(32),
|
||||
CLOUDFLARE_API_TOKEN: 'test-token-not-a-real-credential',
|
||||
ENCRYPTION_KEY: Buffer.alloc(32, 1).toString('base64'),
|
||||
ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com',
|
||||
ACCESS_AUD: 'test-audience',
|
||||
};
|
||||
const settings = readDeploymentSettings(template, env);
|
||||
const database = { uuid: '00000000-0000-4000-8000-000000000001', name: 'edgessh-accounts' };
|
||||
|
||||
function json(result: unknown, success = true): Response {
|
||||
return Response.json({ success, result });
|
||||
}
|
||||
|
||||
test('public template contains no account, database ID, custom domain or runtime secrets', () => {
|
||||
assert.equal(template.account_id, undefined);
|
||||
assert.equal(template.routes, undefined);
|
||||
assert.equal(template.workers_dev, true);
|
||||
assert.deepEqual(template.d1_databases, [{
|
||||
binding: 'DB', database_name: 'edgessh-accounts', migrations_dir: 'migrations',
|
||||
}]);
|
||||
assert.deepEqual(template.vars, { CONNECT_TIMEOUT_MS: '10000' });
|
||||
});
|
||||
|
||||
test('generated config preserves application bindings and paths without persisting secrets', () => {
|
||||
const before = structuredClone(template);
|
||||
const config = createDeploymentConfig(template, settings, database);
|
||||
assert.equal(config.account_id, env.CLOUDFLARE_ACCOUNT_ID);
|
||||
assert.equal(config.name, 'edgessh');
|
||||
assert.deepEqual(config.d1_databases, [{
|
||||
binding: 'DB', database_name: database.name, database_id: database.uuid, migrations_dir: 'migrations',
|
||||
}]);
|
||||
for (const key of ['main', 'assets', 'build', 'migrations', 'durable_objects']) {
|
||||
assert.deepEqual(config[key], template[key]);
|
||||
}
|
||||
const serialized = stringify(config);
|
||||
for (const value of [env.CLOUDFLARE_API_TOKEN, ...Object.values(settings.secrets)]) {
|
||||
assert.equal(serialized.includes(value), false);
|
||||
}
|
||||
assert.deepEqual(parse(serialized), config);
|
||||
assert.deepEqual(template, before);
|
||||
});
|
||||
|
||||
test('worker, database and custom domain can be configured for another account', () => {
|
||||
const custom = readDeploymentSettings(template, {
|
||||
...env, CLOUDFLARE_ACCOUNT_ID: 'b'.repeat(32), WORKER_NAME: 'my-ssh', CUSTOM_DOMAIN: 'ssh.example.com',
|
||||
});
|
||||
assert.equal(custom.databaseName, 'my-ssh-accounts');
|
||||
const config = createDeploymentConfig(template, custom, { ...database, name: custom.databaseName });
|
||||
assert.equal(config.name, 'my-ssh');
|
||||
assert.equal(config.account_id, 'b'.repeat(32));
|
||||
assert.deepEqual(config.routes, [{ pattern: 'ssh.example.com', custom_domain: true }]);
|
||||
});
|
||||
|
||||
test('explicit database settings and manually configured template values are respected', () => {
|
||||
const custom = readDeploymentSettings(template, {
|
||||
...env, WORKER_NAME: 'another-worker', D1_DATABASE_NAME: 'shared-data', D1_DATABASE_ID: database.uuid,
|
||||
});
|
||||
assert.equal(custom.databaseName, 'shared-data');
|
||||
assert.equal(custom.databaseId, database.uuid);
|
||||
const manualTemplate = {
|
||||
...template,
|
||||
routes: [{ pattern: 'manual.example.com', custom_domain: true }],
|
||||
d1_databases: [{ binding: 'DB', database_name: database.name, database_id: database.uuid }],
|
||||
};
|
||||
const manual = readDeploymentSettings(manualTemplate, env);
|
||||
assert.equal(manual.databaseId, database.uuid);
|
||||
assert.deepEqual(createDeploymentConfig(manualTemplate, manual, database).routes, manualTemplate.routes);
|
||||
});
|
||||
|
||||
test('missing deployment settings fail before provisioning and do not reveal values', () => {
|
||||
assert.throws(() => readDeploymentSettings(template, {}), /CLOUDFLARE_ACCOUNT_ID.*ENCRYPTION_KEY.*ACCESS_AUD/);
|
||||
assert.throws(() => readDeploymentSettings(template, { ...env, ACCESS_AUD: ' ' }), /ACCESS_AUD/);
|
||||
});
|
||||
|
||||
test('invalid account, resource, domain and runtime configuration fail validation', () => {
|
||||
for (const [name, value] of Object.entries({
|
||||
CLOUDFLARE_ACCOUNT_ID: 'wrong-account',
|
||||
WORKER_NAME: 'invalid worker name',
|
||||
D1_DATABASE_NAME: '../invalid-name',
|
||||
D1_DATABASE_ID: 'wrong-database-id',
|
||||
CUSTOM_DOMAIN: 'https://ssh.example.com/path',
|
||||
ENCRYPTION_KEY: Buffer.alloc(16).toString('base64'),
|
||||
ACCESS_TEAM_DOMAIN: 'https://example.cloudflareaccess.com',
|
||||
})) {
|
||||
assert.throws(() => readDeploymentSettings(template, { ...env, [name]: value }), new RegExp(name));
|
||||
}
|
||||
assert.throws(() => readDeploymentSettings(template, { ...env, ENCRYPTION_KEY: `${env.ENCRYPTION_KEY}!` }), /ENCRYPTION_KEY/);
|
||||
assert.throws(() => readDeploymentSettings(template, { ...env, ENCRYPTION_KEY: `${env.ENCRYPTION_KEY}==` }), /ENCRYPTION_KEY/);
|
||||
assert.throws(() => readDeploymentSettings({
|
||||
...template, vars: { ENCRYPTION_KEY: env.ENCRYPTION_KEY },
|
||||
}, env), /Secret/);
|
||||
});
|
||||
|
||||
test('database lookup reuses an existing database without any writes', async () => {
|
||||
const calls: string[] = [];
|
||||
const fetcher: typeof fetch = async (input, init) => {
|
||||
calls.push(String(input));
|
||||
assert.equal(init?.method, 'GET');
|
||||
assert.equal((init?.headers as Record<string, string>).Authorization, `Bearer ${env.CLOUDFLARE_API_TOKEN}`);
|
||||
return json([database]);
|
||||
};
|
||||
assert.deepEqual(await ensureDatabase(settings, fetcher), database);
|
||||
assert.deepEqual(calls, [`https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/d1/database?per_page=100&page=1`]);
|
||||
});
|
||||
|
||||
test('database lookup searches later pages before creating resources', async () => {
|
||||
let calls = 0;
|
||||
const fetcher: typeof fetch = async (input, init) => {
|
||||
calls++;
|
||||
assert.equal(init?.method, 'GET');
|
||||
assert.equal(new URL(String(input)).searchParams.get('page'), String(calls));
|
||||
return json(calls === 1
|
||||
? Array.from({ length: 100 }, (_, index) => ({ uuid: String(index), name: `other-${index}` }))
|
||||
: [database]);
|
||||
};
|
||||
assert.deepEqual(await ensureDatabase(settings, fetcher), database);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('first deployment creates once and subsequent deployment reuses the database', async () => {
|
||||
let created = false;
|
||||
let writes = 0;
|
||||
const fetcher: typeof fetch = async (_input, init) => {
|
||||
if (init?.method === 'GET') return json(created ? [database] : []);
|
||||
assert.equal(init?.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(String(init.body)), { name: database.name });
|
||||
writes++;
|
||||
created = true;
|
||||
return json(database);
|
||||
};
|
||||
assert.deepEqual(await ensureDatabase(settings, fetcher), database);
|
||||
assert.deepEqual(await ensureDatabase(settings, fetcher), database);
|
||||
assert.equal(writes, 1);
|
||||
});
|
||||
|
||||
test('explicit ID is verified within the target account and uses the returned database name', async () => {
|
||||
const fetcher: typeof fetch = async (input, init) => {
|
||||
assert.equal(String(input), `https://api.cloudflare.com/client/v4/accounts/${settings.accountId}/d1/database/${database.uuid}`);
|
||||
assert.equal(init?.method, 'GET');
|
||||
return json({ ...database, name: 'existing-database' });
|
||||
};
|
||||
const resolved = await ensureDatabase({ ...settings, databaseId: database.uuid }, fetcher);
|
||||
assert.equal(resolved.name, 'existing-database');
|
||||
assert.deepEqual(createDeploymentConfig(template, settings, resolved).d1_databases, [{
|
||||
binding: 'DB', database_name: 'existing-database', database_id: database.uuid, migrations_dir: 'migrations',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('invalid explicit ID or denied API access never falls back to creating an empty database', async () => {
|
||||
for (const status of [403, 404, 500]) {
|
||||
let calls = 0;
|
||||
const fetcher: typeof fetch = async () => {
|
||||
calls++;
|
||||
return new Response('private upstream details', { status });
|
||||
};
|
||||
await assert.rejects(ensureDatabase({ ...settings, databaseId: database.uuid }, fetcher), (error: Error) => {
|
||||
assert.match(error.message, new RegExp(`HTTP ${status}`));
|
||||
assert.equal(error.message.includes('private upstream details'), false);
|
||||
return true;
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('unsuccessful API envelope aborts resource preparation', async () => {
|
||||
const fetcher: typeof fetch = async () => json(null, false);
|
||||
await assert.rejects(ensureDatabase(settings, fetcher), /未成功/);
|
||||
});
|
||||
|
||||
test('Actions supplies runtime secrets from Secrets and uses the shared deploy entry point', async () => {
|
||||
const workflow = await readFile(new URL('../.github/workflows/deploy.yml', import.meta.url), 'utf8');
|
||||
for (const name of ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']) {
|
||||
assert.ok(workflow.includes(`${name}: \${{ secrets.${name} }}`));
|
||||
assert.equal(workflow.includes(`vars.${name}`), false);
|
||||
}
|
||||
assert.ok(workflow.includes('run: npm run deploy:validate'));
|
||||
assert.ok(workflow.includes('run: npm run deploy\n'));
|
||||
assert.equal(workflow.includes('wrangler d1 migrations apply'), false);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["scripts/**/*.ts", "tests/deployment.test.ts"]
|
||||
}
|
||||
+3
-4
@@ -4,9 +4,8 @@ compatibility_date = "2026-07-01"
|
||||
workers_dev = true
|
||||
preview_urls = false
|
||||
|
||||
routes = [
|
||||
{ pattern = "ssh.dltwcnm.ccwu.cc", custom_domain = true }
|
||||
]
|
||||
# 模板默认使用用户自己的 workers.dev 地址,避免绑定原作者的域名。
|
||||
# Actions 可通过 CUSTOM_DOMAIN 配置自定义域名。
|
||||
|
||||
[build]
|
||||
command = "npm run build:web"
|
||||
@@ -31,5 +30,5 @@ new_sqlite_classes = ["SSHSessionDO"]
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "edgessh-accounts"
|
||||
database_id = "2c1b7a95-a5d6-4a72-a8d2-4132b74aa268"
|
||||
# 部署脚本在目标账户中查找或创建数据库,不把账户专属 ID 留在模板里。
|
||||
migrations_dir = "migrations"
|
||||
Reference in new issue
Block a user