fix(deploy): preserve legacy R2 bindings

This commit is contained in:
tianma-if committed 2026-08-17 16:37:46 +08:00
1 parent 8e5e764bfb
commit 4cae0399d4
11 files changed
+196 -8

No files matched your search

+1
View File
@@ -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:
+1
View File
@@ -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 项目的构建设置中,填入以下标准命令:
+2
View File
@@ -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.
+2
View File
@@ -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、部署并验证。
+3 -1
View File
@@ -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 |
+3 -1
View File
@@ -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` | 自定义路由 |
+1 -1
View File
@@ -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]);
}
+84 -4
View File
@@ -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="));
+52
View File
@@ -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;
+15 -1
View File
@@ -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 });
}
+32
View File
@@ -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"))