From 4cae0399d40e85f16dd25296750d934cbe0ba8f8 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Mon, 17 Aug 2026 16:37:46 +0800 Subject: [PATCH] fix(deploy): preserve legacy R2 bindings --- docs/agent-deploy-cloudflare.md | 1 + docs/agent-deploy-cloudflare.zh-CN.md | 1 + docs/cloudflare-workers-builds.md | 2 + docs/cloudflare-workers-builds.zh-CN.md | 2 + docs/deploy-cloudflare-button.md | 4 +- docs/deploy-cloudflare-button.zh-CN.md | 4 +- scripts/cloudflare-workers-builds.mjs | 2 +- scripts/run-wrangler.mjs | 88 +++++++++++++++++++++++-- scripts/wrangler-runner.mjs | 52 +++++++++++++++ tests/deployment-entrypoints.test.ts | 16 ++++- tests/wrangler-runner.test.ts | 32 +++++++++ 11 files changed, 196 insertions(+), 8 deletions(-) diff --git a/docs/agent-deploy-cloudflare.md b/docs/agent-deploy-cloudflare.md index 4702db2e..108a4a4a 100644 --- a/docs/agent-deploy-cloudflare.md +++ b/docs/agent-deploy-cloudflare.md @@ -27,6 +27,7 @@ This document defines the standard operating specifications and conventions for - **Worker Secret**: Add secret `EDGE_EVER_AUTH_PASSWORD` for initial admin password. - Keep this password only as a Worker runtime Secret; do not copy it into Workers Builds variables. The standard deploy entrypoint reuses and verifies the existing Secret. - Do not edit `wrangler.toml` or add duplicate bindings in the Dashboard. The deployment command generates the `DB` and `RESOURCES` bindings from these standard resource names. + - For an existing Worker deployed from older instructions, do not ask the user to rename or reconfigure a custom R2 bucket. With no explicit override, deployment automatically preserves the live `RESOURCES` binding and administrator username. 5. **Configure Workers Builds Commands** - In the Cloudflare project build settings, set the standard commands: diff --git a/docs/agent-deploy-cloudflare.zh-CN.md b/docs/agent-deploy-cloudflare.zh-CN.md index 6f0de275..cf5781c2 100644 --- a/docs/agent-deploy-cloudflare.zh-CN.md +++ b/docs/agent-deploy-cloudflare.zh-CN.md @@ -27,6 +27,7 @@ - **Worker Secret**:添加密钥 `EDGE_EVER_AUTH_PASSWORD`,值为初始管理员登录密码。 - 该密码只配置为 Worker 运行时 Secret,不要复制到 Workers Builds 构建变量;标准部署入口会复用并验证已存在的 Secret。 - 不要修改 `wrangler.toml`,也不要在控制台重复添加 binding。部署命令会根据标准资源名称生成 `DB` 与 `RESOURCES` binding。 + - 对按旧版文档部署的已有 Worker,不要要求用户重命名或重新配置自定义 R2 存储桶。没有显式覆盖时,部署会自动保留线上 `RESOURCES` binding 与管理员用户名。 5. **配置 Workers Builds 命令** - 在 Cloudflare 项目的构建设置中,填入以下标准命令: diff --git a/docs/cloudflare-workers-builds.md b/docs/cloudflare-workers-builds.md index dbf791a9..b4b74833 100644 --- a/docs/cloudflare-workers-builds.md +++ b/docs/cloudflare-workers-builds.md @@ -14,6 +14,8 @@ Configure `EDGE_EVER_AUTH_PASSWORD` under the Worker's **Settings -> Variables a Keep the tracked `wrangler.toml` unchanged. Ordinary deployments use D1 `edgeever`, R2 `edgeever-resources`, and username `admin`. Optional non-secret instance settings such as `EDGE_EVER_AUTH_USERNAME`, `EDGE_EVER_WORKER_NAME`, `EDGE_EVER_D1_DATABASE_NAME`, `EDGE_EVER_R2_BUCKET_NAME`, and custom routing belong under **Settings -> Builds -> Variables and secrets**. Workers Builds variables are available to the build command, not to the running Worker; the deploy command uses them to generate a temporary Wrangler configuration. Passwords and credentials remain runtime Secrets. +Backward compatibility is automatic: without explicit R2 or username Builds variables, an upgrade inspects the active production Worker versions and preserves their existing `RESOURCES` bucket and administrator username. A brand-new Worker uses the standard defaults. + ## Updates and troubleshooting - A push to `main` builds, applies D1 migrations, deploys, and verifies EdgeEver. diff --git a/docs/cloudflare-workers-builds.zh-CN.md b/docs/cloudflare-workers-builds.zh-CN.md index e7cd114c..4363cfe5 100644 --- a/docs/cloudflare-workers-builds.zh-CN.md +++ b/docs/cloudflare-workers-builds.zh-CN.md @@ -14,6 +14,8 @@ 受版本控制的 `wrangler.toml` 必须保持不变。普通部署使用 D1 `edgeever`、R2 `edgeever-resources` 和用户名 `admin`。`EDGE_EVER_AUTH_USERNAME`、`EDGE_EVER_WORKER_NAME`、`EDGE_EVER_D1_DATABASE_NAME`、`EDGE_EVER_R2_BUCKET_NAME` 与自定义路由等可选非敏感实例参数,应放在 **Settings -> Builds -> Variables and secrets**。Workers Builds 变量只对构建命令可见,不会直接成为 Worker 运行时变量;部署命令会用它们生成临时 Wrangler 配置。密码及其他凭据始终属于运行时 Secret。 +旧版兼容会自动完成:未显式设置 R2 或用户名 Builds 变量时,升级会检查正在承载生产流量的 Worker 版本,并保留其已有的 `RESOURCES` 存储桶和管理员用户名;全新 Worker 才使用标准默认值。 + ## 更新与排错 - `main` 推送会自动构建、执行 D1 migration、部署并验证。 diff --git a/docs/deploy-cloudflare-button.md b/docs/deploy-cloudflare-button.md index d7a36184..dc632397 100644 --- a/docs/deploy-cloudflare-button.md +++ b/docs/deploy-cloudflare-button.md @@ -53,6 +53,8 @@ Log into your [Cloudflare Dashboard](https://dash.cloudflare.com/): The repository's deployment command creates the `DB` and `RESOURCES` bindings from the standard resource names. Do not edit `wrangler.toml` or add duplicate bindings in the Dashboard. +Existing deployments created from older instructions do not need to rename or migrate a custom R2 bucket. When no explicit Builds variable is set, the deploy command reads the live Worker's current `RESOURCES` binding and keeps using that bucket automatically. + --- ### Step 4: Set Build Commands & Start Build @@ -104,7 +106,7 @@ Ordinary deployments do not need these settings. To customize an instance, add n | `EDGE_EVER_WORKER_NAME` | Worker name | | `EDGE_EVER_D1_DATABASE_NAME` | D1 database name; its UUID is discovered automatically | | `EDGE_EVER_D1_DATABASE_ID` | Optional UUID fallback when discovery is unavailable | -| `EDGE_EVER_R2_BUCKET_NAME` | Production R2 bucket name | +| `EDGE_EVER_R2_BUCKET_NAME` | Optional explicit production R2 bucket override; upgrades otherwise reuse the live binding | | `EDGE_EVER_R2_PREVIEW_BUCKET_NAME` | Preview R2 bucket name | | `EDGE_EVER_WORKERS_DEV` | Enable or disable the `workers.dev` route | | `EDGE_EVER_CUSTOM_DOMAIN` / `EDGE_EVER_ROUTE_PATTERN` | Custom routing | diff --git a/docs/deploy-cloudflare-button.zh-CN.md b/docs/deploy-cloudflare-button.zh-CN.md index fa6846a1..605e4a59 100644 --- a/docs/deploy-cloudflare-button.zh-CN.md +++ b/docs/deploy-cloudflare-button.zh-CN.md @@ -53,6 +53,8 @@ 仓库中的部署命令会根据标准资源名称生成 `DB` 与 `RESOURCES` binding。不要修改 `wrangler.toml`,也不要在控制台中重复添加 binding。 +按旧版文档创建过自定义 R2 存储桶的已有部署,不需要改名或迁移数据。未显式设置 Builds 变量时,部署命令会读取线上 Worker 当前的 `RESOURCES` binding,并自动继续使用原存储桶。 + --- ### 步骤 4:设置构建命令并启动构建 @@ -104,7 +106,7 @@ EDGE_EVER_UPDATE_CHANNEL=edge | `EDGE_EVER_WORKER_NAME` | Worker 名称 | | `EDGE_EVER_D1_DATABASE_NAME` | D1 数据库名称,UUID 会自动查询 | | `EDGE_EVER_D1_DATABASE_ID` | 自动查询不可用时的可选 UUID 兜底值 | -| `EDGE_EVER_R2_BUCKET_NAME` | 生产环境 R2 存储桶名称 | +| `EDGE_EVER_R2_BUCKET_NAME` | 可选的生产 R2 存储桶显式覆盖;升级时默认沿用线上 binding | | `EDGE_EVER_R2_PREVIEW_BUCKET_NAME` | 预览环境 R2 存储桶名称 | | `EDGE_EVER_WORKERS_DEV` | 启用或禁用 `workers.dev` 路由 | | `EDGE_EVER_CUSTOM_DOMAIN` / `EDGE_EVER_ROUTE_PATTERN` | 自定义路由 | diff --git a/scripts/cloudflare-workers-builds.mjs b/scripts/cloudflare-workers-builds.mjs index 4dc98c06..ef3538c3 100644 --- a/scripts/cloudflare-workers-builds.mjs +++ b/scripts/cloudflare-workers-builds.mjs @@ -117,7 +117,7 @@ const buildVariables = () => { for (const name of deploymentNames) { const scoped = instanceKey ? `EDGE_EVER_${instanceKey}_${name}` : ""; const key = scoped && merged.get(scoped)?.trim() ? scoped : `EDGE_EVER_${name}`; - const current = merged.get(key)?.trim() || (name === "AUTH_USERNAME" ? "admin" : ""); + const current = merged.get(key)?.trim() || ""; if (current) entries.push([key, current]); } diff --git a/scripts/run-wrangler.mjs b/scripts/run-wrangler.mjs index dc669fe9..1b163f5c 100644 --- a/scripts/run-wrangler.mjs +++ b/scripts/run-wrangler.mjs @@ -10,11 +10,13 @@ import { import { dirname, resolve } from "node:path"; import { buildLocalDevEnvironmentFile, + deployedWorkerSettings, DEPLOYMENT_TARGETS_PATH, findD1DatabaseIdByName, normalizeD1MigrationSql, parseWranglerDeploymentUrls, PLACEHOLDER_D1_ID, + productionVersionIds, repositoryWranglerConfigError, runWranglerSync, shouldCaptureDeploymentTargets, @@ -162,6 +164,7 @@ const envValue = (name) => { const isRemoteCommand = wranglerArgs.includes("deploy") || wranglerArgs.includes("--remote"); +const isDeployCommand = wranglerArgs.includes("deploy"); const isRemoteDevCommand = wranglerArgs.includes("dev") && wranglerArgs.includes("--remote"); const isLocalDevCommand = wranglerArgs.includes("dev") && wranglerArgs.includes("--local"); // Any --local command rewrites .wrangler.generated.toml. Keep local-only vars @@ -230,7 +233,85 @@ if (isRemoteCommand && config.includes(`database_id = "${PLACEHOLDER_D1_ID}"`)) } } -config = replaceTomlValue(config, "bucket_name", envValue("R2_BUCKET_NAME")); +const explicitR2BucketName = envValue("R2_BUCKET_NAME"); +const explicitAuthUsername = envValue("AUTH_USERNAME"); +let inheritedR2BucketName; +let inheritedAuthUsername; + +if (isDeployCommand && (!explicitR2BucketName || !explicitAuthUsername)) { + const deployedWorkerName = config.match(/^name\s*=\s*"([^"]+)"/m)?.[1] ?? "edgeever"; + const statusResult = runWranglerSync( + [ + "--config", + baseConfigPath, + "deployments", + "status", + "--name", + deployedWorkerName, + "--json", + ], + { cwd: resolve("."), encoding: "utf8", env: process.env }, + ); + const missingWorker = statusResult.status !== 0 + && /(?:does not exist|code:\s*10007)/i.test(statusResult.stderr ?? ""); + + if (statusResult.status !== 0 && !missingWorker) { + throw new Error( + `Could not inspect the existing Worker before deployment: ${(statusResult.stderr ?? "unknown Wrangler error").trim()}`, + ); + } + + if (statusResult.status === 0) { + const versionIds = productionVersionIds(statusResult.stdout); + if (versionIds.length === 0) { + throw new Error("The existing Worker has no active production version to inspect."); + } + const deployedSettings = versionIds.map((versionId) => { + const versionResult = runWranglerSync( + [ + "--config", + baseConfigPath, + "versions", + "view", + versionId, + "--name", + deployedWorkerName, + "--json", + ], + { cwd: resolve("."), encoding: "utf8", env: process.env }, + ); + if (versionResult.status !== 0) { + throw new Error( + `Could not inspect deployed Worker version ${versionId}: ${(versionResult.stderr ?? "unknown Wrangler error").trim()}`, + ); + } + return deployedWorkerSettings(versionResult.stdout); + }); + const uniqueValue = (key) => [ + ...new Set(deployedSettings.map((settings) => settings[key]).filter(Boolean)), + ]; + const r2BucketNames = uniqueValue("r2BucketName"); + const authUsernames = uniqueValue("authUsername"); + if (r2BucketNames.length > 1 || authUsernames.length > 1) { + throw new Error( + "Active Worker versions use conflicting instance settings. Complete the rollout or set explicit EDGE_EVER_* Builds variables before deploying.", + ); + } + inheritedR2BucketName = r2BucketNames[0]; + inheritedAuthUsername = authUsernames[0]; + if (!explicitR2BucketName && inheritedR2BucketName) { + writeWranglerNotice("ok", `reusing existing R2 bucket ${inheritedR2BucketName}`); + } + if (!explicitAuthUsername && inheritedAuthUsername) { + writeWranglerNotice("ok", `reusing existing administrator username ${inheritedAuthUsername}`); + } + } +} + +const resolvedR2BucketName = explicitR2BucketName || inheritedR2BucketName; +const resolvedAuthUsername = explicitAuthUsername || inheritedAuthUsername || "admin"; + +config = replaceTomlValue(config, "bucket_name", resolvedR2BucketName); config = replaceTomlValue( config, "preview_bucket_name", @@ -240,14 +321,14 @@ config = replaceTomlValue( const runtimeVars = { // Keep the login identifier explicit in generated online deployments so // users can discover and override the default without changing code. - EDGE_EVER_AUTH_USERNAME: envValue("AUTH_USERNAME") || "admin", + EDGE_EVER_AUTH_USERNAME: resolvedAuthUsername, EDGE_EVER_SESSION_TTL_DAYS: envValue("SESSION_TTL_DAYS"), EDGE_EVER_AUTH_LOGIN_WINDOW_SECONDS: envValue("AUTH_LOGIN_WINDOW_SECONDS"), EDGE_EVER_AUTH_LOGIN_USERNAME_MAX_ATTEMPTS: envValue("AUTH_LOGIN_USERNAME_MAX_ATTEMPTS"), EDGE_EVER_AUTH_LOGIN_USERNAME_COOLDOWN_SECONDS: envValue("AUTH_LOGIN_USERNAME_COOLDOWN_SECONDS"), EDGE_EVER_AUTH_LOGIN_IP_MAX_ATTEMPTS: envValue("AUTH_LOGIN_IP_MAX_ATTEMPTS"), EDGE_EVER_AUTH_LOGIN_IP_COOLDOWN_SECONDS: envValue("AUTH_LOGIN_IP_COOLDOWN_SECONDS"), - EDGE_EVER_R2_BUCKET_NAME: envValue("R2_BUCKET_NAME"), + EDGE_EVER_R2_BUCKET_NAME: resolvedR2BucketName, EDGE_EVER_DEMO_MODE: envValue("DEMO_MODE"), EDGE_EVER_LOCAL_DEMO_SEED: envValue("LOCAL_DEMO_SEED"), // Auth-free access is a local-development capability. Remote deployments @@ -336,7 +417,6 @@ if (changed) { writeFileSync(generatedConfigPath, config); } -const isDeployCommand = wranglerArgs.includes("deploy"); const captureDeploymentTargets = isDeployCommand && shouldCaptureDeploymentTargets(); const deploymentTargetsPath = resolve(DEPLOYMENT_TARGETS_PATH); const hasSecretsFileArg = wranglerArgs.some((arg) => arg === "--secrets-file" || arg.startsWith("--secrets-file=")); diff --git a/scripts/wrangler-runner.mjs b/scripts/wrangler-runner.mjs index deb69d24..af83535a 100644 --- a/scripts/wrangler-runner.mjs +++ b/scripts/wrangler-runner.mjs @@ -134,6 +134,58 @@ export const findD1DatabaseIdByName = (json, databaseName) => { return typeof databaseId === "string" ? databaseId : undefined; }; +const parseWranglerObject = (json, description) => { + let value; + try { + value = JSON.parse(json); + } catch { + throw new Error(`Wrangler returned invalid JSON while reading ${description}.`); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Wrangler returned an unexpected response while reading ${description}.`); + } + return value; +}; + +export const productionVersionIds = (json) => { + const deployment = parseWranglerObject(json, "the production Worker deployment"); + if (!Array.isArray(deployment.versions)) { + throw new Error( + "Wrangler returned an unexpected response while reading the production Worker deployment.", + ); + } + return deployment.versions + .filter((version) => + typeof version?.version_id === "string" && Number(version?.percentage) > 0 + ) + .sort((left, right) => Number(right.percentage) - Number(left.percentage)) + .map((version) => version.version_id); +}; + +export const deployedWorkerSettings = (json) => { + const version = parseWranglerObject(json, "the deployed Worker version"); + const bindings = version.resources?.bindings; + if (!Array.isArray(bindings)) { + throw new Error( + "Wrangler returned an unexpected response while reading the deployed Worker version.", + ); + } + const resources = bindings.filter((binding) => + binding?.name === "RESOURCES" && binding?.type === "r2_bucket" + ); + const usernames = bindings.filter((binding) => + binding?.name === "EDGE_EVER_AUTH_USERNAME" && binding?.type === "plain_text" + ); + return { + r2BucketName: resources.length === 1 && typeof resources[0].bucket_name === "string" + ? resources[0].bucket_name + : undefined, + authUsername: usernames.length === 1 && typeof usernames[0].text === "string" + ? usernames[0].text + : undefined, + }; +}; + export const buildWranglerSpawnOptions = (args, options = {}) => { if (!isD1MigrationApplyCommand(args) || options.input !== undefined) { return options; diff --git a/tests/deployment-entrypoints.test.ts b/tests/deployment-entrypoints.test.ts index b006af43..1fc6f584 100644 --- a/tests/deployment-entrypoints.test.ts +++ b/tests/deployment-entrypoints.test.ts @@ -314,7 +314,14 @@ describe("Cloudflare deployment entrypoints", () => { writeFileSync( resolve(wranglerBinDirectory, "wrangler.js"), [ - 'if (process.argv.includes("deploy")) {', + 'if (process.argv.includes("deployments")) {', + ' process.stdout.write(JSON.stringify({ versions: [{ version_id: "legacy-version", percentage: 100 }] }));', + '} else if (process.argv.includes("versions")) {', + ' process.stdout.write(JSON.stringify({ resources: { bindings: [', + ' { name: "RESOURCES", type: "r2_bucket", bucket_name: "legacy-user-resources" },', + ' { name: "EDGE_EVER_AUTH_USERNAME", type: "plain_text", text: "legacy-owner" }', + ' ] } }));', + '} else if (process.argv.includes("deploy")) {', ' process.stdout.write("Uploaded edgeever\\nDeployed edgeever triggers (0.4 sec)\\n https://edgeever.example.workers.dev\\nCurrent Version ID: version-1\\n");', "}", "", @@ -342,6 +349,13 @@ describe("Cloudflare deployment entrypoints", () => { resolve(workingDirectory, ".wrangler.deployment-targets.json"), "utf8", ))).toEqual({ urls: ["https://edgeever.example.workers.dev"] }); + const generatedConfig = readFileSync( + resolve(workingDirectory, ".wrangler.generated.toml"), + "utf8", + ); + expect(generatedConfig).toContain('bucket_name = "legacy-user-resources"'); + expect(generatedConfig).toContain('EDGE_EVER_R2_BUCKET_NAME = "legacy-user-resources"'); + expect(generatedConfig).toContain('EDGE_EVER_AUTH_USERNAME = "legacy-owner"'); } finally { rmSync(workingDirectory, { force: true, recursive: true }); } diff --git a/tests/wrangler-runner.test.ts b/tests/wrangler-runner.test.ts index 63cca3d9..c3c236fd 100644 --- a/tests/wrangler-runner.test.ts +++ b/tests/wrangler-runner.test.ts @@ -6,9 +6,11 @@ import { buildWranglerInvocation, buildWranglerEnvironment, buildWranglerSpawnOptions, + deployedWorkerSettings, findD1DatabaseIdByName, isD1MigrationApplyCommand, normalizeD1MigrationSql, + productionVersionIds, LOCAL_DEV_CREDENTIALS_ENCRYPTION_KEY, resolveWranglerCliPath, resolveWranglerRuntimeExecutable, @@ -93,6 +95,36 @@ describe("cross-platform Wrangler runner", () => { ); }); + test("reads active production versions and legacy deployment settings", () => { + expect(productionVersionIds(JSON.stringify({ + versions: [ + { version_id: "version-old", percentage: 10 }, + { version_id: "version-current", percentage: 90 }, + { version_id: "version-inactive", percentage: 0 }, + ], + }))).toEqual(["version-current", "version-old"]); + + expect(deployedWorkerSettings(JSON.stringify({ + resources: { + bindings: [ + { name: "RESOURCES", type: "r2_bucket", bucket_name: "my-old-edgeever-bucket" }, + { name: "EDGE_EVER_AUTH_USERNAME", type: "plain_text", text: "owner" }, + ], + }, + }))).toEqual({ + r2BucketName: "my-old-edgeever-bucket", + authUsername: "owner", + }); + }); + + test("rejects malformed Worker deployment responses", () => { + expect(() => productionVersionIds("[]")).toThrow("unexpected response"); + expect(() => deployedWorkerSettings("not-json")).toThrow("invalid JSON"); + expect(() => deployedWorkerSettings(JSON.stringify({ resources: {} }))).toThrow( + "unexpected response", + ); + }); + test("keeps every D1 trigger on one physical line for remote compatibility", () => { const migrationSql = ["0001_initial.sql", "0013_mobile_sync_changes.sql"] .map((file) => readFileSync(resolve("migrations", file), "utf8"))