fix(deploy): preserve legacy R2 bindings
This commit is contained in:
1 parent
8e5e764bfb
commit
4cae0399d4
11 files changed
+196
-8
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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 项目的构建设置中,填入以下标准命令:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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、部署并验证。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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` | 自定义路由 |
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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="));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in new issue
Block a user