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
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user