main 已占用 202607190001(OpenResty 默认限流), 将 Pages source runtime / scanner seed 顺延为 202607190002、202607190003,并同步迁移测试期望配置数。
88 KiB
Pages 项目部署源与 GitHub Releases 自动更新 V2 实现方案
日期:2026-07-19
状态:代码实施完成(范围内自动化验证完成;生产环境验收见 §7)
方案版本:V2(设计修订版,不代表新增 /api/v2)
关联材料:
- 原方案:
20260718-pages-source-sync.md - 设计审核:
20260719-pages-source-sync-design-review.md - 表结构审核:
20260719-pages-source-sync-schema-revision.md
本文是完整、独立且可直接实施的技术方案,取代原方案成为该功能唯一实现基线。原方案与两份审核文档仅用于追溯设计演进;开发时不需要再将它们与本文拼接,也不得沿用其中与本文冲突的宽表、11 态状态机、
activate=false或四重 fence 设计。
0. 结论摘要
V2 保留原方案正确的主链路:外部来源只由 Server 控制面访问,所有包都进入统一的不可变 deployment 管线,Agent 仍只从 Server 拉取当前 active package。审核意见中的高优先级问题按以下规则一次性收敛:
- source 配置与运行态拆为
of_pages_project_sources、of_pages_project_source_runtime两张表;runtime 不再冗余project_id。 - 来源同步固定为“解析/下载 → 校验 → 创建或复用 deployment → 原子激活”,API 不提供
activate开关。 sync_status只保留idle | checking | update_available | syncing | failed | attention六态;排队状态使用现有TaskExecution,不在 source runtime 重复保存。- 互斥只由 runtime lease 负责;过期结果只在最终事务校验 source
config_version、projectcontent_config_version、lease_token与 lease 未过期时间,不再传递通用expected_revision。 - 人工激活不同 deployment 时,只要项目存在 source,就在同一事务中 fence 在途任务;若自动更新已开启,同时强制关闭,避免人工回滚被下一轮 latest 静默覆盖。
history_count=1时,手动上传允许临时保留 active 与最新 candidate 两条;激活后恢复严格上限。source sync 因创建与激活同事务完成,不产生未激活候选。- Remote URL 只支持手动“同步并发布”;只有 GitHub
latest支持定时检查和可选自动更新,GitHubtag只支持手动检查/同步。 - Remote URL 使用显式
remote_url_set控制“保留或替换”密文 URL;API、日志、任务 payload 和 deployment provenance 均不得泄漏 query token。 - 阶段 0 先修复
RootDir、归档真实展开限制、Agent 全量内存下载和历史裁剪问题,再接入远端来源。
1. 目标与背景 (Goal & Context)
1.1 方案制定时的实现与问题
方案制定时,Pages 已支持:
- 管理员本地上传压缩包;
- 同步调用
POST /api/v1/d/pages/:id/deployments/upload-from-url完成一次性 URL 导入; - 使用
RootDir + EntryFile检查归档并通过upload.Ingest保存; - 创建不可变 deployment、手动激活、保留历史版本;
- Agent 通过 latest hash/package 接口拉取 active deployment,并原子切换本地
current。
现状的主要问题不是缺少下载函数,而是缺少项目级、可持续管理的来源模型:URL 每次都要重新输入,GitHub Release 无版本游标和自动检查,任务与 deployment 也没有可审计的来源快照。同时,现有代码还存在必须在自动化前修复的边界:
RootDir已用于 Server 校验,但 OpenRestyLocalRoot未稳定追加该目录;- Agent 将整个 package 读入
[]byte,解压时关闭实际限制; - tar family 的部分检查/解压路径会按声明大小物化成员内容;
history_count=1时,刚上传的未激活 candidate 会被旧 active 挤掉;PolicyDedupNewRecord当前复制既有 record metadata,且新 record 持久化失败时可能误删复用的共享 object;- 一次性 URL client 允许私网与不安全 TLS,不能作为新持久来源的默认网络策略;
- 前端上传 payload、deployment 类型、固定入口文件提示和请求超时与后端契约存在漂移。
1.2 功能目标
本方案交付以下能力:
- Pages 项目可保持手动模式,或配置一个持久 Remote URL / GitHub Release 来源;
- Remote URL 可重复手动同步,每次按下载内容 SHA-256 幂等创建或复用 deployment 并激活;
- GitHub 支持
latest(默认)与固定tag,asset 名称默认精确匹配dist.zip; - GitHub
latest可按 5~1440 分钟间隔定时检查,默认 60 分钟;自动更新默认关闭; - 检查发现新版本但自动更新关闭时,只展示更新,不下载;
- 同一 Release 下 asset 被替换时进入
attention,必须由管理员确认指定 revision 后才允许同步; RootDir/EntryFile继续作为项目级内容配置统一作用于本地、Remote 与 GitHub 包,不在 source 中复制一套入口字段;- 所有来源统一使用现有 Pages 归档检查、上传、deployment、激活、历史裁剪和 Agent 分发链路;
- 自动化执行可互斥、可 fence、可恢复,且失败不会改变旧 active deployment。
1.3 来源能力矩阵
| 模式 | source 行 | 触发方式 | 检查更新 | 自动更新 | revision | 成功结果 |
|---|---|---|---|---|---|---|
| 手动本地上传 | 无 | 管理员上传 | 无 | 无 | 不用于幂等 | 创建 candidate,管理员再激活 |
| 一次性 URL(兼容) | 无 | 旧同步 API | 无 | 无 | 不用于幂等 | 每次创建 candidate,管理员再激活 |
| 持久 Remote URL | 有 | 管理员“同步并发布” | 不提供 | 不提供 | 下载内容 SHA-256 | 创建或复用并强制激活 |
| GitHub Release tag | 有 | 管理员检查/同步 | 仅手动 | 不提供 | Release/asset 元数据哈希 | 创建或复用并强制激活 |
| GitHub Release latest | 有 | 手动或 scanner | 定时 | 可选,默认关闭 | Release/asset 元数据哈希 | 创建或复用并强制激活 |
无 source 行即手动模式。切换或删除 source 不删除 deployment,也不改变当前 active deployment。
1.4 默认值
| 配置 | 默认值 | 边界 |
|---|---|---|
| GitHub selector | latest |
latest / tag |
| Release asset | dist.zip |
basename,精确且区分大小写 |
| 自动更新 | false |
仅 GitHub latest 可开启 |
| 检查间隔 | 60 分钟 | 5~1440 分钟 |
| scanner cron | */5 * * * * |
固定,无新增系统设置 |
| scanner 单批 | 20 个 source | 按 next_check_at, source_id 排序 |
| check lease | 2 分钟 | 到期可恢复 |
| sync lease | 15 分钟 | 长下载期间按需续租 |
| Remote 网络策略 | public |
public / trusted_internal |
1.5 非目标
本次不实现:
- GitHub 私有仓库、GitHub Token、GitHub App 或其它代码托管平台;
- source archive、
zipball_url/tarball_url回退; - asset glob、正则、优先级列表或 semver 自行排序;
- Remote URL 的定时轮询或自动更新;
- 多 source、分支构建、Webhook、CI 构建、预览环境;
remote_url数据库加密列;V2 先保证最小暴露和全链路脱敏;- 额外同步历史表、租约表、Provider 分表或全局 GitHub 响应缓存;
- Agent 直接访问 GitHub 或 Remote URL。
上述“仓库构建”属于明确的后续能力,不在 V2 偷跑实现;但 V2 的 Provider、部署来源视图和导入管线必须保留可扩展边界,避免未来只能把 Git clone/build 逻辑塞入 github_release 分支或重写 deployment 主链路。
2. 设计与决策 (Design & Decisions)
2.1 核心原则
- Server 单一信任边界:第三方网络访问、digest 校验与归档检查都在 Server 完成。
- source 可变,deployment 不可变:source 表示当前配置;deployment 保存创建时的最小来源快照,不随 source 编辑。
- 检查不等于部署:GitHub check 只更新远端游标;只有 sync 才下载、创建并激活。
- 成功才切换:网络和归档工作在事务外;active pointer、deployment 与 applied cursor 在最终事务原子提交。
- 状态面最小化:source runtime 只保存控制面稳定状态,队列细节和阶段日志复用现有 TaskExecution。
- 人工操作优先:人工激活或回滚必须 fence 自动任务,且不能被自动更新静默覆盖。
- 平台能力复用:文件摄取继续通过
upload.Ingest;普通文件删除使用upload.Remove/RemoveOwned,Pages 保留类型在复检业务引用后使用同包的RemoveLockedTx;任务继续使用现有 task/Asynq 框架。
2.2 总体架构
flowchart LR
Admin["管理员 / Pages 详情页"] --> SourceAPI["Pages Source API"]
SourceAPI --> ConfigDB[("Source Config")]
SourceAPI --> RuntimeDB[("Source Runtime")]
SourceAPI -->|"手动 check / sync"| ActionTask["Pages Source Action Task"]
Scheduler["Scheduler"] --> ScanTask["Pages Source Scan Task"]
ScanTask -->|"串行检查到期 latest"| GitHubAPI["GitHub Releases API"]
ScanTask --> RuntimeDB
ScanTask -->|"限量 orphan record 补偿"| Upload
ScanTask -->|"发现更新且 auto=true"| ActionTask
ActionTask --> Remote["Remote URL / GitHub Asset"]
ActionTask --> Pipeline["统一导入管线"]
Pipeline --> Inspect["真实展开与入口校验"]
Inspect --> Upload["upload.Ingest"]
Upload --> DeploymentDB[("Pages Deployments")]
DeploymentDB --> Activate["原子激活 + applied cursor"]
Activate --> RuntimeDB
Agent["Agent latest hash/package 对账"] --> DeploymentDB
Agent --> Current["projects/{id}/current"]
Current --> OpenResty["OpenResty 静态服务"]
scanner 本身是一个正式 TaskHandler,并非绕过任务框架。它在单次执行中先扫描并精确 CAS 恢复全部过期 lease,再限量补偿最多 100 条 orphan record,最后串行检查最多 20 个到期的 GitHub latest source,避免一次 cron 批量投递并行 GitHub 请求。手动操作和自动下载使用统一 action task;scanner 不执行长时间 package 下载。
2.3 领域对象与不变量
| 对象 | 生命周期 | 不变量 |
|---|---|---|
| PagesProject | 可变 | RootDir / EntryFile 的实质变化递增 content_config_version |
| PagesProjectSource | 可变配置 | 每项目最多一条;不保存状态、游标或 lease |
| PagesProjectSourceRuntime | 可变运行态 | 与 source 1:1;状态、游标、lease 只写本表 |
| PagesDeployment | 不可变事实 | 持久来源 revision 幂等;provenance 创建后不回写 |
| PagesDeploymentFile | 不可变清单 | 只属于一个 deployment |
核心不变量:
- source 与 runtime 必须同事务创建、同事务删除;无 source 就无 runtime。
- deployment 的
source_identity与source_revision必须同时为非空值或同时为 SQLNULL。 - 持久来源 sync 成功时,deployment、files、active pointer 和 runtime applied cursor 在同一事务提交。
- source/project 配置变化或人工激活可以使任务过期;过期任务不得改变 active、runtime 或其它 deployment。
- source API 永不返回完整 Remote URL,任务 payload 永不携带 URL。
2.4 数据模型
2.4.1 关系总览
of_pages_projects
└── 0..1 of_pages_project_sources
└── 1..1 of_pages_project_source_runtime
of_pages_projects
└── 0..N of_pages_deployments
└── 0..N of_pages_deployment_files
不建立物理外键;删除顺序由 Pages service 事务显式保证。
2.4.2 of_pages_project_sources:纯配置
| 字段 | 类型 / DB 默认 | 说明 |
|---|---|---|
id |
PK | source ID |
project_id |
bigint/integer | 项目 ID,唯一索引 |
source_type |
varchar(32), '' |
服务层写 remote_url / github_release |
remote_url |
text, '' |
仅 Remote;可含 query secret,禁止回显 |
remote_network_policy |
varchar(32), '' |
服务层写 public / trusted_internal |
github_repository |
varchar(255), '' |
规范化为 {owner}/{repo} |
release_selector |
varchar(16), '' |
latest / tag |
release_tag |
varchar(255), '' |
tag 模式必填,latest 必须空 |
asset_name |
varchar(255), '' |
服务层默认写 dist.zip |
auto_update_enabled |
bool, false |
仅 GitHub latest 可为 true |
check_interval_minutes |
int, 0 |
GitHub latest 服务层默认写 60 |
config_version |
int, 0 |
创建显式写 1;实质配置变化或人工 fence 时递增 |
source_identity |
char(64), '' |
无凭据的稳定身份 SHA-256 |
created_at / updated_at |
datetime | 审计时间 |
配置表禁止加入 sync_status、etag、last_seen_*、last_applied_* 或 lease_*。
2.4.3 source_identity
GitHub:
LP(value) = uint64be(byte_length(UTF8(value))) || UTF8(value)
SHA-256(
"openflare:pages:github-release:v2" ||
LP(owner_repo) || LP(selector) || LP(tag) || LP(asset_name)
)
GitHub identity 对每个 UTF-8 字段使用无歧义的长度前缀编码,不能使用分隔符直接拼接;自动更新开关和检查间隔不参与 identity。
Remote:
SHA-256("remote_url|" + canonical_scheme_host_port_path)
Remote canonical identity 使用小写 scheme/host、移除默认端口并保留规范化 path;明确排除 query、fragment 和 userinfo。下载 URL 仍保存管理员输入的完整值,但 URL userinfo 和 fragment 本身不允许保存。
identity 变化时,同事务重置 runtime 的 ETag、seen/applied cursor、detail、错误、检查时间和 lease;当前 active deployment 不变。仅 query token、自动更新、检查间隔或网络策略变化时 identity 不变,保留 cursor,但仍递增 config_version、清 lease 并按现有 cursor 重算稳定状态。
2.4.4 of_pages_project_source_runtime:纯运行态
| 字段 | 类型 / DB 默认 | 说明 |
|---|---|---|
source_id |
PK | 与 source 1:1 的逻辑关联 |
etag |
varchar(512), '' |
GitHub 条件请求 |
last_seen_revision |
char(64), '' |
最近解析到的 revision |
last_seen_detail |
text, '' |
已校验的安全 JSON 对象字符串 |
last_applied_revision |
char(64), '' |
当前 source 视角下已激活 revision |
last_applied_detail |
text, '' |
与 applied revision 配套的安全 JSON |
sync_status |
varchar(32), '' |
创建时服务层显式写 idle |
last_error |
text, '' |
脱敏后的最近错误 |
last_checked_at |
nullable datetime | GitHub 最近完成检查时间 |
last_synced_at |
nullable datetime | 最近成功同步并激活时间 |
next_check_at |
nullable datetime | 仅 GitHub latest 非空;普通索引 |
lease_expires_at |
nullable datetime | 当前租约截止时间 |
lease_token |
varchar(64), '' |
每次获取租约生成的新 token |
updated_at |
datetime | 运行态更新时间 |
runtime 刻意不保存 project_id:scanner 本来就必须 join source 读取 source_type、selector 和 config version;重复保存 project ID 只会引入漂移和额外索引。scanner 查询以 next_check_at 索引定位 runtime,再 join source。
detail 使用跨 PostgreSQL/SQLite 一致的 text,并由 Go typed struct 统一 marshal/unmarshal;比较和幂等只读取 revision 列,禁止解析 JSON 做 CAS。GitHub detail 最小形状为:
{
"provider": "github",
"release_id": "123456",
"asset_id": "789",
"tag": "v1.2.3",
"asset_name": "dist.zip",
"asset_updated_at": "2026-07-18T12:00:00Z",
"digest": "sha256:..."
}
Remote detail 只保存无密钥显示信息,例如:
{
"provider": "remote_url",
"display_name": "dist.zip"
}
2.4.5 状态机
状态固定为:
idle | checking | update_available | syncing | failed | attention
stateDiagram-v2
[*] --> idle
idle --> checking: GitHub check
update_available --> checking: 再次 check
failed --> checking: 重试 check
attention --> checking: 再次 check
checking --> idle: 无更新
checking --> update_available: 有更新且不自动同步
checking --> attention: 同 Release asset 被替换
checking --> failed: 检查失败
idle --> syncing: 手动 sync
update_available --> syncing: 手动或自动 sync
failed --> syncing: 手动重试
attention --> syncing: 确认指定 revision
syncing --> idle: 同步并激活成功
syncing --> failed: 下载/校验/提交失败
syncing --> attention: 替换风险未确认
约定:
syncing覆盖下载、校验、Ingest、创建和激活;详细阶段只写 task 日志。failed可以与“已有待更新 revision”同时存在;API 的update_available始终由 revision 派生,而非由状态字符串判断。attention是 GitHub 供应链确认状态,不等同于普通失败。queued/succeeded属于w_task_executions,不进入 runtime。
派生规则:
update_available =
last_seen_revision != '' AND
last_seen_revision != last_applied_revision
2.4.6 of_pages_projects 增量
新增:
| 字段 | 类型 / 默认 | 说明 |
|---|---|---|
content_config_version |
int, 0 |
仅 RootDir / EntryFile 实质变化时 +1 |
SPA Fallback、API Proxy、名称、描述、启停等变化不影响归档内容校验,不递增该版本。
2.4.7 of_pages_deployments 精简 provenance
| 字段 | 类型 / DB 默认 | 说明 |
|---|---|---|
source_type |
varchar(32), '' |
manual_upload / manual_url / remote_url / github_release |
source_identity |
nullable char(64) | 持久 source 快照;手动/一次性 URL 必须为 SQL NULL |
source_revision |
nullable char(64) | 持久 source 幂等键;手动/一次性 URL 必须为 SQL NULL |
source_label |
varchar(255), '' |
tag 或安全文件名,不含 query |
source_meta |
text, '' |
安全 JSON 审计快照,不含 URL/token |
trigger_type |
varchar(32), '' |
manual_upload / manual_url / manual_sync / scheduled_auto_update |
不再增加独立的 release/asset/digest 宽列;这些只在 source_meta 保留审计快照。deployment 列表 API 只返回安全的 source_type、source_label、trigger_type,不直接输出原始 meta JSON。
revision 生成:
github_raw = github:<release_id>:<asset_id>:<asset_updated_at>:<declared_digest>
github_revision = SHA-256(github_raw)
remote_revision = SHA-256(downloaded_package_bytes)
GitHub 未提供 digest 时,declared_digest 为空;同步仍必须计算 package SHA-256 作为 deployment checksum。若 GitHub 提供 sha256: digest,则下载后必须严格校验。
2.4.8 索引与迁移
索引:
UNIQUE of_pages_project_sources(project_id)
INDEX of_pages_project_source_runtime(next_check_at)
UNIQUE of_pages_deployments(project_id, deployment_number)
UNIQUE of_pages_deployments(project_id, source_identity, source_revision)
WHERE source_identity IS NOT NULL AND source_revision IS NOT NULL
PostgreSQL 与 SQLite 均创建同语义的部分唯一索引。禁止用空字符串代替 deployment 的 NULL provenance,否则手动重复上传会被误判为同一来源版本。
新增双方言 migration:
202607190002_add_pages_source_runtime.sql:两张 source 表、project content version、deployment provenance、索引和存量回填。202607190003_seed_pages_source_scan.sql:幂等插入of_pages_source_scan的 5 分钟 schedule。
存量 deployment 只能可靠回填为 source_type=manual_upload、trigger_type=manual_upload,identity/revision 保持 NULL;现有记录无法反推出是否来自旧一次性 URL。schedule seed 不写死 ID,使用 WHERE NOT EXISTS (task_type = 'of_pages_source_scan'),Down 仅按该 task type 删除。
2.4.9 写入矩阵
| 操作 | source config | runtime | deployment |
|---|---|---|---|
| 创建 source | 新建 | 同事务新建 idle | 不变 |
| 编辑 source | 实质变化时 version +1 | identity 变则 reset,否则保留 cursor、清 lease | 不变 |
| 删除 source | 删除 | 同事务删除 | 全部保留 |
| GitHub check / 304 | 不变 | seen、时间、状态、下次检查 | 不变 |
| source sync | 不变 | syncing → applied/idle | 创建或复用并激活 |
| 人工激活/回滚 | 必要时关闭 auto、version +1 | 清 lease,按目标 provenance 更新 applied | 切 active |
| project 删除 | 删除 | 先删除 | 按现有流程删除 |
2.5 任务、租约与并发
2.5.1 任务类型
只新增两个任务:
| Meta Type | Asynq Type | 职责 |
|---|---|---|
of_pages_source_scan |
openflare:pages_source_scan |
恢复过期 lease/orphan record;串行检查一批到期 GitHub latest source;必要时投递 sync action |
of_pages_source_action |
openflare:pages_source_action |
执行管理员 check/sync 或 scanner 触发的 sync |
两者都在 internal/task/handlers/register.go 显式注册 Handler 与 TaskMeta。现有 bootstrap.RegisterTasks() 已覆盖 API、worker、scheduler 和 all 入口,不新增 init(),也不修改 internal/router/router.go、internal/bootstrap/bootstrap.go 或 internal/cmd 的装配职责。
两类任务都标记为 TaskMeta.InternalOnly=true。通用 Admin Task 类型列表、手工 dispatch 与 schedule 创建/更新必须隐藏或拒绝 internal-only meta;scheduler 与 Pages 内部 dispatch 仍使用完整 registry。这样客户端不能绕过 Pages Handler 自行伪造 source_id、config_version 或 actor。
action payload 只包含:
{
"source_id": 42,
"config_version": 3,
"action": "check",
"actor": "user:1234567890",
"target_revision": "",
"confirmed_revision": ""
}
规则:
action仅为check/sync;target_revision只由 scanner 在自动 sync 时写入,用于锁定本次 check 发现的 revision;手动 sync 为空;confirmed_revision只在确认attention时携带 UI 当前看到的精确 revision;不用单纯 boolean 确认未知的未来版本;- payload 不携带 Remote URL、GitHub 下载 URL、ETag、
content_config_version或通用expected_revision;target_revision是自动检查结果约束,不参与配置 fencing; actor手动操作为user:<id>,自动任务为system:pages-source-sync,禁止空字符串表示系统。
调用 task.DispatchTask 时,框架级 triggeredBy 继续使用 manual / system;具体操作者只放在已校验且无密钥的 action payload 中,供 deployment created_by 与审计日志使用。
该 payload 是 Server 内部契约:HTTP Handler 只接受 action 所需业务字段,再从路由项目、当前 source 和 OAuth context 组装 source_id/config_version/actor,禁止客户端直接指定或冒充这些值。
content_config_version 在 sync Worker 获取 lease 后读取并形成执行快照,最终事务再次检查。这样既能阻止旧入口配置被激活,又不把每次项目变更传播进队列 payload。
2.5.2 lease 规则
lease 只解决“同一 source 同时只能有一个执行者”:
- check 获取 2 分钟短 lease,并将状态切为
checking; - sync 获取 15 分钟长 lease,并将状态切为
syncing; - 获取使用
source_id + config_version + lease 已过期的 CAS; - 续租、状态写入、终态和释放必须同时满足
lease_token匹配且lease_expires_at > now;续租不再 join project/source 版本; - 最终事务前强制续租一次;最终提交仍必须再次检查 token 与未过期时间,不能让“尚未被新 Worker 改写 token 的过期 lease”通过;
- source 配置变化、RootDir/EntryFile 变化或人工激活统一调用
fenceAndNormalizeRuntime:清 token/expiry;若当前 seen/applied 仍构成同 Release 替换则为attention,否则 seen≠applied 为update_available,其余为idle;source 删除则同事务直接删除 runtime/source,行不存在即 fence; - 未拿到 lease 的重复任务写一条 no-op task 日志并成功结束,不制造 runtime 错误。
最终提交的锁顺序固定为:
project -> source(存在时) -> runtime(存在时) -> upload(所有相关 ID 升序)
提交前只校验:
source.config_version == captured_source_version
project.content_config_version == captured_content_version
runtime.lease_token == worker_token
runtime.lease_expires_at > transaction_now
target_upload.status == used
source/runtime 条件只适用于持久 source;本地上传和一次性 URL 仍必须先锁 project、最后锁目标 upload。create-or-load 选中的既有 deployment 与本次新建但最终未使用的 upload 不同时,两个 upload ID 在最后一层按升序加锁,避免多行反序。上述任一条件不满足,任务按“配置、执行权或上传记录已变化”结束,不覆盖新 runtime 状态;若本次创建了 upload record,则进入补偿。revision 幂等由 deployment 部分唯一索引负责,不再增加第四套通用 revision fence。
2.5.3 scanner 流程
每 5 分钟执行:
- 扫描所有 runtime 中 lease 已过期且状态为
checking/syncing的行;恢复 UPDATE 必须再次 CAS 原 token 且lease_expires_at <= now,避免覆盖刚续租的 Worker。成功后清 lease、状态设为failed,记录“上次任务租约已过期”,GitHub latest 的next_check_at调整为近期重试。 - 执行 2.7.2 的限量 orphan record reconciliation;单条失败只告警并保留候选,不中断 source 检查。
- join source 查询
github_release + latest + next_check_at <= now,按next_check_at, source_id排序,最多取 20 条。 - 对每条 source 尝试获取短 lease;失败说明另一个 scanner/action 已处理,直接跳过。
- 在当前 scanner TaskHandler 内串行调用 GitHub check,共享同一 check service;单个 source 失败只落该 runtime,不中断其它 source。
304仍更新last_checked_at/next_check_at,并根据已保存的 seen/applied revision 重新判断是否待同步。- 发现更新后无论 auto 开关,都先原子写 seen cursor、将状态落为
update_available并释放短 lease;auto 开启时再投递带本次target_revision的action=sync,sync Worker 获取长 lease 后才切为syncing。 - sync 入队失败:保持
update_available,记录安全错误,并把next_check_at调整为短退避,后续 scanner 可再次尝试。 - 下次检查时间使用 interval 加 source-ID 派生的小幅 jitter,避免整点集中请求。
scanner 直接串行 check 而不是先批量投递 check action,目的是减少 GitHub 并发和一层“派发预占”状态。重叠的 scanner 实例仍通过每个 source 的 lease 互斥;不增加全局 scanner 锁。
2.5.4 action 流程
手动 check:
- Handler 校验 source 为 GitHub;Remote 直接返回稳定 400,不入队。
- action Worker 校验 payload
config_version,获取短 lease。 - 解析 Release/asset,更新 seen、ETag、检查时间与状态后释放 lease。
- 手动 check 永远不隐式下载;即使 auto 已开启,也只由 scanner 检查路径触发自动 sync,避免“点击检查”产生意外发布。
手动或自动 sync:
- 校验 source/config version,获取长 lease并读取 project content version。
- GitHub 在 lease 内重新解析目标 Release/asset;Remote 直接下载。这样刚保存 source 时无需等待一次 check 才能同步。
- scanner 自动 sync 若携带
target_revision,本次新解析 target 必须与其相等;不相等说明 latest 在 check 与执行间变化,任务将新 target 安全写为 seen,按本次 target 归一为attention/update_available,释放 lease 并把next_check_at提前,禁止直接部署未经原 check 锁定的新 revision。 - 非空
confirmed_revision必须先与本次 target 完全相等,否则要求刷新后重试;再以本次 target 与 applied detail 判断同 Release 替换,构成替换且未确认当前 target 时写attention并停止。 - 流式下载、digest/checksum 校验、归档检查与 Ingest。
- 进入最终事务完成 create-or-load、激活与 applied cursor;提交后严格裁剪历史。
永久业务错误(非法配置、asset 不存在、未确认 attention)通过 task 框架的 PermanentError 包装为 asynq.SkipRetry,不进行 Asynq 快速重试;瞬时网络/存储错误按 TaskMeta 的有限次数退避重试。包装后的 Error() 只暴露脱敏 domain message。重复任务、旧 config version 和丢失 lease 作为成功 no-op 结束,避免无意义重试。Provider/Action Handler 在把 error 返回 task executor 前必须转换为不含 URL/query/header/body 的安全 domain error;原始错误也只能经统一 URL 脱敏后写内部日志,防止 TaskExecution error_message/log/result 持久化密钥。
2.6 Provider 设计
2.6.0 Provider 扩展边界与未来仓库构建
V2 Provider 只负责把某个外部来源解析为一个经过约束的不可变归档候选,不负责直接写 deployment、切 active 或操作 Agent。Pages service 继续统一承担归档检查、upload.Ingest、deployment create-or-load、激活、历史裁剪与补偿。当前 Remote URL 与 GitHub Release 都实现这一窄边界。
为后续“从仓库拉代码自动构建”预留以下设计约束,但本期不增加数据库列、API 或空实现:
- 后续新增独立
git_repositorysource/provider,禁止复用或扩展github_release语义;Release asset 是预构建产物来源,repository source 是源码与构建来源,两者凭据、revision、失败阶段和 UI 配置完全不同。 - repository provider 的输出仍必须是临时目录中的受限归档/构建产物描述,再进入现有统一导入管线;build checkout、依赖安装、命令执行和日志隔离属于未来独立 build executor,不进入 Agent,也不绕过
upload.Ingest。 - source view 与前端表单继续使用 discriminated union;未来可以新增 repository variant,而无需给 Remote/GitHub Release 视图加入无关的 branch、build command、output directory 或 environment 字段。
- deployment provenance 保留
source_type/source_identity/source_revision/source_label/source_meta/trigger_type的通用事实边界;未来 repository revision 可使用 commit SHA,安全source_meta可保存 branch/build 输出摘要,但不得保存凭据或完整环境变量。 - TaskExecution 继续承载阶段日志。未来构建可增加 resolve/checkout/build/package 阶段,但 source runtime 不因此扩展为构建步骤状态机。
该边界参考 Cloudflare Pages 当前将 Git integration 与 Direct Upload 分成不同来源体验、同时把生产部署与历史部署统一呈现的产品结构;OpenFlare 保留自己的“来源可切换且历史部署不删除”决策,不照搬 Cloudflare 创建后不可切换来源的限制。
2.6.1 持久 Remote URL
Remote 来源只提供“同步并发布”,不提供 check、定时检查或自动更新。每次同步:
- 按 source 保存的 network policy 构建下载 client;
- 流式写入 Server 临时文件,同时计算 SHA-256 和实际压缩包大小;
- 以内容 SHA-256 生成 revision;若同 identity/revision deployment 已存在,跳过 Ingest,直接进入安全激活;
- 新 revision 使用统一归档/上传/激活管线;
- 成功后 seen 与 applied 同时更新为该 revision,状态回到
idle。
归档格式优先使用配置 URL path 的安全 basename;名称缺失或无可识别扩展名时,使用 pagesarchive.DetectFormat 对临时文件至少前 512 字节做 magic sniff,覆盖 tar 在偏移位置的签名,不能沿用当前仅 16 字节的探测。redirect 最终 URL 和 Content-Disposition 不进入 provenance,避免签名地址或不可信文件名泄漏。
Remote URL 的 query 可用于签名 token。API 返回:
has_remote_url=true;display_url=https://example.com/dist.zip?***;- 永不返回原始 URL。
编辑时使用显式 remote_url_set:
- 新建 Remote、从 GitHub 切换到 Remote:必须为
true且 URL 非空; - 编辑现有 Remote 但只改 network policy:必须为
false,同时省略remote_url; - 替换地址:为
true并提交新 URL; false却携带 URL,或true但 URL 为空,均返回 400;- 前端绝不能把
display_url当作可保存值。
2.6.2 GitHub Releases
仓库地址只接受:
https://github.com/{owner}/{repo}
https://github.com/{owner}/{repo}.git
保存时规范化为 {owner}/{repo};拒绝非 https、非 github.com、userinfo、query、fragment、额外 path 及空 owner/repo。V2 只访问公开仓库。
Release 解析:
- latest:
GET /repos/{owner}/{repo}/releases/latest;采用 GitHub 的 latest 语义,不拉列表、不自行比较 semver; - tag:
GET /repos/{owner}/{repo}/releases/tags/{url.PathEscape(tag)};固定 tag 不进入 scanner; - asset:只接受
state=uploaded且name == asset_name的精确、区分大小写匹配; - asset 不存在时,安全错误最多列出该 Release 前 10 个 asset 名,单项与总错误长度均截断;
- 不回退到源码 archive。
API client 使用新的窄包 internal/integration/githubrelease,集中 Release/asset HTTP 契约、redirect、ETag 与限流解析;Pages 模块只负责 source 规则、revision 和状态映射。当前 node/edge/admin updater 的旧实现不在本功能中强制迁移,但后续新增调用方必须复用该包,避免继续增加 feature-local GitHub client。
- 发送
Accept: application/vnd.github+json、固定User-Agent;实现基线固定X-GitHub-Api-Version: 2026-03-10,收敛为一个常量; - 保存 ETag 并发送
If-None-Match; - 处理
Retry-After、X-RateLimit-Remaining、X-RateLimit-Reset,按服务端指示设置next_check_at,禁止紧循环; - asset 下载使用
/repos/{owner}/{repo}/releases/assets/{asset_id}与Accept: application/octet-stream,兼容200内容和302跳转; - GitHub 始终使用严格 TLS;asset redirect 仅允许 HTTPS、最多 5 次,每跳解析并校验公网 IP;跨 host 删除
Authorization、Cookie、Referer和条件请求 header; - 元数据与下载错误只保留 status、request id、repo、tag、asset 等安全上下文。
参考官方文档:
- GitHub Releases REST API
- GitHub Release Assets REST API
- GitHub REST API 最佳实践
- GitHub REST API Rate Limits
未认证公共请求存在严格额度,V2 通过 ETag、串行 scanner、jitter 与服务端退避降低消耗,不承诺大规模仓库轮询。多项目共享仓库缓存留到出现真实规模瓶颈后再设计。
2.6.3 attention 与 digest 失败边界
- 每次 check/sync 都以本次新解析的 target 判断:
target.release_id == applied.release_id且 revision 变化时进入attention;禁止用过期的 runtime seen 代替本次 target; - 管理员同步时必须提交与当前 seen 完全相等的
confirmed_revision;状态变化后旧确认自动失效; - declared digest 与实际 package checksum 不一致:
failed,不能用 attention 确认绕过; - 同一 revision 重复点击由部分唯一索引和 lease 双重保证只产生一条 deployment;
- 后续 latest 已推进到不同 release ID 时,不再满足同 Release 替换条件,应转为普通
update_available并按 auto 策略继续;attention 不设计成永久 hold。
2.7 统一导入、激活与回滚
2.7.1 source sync 原子提交
source sync 不创建长期 candidate,固定执行以下顺序:
- 获取 source lease,快照 source config version、project content version、
RootDir、EntryFile。 - 事务外解析并流式下载到临时文件,计算 checksum;临时文件在所有退出路径删除。
- 使用快照的
RootDir + EntryFile做真实展开限制、路径与入口校验,得到 manifest。 - 先查询相同 project/source identity/revision 的 deployment;存在则不调用 Ingest。
- 不存在时调用
upload.Ingest,使用现有 Pages upload type 与PolicyDedupNewRecord;upload metadata 的Extra写入固定 marker 版本、十进制字符串形式的pages_project_id及可选pages_source_id,供孤儿补偿判断,绝不写 URL 或 token。平台需先修正 dedup 新记录语义:新 record 采用本次请求的业务 metadata,仅从既有 object 继承存储归属Bucket,不能继续复制既有 record 的业务Extra;dedup record 写库失败时也绝不能删除并非本次 Ingest 创建的共享 object。 - 最终事务先按
project -> source -> runtime加锁并校验双 version、lease token/expiry;project 锁同时串行化本项目所有 V2 deployment 创建、激活、裁剪与 orphan 判定。 - create-or-load 必须使用 GORM
clause.OnConflict{DoNothing: true}(或等价INSERT ... ON CONFLICT DO NOTHING),再按(project_id, source_identity, source_revision)查询 winner,禁止依赖普通唯一冲突后继续查询已 aborted 的 PostgreSQL 事务。若冲突仅来自 deployment number 且 revision winner 不存在,则在 project 锁内重新分配编号并有限重试。 - 确定目标 deployment 后,将目标 upload 与本次 Ingest upload(若不同)按 ID 升序锁定;目标 upload 必须仍为
used。唯一竞争产生的多余 upload 只记录为事务后的补偿目标,禁止在 Pages 事务内调用另起事务的upload.Remove。 - 取消旧 active、激活目标 deployment、更新 project active pointer,并更新 runtime applied/seen/status/时间。
- 提交后立即补偿未被采用的 upload,再执行严格历史裁剪;事务回滚则补偿本次 Ingest upload。裁剪失败不回滚已成功激活,但必须告警并由下一次裁剪自愈。
任何最终事务前的失败都保持旧 active。created_by / trigger_type 约定:
| 触发 | created_by |
trigger_type |
|---|---|---|
| 本地上传 | user:<id> |
manual_upload |
| 一次性 URL | user:<id> |
manual_url |
| 持久来源手动 sync | user:<id> |
manual_sync |
| scanner 自动更新 | system:pages-source-sync |
scheduled_auto_update |
2.7.2 Ingest 补偿与延迟记录恢复
upload.Remove 当前只会软删除 upload record、调整统计并失效缓存,不会删除底层 object;因此实现与验收不得宣称 defer 调用后物理文件已回收。
新创建的 Pages upload record 统一使用以下无密钥 marker;pages_source_id 只在持久 source sync 时存在,手动上传与一次性 URL 省略该键:
{
"pages_ingest_marker": "pages_deployment_v2",
"pages_project_id": "123",
"pages_source_id": "456"
}
ID 使用十进制字符串,cleanup 必须严格解析并校验关联归属;marker 不作为权限凭证,只作为“允许进入 Pages 孤儿判定”的一个条件。project_slug、归档格式等可由正式模型/Upload 列获得且当前无读取方,不再复制进新 record 的 Extra。
openflare_pages_deployment 由 upload 平台集中定义并导出为保留 type,Pages 与通用 Handler 复用同一常量:通用 POST /api/v1/upload 必须拒绝客户端提交该值,通用管理员/用户删除入口及 upload.Remove / RemoveOwned 也必须拒绝删除该类型;cleanup 候选还必须满足 user_id == repository.GetSystemUser(ctx).ID。marker、保留 type、system owner 三项缺一不可,避免普通用户伪造 metadata 后被后台任务误删。
V2 采用两层处理:
- 立即补偿:只要 Ingest 创建了新 upload record 而最终事务未引用它,就调用 Pages 内部
removePagesUploadIfUnreferenced;该函数锁 project(存在时)与 upload、再次确认没有任何 deployment 引用,再调用upload.RemoveLockedTx。补偿错误必须写可告警日志,不能_ =静默忽略。 - 延迟记录补偿:Pages scanner 每轮最多选择 100 条超过 2 小时、状态仍为
used、system owner、type 为openflare_pages_deployment、无 deployment 引用且带 V2 Pages marker 的 upload。这覆盖“立即补偿调用本身失败”的恢复路径;PostgreSQL 使用 JSONB 路径、SQLite 使用json_extract将 marker 纳入 SQL 候选条件,避免存量合法记录长期占满批次。任一条件不满足的记录一律跳过,禁止仅凭 type/时间推断孤儿。
upload.Remove、RemoveOwned 与 Pages 内部删除路径必须共用同一幂等删除原语:事务内锁定包含 deleted 状态的 record,再由 RemoveLockedTx 以 id + status IN (pending, used) 做 CAS;只有 RowsAffected == 1 才递减统计,已 deleted 视为成功 no-op。事务成功后无论本次是否发生状态迁移都失效该 record 的 metadata cache,以便顺带修复前次提交后 cache invalidation 中断;这样立即补偿、延迟补偿、历史裁剪和管理员删除并发时不会重复扣减统计。Remove / RemoveOwned 在锁内发现保留 type 时返回稳定 domain error,不得调用 RemoveLockedTx。
cleanup 最终 recheck 与软删除必须在同一数据库临界区完成:
- 事务外读取 candidate 快照,严格解析 system owner、marker、
pages_project_id/pages_source_id;格式错误直接跳过并告警; - 事务内统一按
project -> source(存在时) -> runtime(存在时) -> upload加锁;项目或 marker 指向的 source 已不存在属于合法 orphan 场景,应继续检查;只有 source ID 仍存在但其project_id与 marker 不同才跳过并告警。禁止先锁 upload 再反向读取 runtime; - source 存在时若 runtime 有未过期 lease,回滚并跳过;随后锁 upload,再次确认 ID、marker、归属、
used状态和 2 小时阈值; - 在持有 project/upload 锁的情况下确认不存在任何 deployment 引用该 upload;所有 V2 deployment 创建路径也必须遵循同一锁顺序,避免检查后又插入引用;
- 通过 upload 平台提供的事务内幂等
RemoveLockedTx完成软删除与统计更新,提交后统一失效 upload metadata cache;业务模块不得直接调用 repository 或改w_uploads; - Pages 最终提交若后获得 upload 行锁,必须因 status 已 deleted 而终止;若 deployment 提交先完成,cleanup 在引用检查时跳过。两者竞争时只能有一方成功,绝不允许 deployment 指向 deleted upload。
V2 不物理删除 object,也不硬删除 upload record:PolicyDedupNewRecord 可能共享 file_path,当前平台没有能与并发 dedup 创建原子协调的引用锁/引用计数,先检查再删除仍有竞态。软删除后的 object 和记录保持可识别,待 upload 平台提供安全的统一 blob GC 后回收;Pages 业务包不得直接调用 storage backend。清理失败保留 active 候选供下次重试,并输出数量与错误上下文。
同一安全边界也适用于现有 system:cleanup:阶段 0 将 pending upload 清理收敛为 RemoveLockedTx 的记录级软删除、统计与 cache 失效,停止直接 backend.Delete(file_path)。仅增加“是否还有 active record”检查仍无法闭合“检查后并发 dedup 新 record”的竞态,不能作为物理删除依据;所有 upload blob 的物理回收统一留给未来具备引用协调能力的平台 GC。
2.7.3 人工激活/回滚硬约束
通过现有 activation API 人工激活不同于当前 active ID 的 deployment 时:
- 按全局顺序锁 project、当前 source(如有)、runtime 与目标 deployment 的 upload;目标 upload 非
used时拒绝激活; - 若存在 source,始终
config_version + 1并清 lease,fence 已排队和正在执行的 source task; - 若
auto_update_enabled=true,同事务强制改为 false; - 目标 deployment identity 等于当前 source identity 时,将 runtime applied 更新为目标 revision/detail;否则清空 applied;若归一后的 seen/applied 仍构成同 Release 替换则保持
attention,否则状态为idle或update_available; - 切换 active deployment 后提交;
- 输出结构化审计日志:actor、project、旧/新 deployment、是否关闭 auto、目标 source type/identity(不含 URL)。
重复激活当前 active 视为 no-op,不关闭自动更新。该规则刻意比“只在 identity 不同才关闭”更严格:即使回滚到同一 GitHub source 的旧 revision,下一轮 latest 也可能覆盖人工选择。
前端确认框必须明确提示:“激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。”成功后同时刷新 project、source 与 deployment queries。
2.7.4 history_count=1
手动上传仍保留“先上传 candidate、再人工激活”的现有交互,但裁剪增加 preserveCandidateID:
- 上传完成后保留当前 active 与本次新 candidate;即使 history limit 为 1,也允许临时最多 2 条;
- 再次上传时只保护 active 与最新 candidate,旧 candidate 可被裁剪;
- candidate 激活后执行 strict prune,不再传 preserve ID,恢复总数
<= history_count; - source sync 在同一事务内创建并激活,提交后直接 strict prune;
- prune 删除 deployment/files 后,artifact record 也统一交给
removePagesUploadIfUnreferenced;通用文件管理永远不直接删除 Pages 保留类型; history_count<=0继续表示不限制。
手动 candidate 创建与 source 最终提交都先锁 project 再分配 deployment number,并由 (project_id, deployment_number) 唯一索引兜底。这是一项明确的产品例外,不新增 candidate 状态或额外保留配置。
preserve/strict prune 每次都在事务内先锁 project,再重新读取 active 与候选集合后决定删除项;禁止沿用事务外快照做删除判断。deployment/files 提交后,待删除 artifact 再交给无引用复检路径软删除。
2.8 安全与数据面前置修复
2.8.1 RootDir / EntryFile
统一使用一个严格的逻辑路径规范化函数:
RootDir允许空字符串表示归档根目录;非空 RootDir 与 EntryFile 只接受 UTF-8 相对 POSIX 路径,空 EntryFile 由服务层归一为index.html;- 拒绝绝对路径、
./..segment、反斜线、Windows drive、NUL/控制字符、引号、分号及超长值; - 逻辑归档路径用
path处理,不用平台相关filepath;落盘路径仍用filepath并再次执行目录逃逸检查; - project 已有 active deployment 时,更新 RootDir/EntryFile 前用现有 deployment file manifest 验证新入口存在;失败保持原配置;
- 阶段 0 先完成严格校验、manifest 验证与 LocalRoot 一致性;阶段 1 随 source DDL 增加
content_config_version后,实质变化再递增该版本并清当前 source lease; - snapshot 与 rebind 构建
LocalRoot时安全追加规范化 RootDir,确保 Server 检查路径与 OpenResty 实际服务路径一致。
归档继续保留现有 common-root 语义:若所有文件共享唯一首层目录,检查与解压都会先剥离该目录,随后再解释 RootDir。阶段 0 以测试固化该规则,未来仓库构建产物也必须输出符合相同 artifact contract 的目录结构。
2.8.2 Remote SSRF 与 TLS
public 策略:
- 仅允许
http/https,最多 5 次 redirect,不使用环境代理; - 每次连接前解析 host,拒绝 loopback、private、link-local、multicast、unspecified 及其它非公网地址;
- 自定义
DialContext直接连接已校验 IP,不能在校验后再次按 host 解析,防止 DNS rebinding; - 每次 redirect 重新执行 scheme、host 与 IP 检查;
- HTTPS 严格证书验证,禁止
InsecureSkipVerify; - 设置连接、响应头、整体下载超时,并以实际流量强制 package size 上限。
专用 client 通过 pkg/httppool 新增的可配置 transport factory 复用连接池参数与 OTel instrumentation,同时显式注入 no-proxy、受控 DialContext 和 TLS policy;不能直接使用当前会读取环境代理的 DefaultTransport()。
trusted_internal 策略是管理员显式选择的信任边界:允许私网目标与自签 TLS,但仍执行 http(s)、redirect、超时、真实大小和归档限制;UI 必须展示醒目风险提示。新 source 默认永远是 public。
旧 upload-from-url 为兼容现有行为,内部映射到共享 downloader 的 trusted-internal 兼容策略,不再保留第二套 HTTP client;新 UI 不再暴露该入口。
2.8.3 归档真实限制
Server 与 Agent 都必须按实际读取字节执行:
- 压缩包字节数、单文件展开字节数、总展开字节数、文件数;
- Content-Length/asset size 只用于提前拒绝,不能代替流式上限;
- 拒绝 Zip-Slip、绝对路径、Windows drive、symlink、hardlink、device/特殊条目;
- tar/tar.gz/tar.xz/tar.bz2 检查与解压不得把所有成员 body 物化到内存;
- zip/7z 声明大小必须在实际复制时再次验证;
- Server manifest 中的
file_count/total_size来自实际检查结果。
继续复用现有 Pages 设置:压缩包默认 100 MiB、硬上限 2048 MiB、文件数 1000、展开总量按现有规则计算;不新增一组 source 专用大小设置。
2.8.4 Agent 流式下载与本地硬上限
latest hash 响应扩展为:
{
"project_id": 1,
"deployment_id": 2,
"hash": "sha256-hex",
"package_size": 1048576,
"file_count": 128,
"total_size": 8388608
}
Agent:
- 先读取 metadata,并拒绝超过 Agent 编译期绝对上限的值;绝对上限不得被 Server 响应放大。
- 将 package response 流式写入 release 临时文件,使用
io.LimitedReader约束实际压缩字节,并在写入同时计算 SHA-256。 - 再次读取 latest metadata;hash/deployment 发生变化时删除临时文件并按现有有限次数重试。
- 使用
pagesarchive.ExtractFile的流式实现解压到.tmp,开启文件数、单文件和总量限制;Server metadata 只作为更小的预期上限,仍受本地绝对 cap 约束。 - 完整校验、写 marker 后才原子切换 current;失败保留旧 current。
编译期绝对上限固定为压缩包 2 GiB、文件数 1000、单文件 8 GiB、总展开 8 GiB,与 Server 当前硬边界一致;Server metadata 只能收紧这些值。file_count>0 && total_size=0 是全部零字节文件的合法情况,不能被 limits 的默认值逻辑放大。
Agent 继续只访问 Server,不解析 source provenance,也不访问第三方 URL。
2.9 API 与鉴权
沿用当前 Pages/admin action-style 路由;所有接口使用 apiutil.AdminMiddlewares(),成功 HTTP 200,错误通过 response.Abort* 交给全局 ErrorHandler。
| 方法 | 路由 | 语义 |
|---|---|---|
| GET | /api/v1/d/pages/:id/source |
返回 discriminated source view;无 source 返回 manual |
| POST | /api/v1/d/pages/:id/source/update |
创建或更新 source |
| POST | /api/v1/d/pages/:id/source/delete |
幂等切回 manual;deployment/active 保留 |
| POST | /api/v1/d/pages/:id/source/check |
GitHub 手动检查;Remote 返回稳定 400 |
| POST | /api/v1/d/pages/:id/source/sync |
Remote/GitHub 同步并强制激活 |
2.9.1 Source update payload
Remote:
{
"source_type": "remote_url",
"remote_url_set": true,
"remote_url": "https://artifacts.example.com/dist.zip?token=secret",
"remote_network_policy": "public"
}
GitHub latest:
{
"source_type": "github_release",
"repository_url": "https://github.com/owner/repo",
"release_selector": "latest",
"asset_name": "dist.zip",
"auto_update_enabled": false,
"check_interval_minutes": 60
}
GitHub tag:
{
"source_type": "github_release",
"repository_url": "https://github.com/owner/repo",
"release_selector": "tag",
"release_tag": "v1.2.3",
"asset_name": "dist.zip"
}
使用 discriminated validation:Remote 不接受 GitHub/auto 字段;tag 不接受开启 auto 或非零 interval;latest 必须没有 tag;模式外已知字段非零即 400。数据库产品默认由 service 归一并显式写入,不依赖 GORM/DB 默认推断。
source type 切换时必须在同一事务清空另一 Provider 的全部列:Remote → GitHub 清除完整 remote_url/network_policy,GitHub → Remote 清除 repository/selector/tag/asset/auto/interval。禁止只改 source_type 而让 query token 或失效配置继续滞留数据库。
GitHub source 新建或实质更新成功后,在数据库事务提交后异步投递首次 check;无实质变化不重复投递。队列入队不是数据库事务的一部分,因此入队失败时 source 仍保存成功:响应中的 check_task=null、warning 给出可重试提示,同时 runtime 标为 failed;用户可点击检查,latest scanner 也会在近期重试。
创建或更新 latest source 时先将 next_check_at 设为 now + interval + jitter;tag 始终为 NULL。首次 check 入队失败时将 latest 的 next_check_at 提前到下一轮 scanner,成功 check 则按 interval 重算。首次 check 本身只负责发现版本,不因保存动作隐式发布;若管理员同时开启 auto,后续 scanner 或显式 sync 再执行发布。
update 响应:
{
"error_msg": "",
"data": {
"source": {},
"check_task": {
"task_id": "manual_of_pages_source_action_...",
"execution_id": "1234567890",
"action": "check"
},
"warning": ""
}
}
2.9.2 Source view
manual:
{
"source_type": "manual"
}
Remote view 只返回 Remote 有效字段:
{
"source_type": "remote_url",
"has_remote_url": true,
"display_url": "https://artifacts.example.com/dist.zip?***",
"remote_network_policy": "public",
"sync_status": "idle",
"last_applied": {
"revision": "sha256-hex",
"label": "dist.zip"
},
"last_synced_at": "2026-07-19T10:00:00Z",
"last_error": ""
}
GitHub view:
{
"source_type": "github_release",
"github_repository": "owner/repo",
"release_selector": "latest",
"release_tag": "",
"asset_name": "dist.zip",
"auto_update_enabled": false,
"check_interval_minutes": 60,
"sync_status": "update_available",
"update_available": true,
"last_seen": {
"revision": "revision-hex",
"label": "v1.2.3",
"asset_name": "dist.zip"
},
"last_applied": {
"revision": "revision-hex",
"label": "v1.2.2",
"asset_name": "dist.zip"
},
"last_checked_at": "2026-07-19T10:00:00Z",
"last_synced_at": "2026-07-18T10:00:00Z",
"next_check_at": "2026-07-19T11:00:00Z",
"last_error": ""
}
API 不返回 config/content version、lease、ETag、raw detail JSON、GitHub asset URL 或完整 Remote URL。detail 先反序列化为内部 typed struct,再映射为上述安全 view。
2.9.3 Action request/receipt
check 无请求体。普通 sync 的规范请求体为 {};Handler 同时把空 body 的 io.EOF 视为默认空请求,避免 BaseService.post(..., undefined) 稳定返回 400。只在 attention 确认时提交:
{
"confirmed_revision": "revision-hex-currently-shown"
}
action 成功入队返回:
{
"task_id": "manual_of_pages_source_action_...",
"execution_id": "1234567890",
"action": "sync"
}
Handler 在 task.DispatchTask 返回后,按 task ID 读取已先创建的 TaskExecution,并返回 numeric execution ID 的字符串形式。前端复用现有 task execution detail API 轮询 pending/running/succeeded/failed,source runtime 不增加 queued 状态。
典型错误:
| 条件 | HTTP | 文案语义 |
|---|---|---|
| payload/模式字段非法 | 400 | 指出当前来源允许的配置 |
| Remote 调用 check | 400 | 远程地址来源不支持检查更新,请使用立即同步 |
| attention 未确认或确认已过期 | 400 | 要求刷新并确认当前 revision |
| 项目/source 不存在 | 404 | 安全的资源不存在提示 |
| source 有有效 lease | 409 | 来源任务正在执行 |
| 入队/数据库内部失败 | 500 | 通用安全提示,底层错误写日志 |
上述 attention/lease 检查是 Handler 的 best-effort preflight;preflight 与 Worker 获取 lease 之间仍可能发生竞态。竞态中的权威结果由 Worker 的 target revision、lease 与最终事务校验决定,并通过脱敏的 TaskExecution 成功 no-op 或失败结果反馈,API 不承诺把所有异步竞态同步映射成 400/409。
2.9.4 旧一次性 URL
POST /api/v1/d/pages/:id/deployments/upload-from-url 在 V2 保留:
- Swagger description 标记 Deprecated;
- 不创建 source,不写 identity/revision,每次仍创建新的 manual URL candidate;
- 内部复用新的流式 downloader、归档校验和 candidate 裁剪规则;
- 为保持兼容,映射到 trusted-internal 网络策略;
- 新前端移除入口,最早在下一个 major version 才考虑删除。
2.10 前端方案
2.10.1 页面结构
当前 detail/page.tsx 仅转发 page-client.tsx,且 page-client.tsx 已接近复杂度阈值。V2 将路由骨架、标题、外层布局与 Suspense 直接移回物理入口 page.tsx,再拆出高状态密度组件;禁止继续保留纯转发页面:
detail/page.tsx
├── pages-source-card.tsx
├── pages-source-dialog.tsx
├── deployment-history.tsx
└── deployment-files-panel.tsx
六个 source status 的 badge/文案映射直接放在 pages-source-card.tsx,不再创建薄的 pages-source-status.tsx。现有 page-client.tsx 的剩余 query/交互逻辑在拆分后移入对应业务组件,不再作为同名页面容器保留。
信息层级参考 Cloudflare Pages 当前项目页,但使用 OpenFlare 现有设计系统实现,不复制品牌视觉:
- 顶部项目摘要优先显示当前生产部署、入口路径与关键动作;
- “部署源”卡片单独表达当前 source、远端游标与同步动作,来源设置不与 deployment 行内操作混杂;
- “部署历史”展示不可变部署事实与来源快照,当前 active 置顶突出,历史回滚保持显式确认;
- source dialog 以 manual / Remote URL / GitHub Release 的分步选择呈现;未来新增 repository source 时只增加新的 discriminated step,不改写现有三类表单字段。
2.10.2 能力分离
Remote 卡片只显示:
- 脱敏 URL、network policy、最近同步、已应用 revision、最近错误;
- “编辑来源”“同步并发布”“切换回手动”;
- 不显示检查、自动更新、检查间隔或 next check。
编辑 Remote 默认 remote_url_set=false 并展示只读 masked URL;用户点击“更换地址”后才出现空输入框。trusted_internal 需要二次风险提示。
GitHub latest 卡片显示检查、同步、自动更新、间隔、远端/已应用版本和 next check。GitHub tag 显示手动检查/同步,隐藏自动更新与周期字段。attention 使用 Alert + 确认弹窗,提交卡片当前 revision。
source 历史信息与 deployment 历史分工:
- source 卡片显示当前远端状态;
- deployment 行只显示创建时快照,例如
GitHub · v1.2.3 · 定时更新; - 历史区域明确标注“部署时来源快照”,不重复展示远端最新状态。
2.10.3 上传与契约修复
DeploymentUploadDialog移除 URL tab,只保留本地上传;- 显示项目实际
root_dir + entry_file,不再硬编码index.html; - multipart 只发送
package,删除后端未消费的 root/entry 字段; PagesDeployment类型删除后端不返回的root_dir/entry_file,增加安全 provenance 字段;- 兼容 URL service 使用与后端 10 分钟相容的 timeout,直到 UI/API 最终移除;
- deployment query 的
isError单独渲染错误组件,不能降级成“暂无部署”。
2.10.4 轮询
- 用户 action 拿到
execution_id后轮询现有 TaskExecution;pending/running 继续,succeeded/failed 停止; - 终态统一 invalid project/source/deployments/files queries;失败展示 TaskExecution 安全文案并重新读取 source
last_error; - source status 为 checking/syncing 时,以约 2 秒频率刷新 source;
- GitHub latest 空闲时以低频刷新或在
next_check_at附近刷新,确保 scanner 发现更新后页面无需手动刷新;Remote/tag 空闲时不持续轮询; - 所有轮询设置前端最长等待时间,超时停止自动请求并提供手动刷新;
- 操作按钮在本地 mutation、TaskExecution pending/running 或 source lease busy 任一条件成立时禁用。
在实现任何 Next.js 改动前,先读取 frontend/node_modules/next/dist/docs/ 中与 App Router、Client Component、数据获取相关的当前版本文档,并遵循项目 shadcn 与页面拆分规范。
2.11 日志、可观测性与敏感信息
source status 不承担详细执行日志。TaskExecution 日志使用稳定阶段前缀:
[check] [resolve] [download] [verify] [ingest] [activate] [cleanup]
日志可以记录 source/project ID、repo、tag、asset name、revision 前缀、HTTP status、GitHub request ID、字节数和耗时;不得记录 Remote 原始 URL/query、asset 临时下载 URL、Cookie/Authorization 或响应 body。
scanner TaskResult 和结构化日志至少记录:
- 到期总数、选取数、成功/失败/跳过数;
- 检查 backlog;
- GitHub 403/429 与退避截止时间;
- 自动 sync 投递成功/失败数;
- lease 过期恢复数。
- orphan 候选、已补偿、仍被引用、lease busy、非法 marker 与失败数。
当前仓库没有统一业务 metrics abstraction,V2 不为该功能单独引入一套指标框架;后续接入全局 OTel metrics 时再把上述计数提升为 metrics。
2.12 关键取舍
| 决策 | 采用方案 | 未采用方案与原因 |
|---|---|---|
| runtime project ID | 不冗余,scanner join source | 冗余列需额外一致性维护,且无法消除读取 config 的 join |
| sync 语义 | 固定创建/复用并激活 | activate=false 与 history=1 冲突,并扩大 UI/状态机 |
| 状态 | 六态 | 11 态与 TaskExecution 重复,容易卡在中间态 |
| scanner | TaskHandler 内串行 check,自动更新再投 sync | 批量投递 check 会放大 GitHub 并发并需要派发预占状态 |
| fencing | lease + source/project 双 version 最终校验 | 通用 expected revision 是第四套重复 fence;仅 attention 使用精确确认 revision |
| 回滚 | 人工激活其它部署即 fence;auto 强制关闭 | 只靠 UI 提示无法阻止下一轮 latest 覆盖回滚 |
| Remote URL 编辑 | remote_url_set 显式保留/替换 |
masked URL 回填、空串或省略语义容易误清密钥 |
| GitHub client | 新建窄 internal/integration/githubrelease 包,Pages 复用 |
仓库已有多套 feature-local Release 访问,再新增 Pages 私有 client 会继续扩大重复;本阶段不强制迁移旧调用方 |
| orphan upload | Pages 无引用复检软删除 + scanner 延迟记录补偿;物理 blob GC 后续统一建设 | Pages 直接删 storage 违反平台边界且可能误删 dedup 共享对象;通用 upload cleanup 反向依赖 Pages 状态也会破坏模块边界 |
| scanner 批量 | V2 固定 20,并记录 backlog | 现阶段新增系统设置只扩大配置面;出现真实容量瓶颈后再配置化或改延迟任务 |
3. 具体修改文件清单 (Proposed Changes)
以下为实施边界;同一阶段可在不改变职责的前提下合并测试文件,不应再拆出只有常量转发的薄文件。
3.1 后端 Server
[NEW] internal/model/openflare_pages_source.go
PagesProjectSource、PagesProjectSourceRuntime模型与表名。
[MODIFY] internal/model/openflare_pages.go
- project content version;deployment nullable provenance。
[NEW] internal/apps/openflare/pages/source.go
- discriminated input/view、默认值、identity、脱敏、source CRUD。
[NEW] internal/apps/openflare/pages/source_provider.go
- Provider 内部接口、Remote public/trusted downloader、共享流式下载结果。
[NEW] internal/integration/githubrelease/client.go
- 可复用的 GitHub latest/tag/asset client、ETag、rate limit、受控 redirect 与安全错误。
[MODIFY] pkg/httppool/httppool.go
- 增加保留现有池参数/OTel 的可配置 transport factory,供 SSRF-safe DialContext、no-proxy 与 TLS policy 使用;默认 client 行为不变。
[NEW] internal/apps/openflare/pages/source_sync.go
- Remote/GitHub 统一 ingest、deployment create-or-load、原子激活与失败补偿。
[NEW] internal/apps/openflare/pages/source_runtime.go
- source execution snapshot、短/长 lease、heartbeat、失败终态、过期 lease 精确 CAS 恢复。
[NEW] internal/apps/openflare/pages/github_source.go、github_source_action.go
- GitHub 配置归一化,以及 latest/tag check、ETag/304、精确 revision sync、attention 与 provider 退避。
[NEW] internal/apps/openflare/pages/source_tasks.go
- action TaskMeta、旧/新 payload normalization、actor/trigger 边界与 Handler。
[NEW] internal/apps/openflare/pages/source_scanner.go
- internal-only scanner、过期 lease 恢复、20 条稳定批次、403/429 退避、backlog 与精确 revision 自动派发。
[NEW] internal/apps/openflare/pages/source_orphan_cleanup.go、internal/model/openflare_pages_cleanup.go
- 100 条/2 小时隔离的 orphan upload 候选查询、统一锁序复检、幂等软删除与缓存修复。
[MODIFY] internal/apps/openflare/pages/logics.go
- 统一 deployment 创建/激活;created_by/provenance;人工回滚硬约束;candidate/strict prune;Pages artifact 无引用复检删除。
[MODIFY] internal/apps/openflare/pages/helpers.go
- 严格 RootDir/EntryFile、真实归档限制、manifest 与 Agent metadata;移除对 Pages 保留 type 的通用
upload.Remove调用。
[MODIFY] internal/apps/openflare/pages/download_url.go
- 旧 URL 导入改用共享 downloader,删除独立不安全 client 分叉;无扩展名归档使用至少 512 字节 format sniff。
[MODIFY] internal/apps/openflare/pages/routers.go
- 5 个 source Handler;从 OAuth context 获取真实 user ID;Swagger;旧 URL deprecated。
[MODIFY] internal/apps/openflare/pages/errs.go
- source、Provider、lease 与 attention 的稳定安全错误文案。
[MODIFY] internal/router/v1/openflare/register_pages.go
- 注册 5 条 source 路由;不在顶层 router 直接挂业务 Handler。
[MODIFY] internal/task/handlers/register.go
- 显式注册 scanner/action Handler 与 TaskMeta。
[MODIFY] internal/apps/upload/ingest/helpers.go
PolicyDedupNewRecord的新 record 使用本次请求业务 metadata,并只继承既有 object 的Bucket,保证每条业务记录的归属信息独立。- 将“本次是否真实写入 object”作为持久化失败补偿的显式条件;dedup record 创建/统计失败不得删除复用的既有
file_path。
[MODIFY] internal/apps/upload/ingest/remove.go、internal/apps/upload/exports.go
- 增加仅供已持有 upload 行锁的事务编排使用的
RemoveLockedTx,统一 CAS 软删除与统计更新;Remove/RemoveOwned也改用该原语并将已删除视为 no-op,但对 Pages 保留 type 返回稳定拒绝错误。 - 提供提交后调用的 cache invalidation 出口,禁止调用方直接依赖 upload cache 子包。
[MODIFY] internal/repository/upload.go
- upload 软删除更新增加 active status 条件并返回
RowsAffected,保证只有一次真实状态迁移会触发统计扣减。
[MODIFY] internal/apps/upload/task/cleanup.go
- pending upload 清理改用幂等软删除原语并停止直接删除可能被 dedup record 共享的 object;物理 blob GC 不在 Pages V2 内伪实现。
[MODIFY] internal/apps/upload/handler/routers.go、file_management.go、logics.go
- 通用上传 API 拒绝创建 Pages 保留 type,通用管理员/用户文件删除拒绝移除该 type,并同步更新 Swagger 错误说明。
[MODIFY] internal/apps/upload/shared/constants.go、errs.go
- 在 upload 平台集中定义保留 type
openflare_pages_deployment与安全错误,由exports.go导出并供 Pages/Handler 共用。
[NEW/MODIFY TEST] Pages、upload 与 model tests
source_test.go、source_provider_test.go、source_sync_test.go、internal/integration/githubrelease/client_test.go与pkg/httppool/httppool_test.go;logics_test.go、routers_test.go、internal/apps/upload/ingest/helpers_test.go、internal/apps/upload/ingest/remove_test.go、internal/apps/upload/handler/routers_test.go、internal/apps/upload/task/tasks_test.go;- model/迁移测试覆盖 NULL 部分索引与双版本。
3.2 数据库迁移
[NEW] PostgreSQL
internal/db/migrator/goose/postgres/202607190002_add_pages_source_runtime.sqlinternal/db/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql
[NEW] SQLite
internal/db/migrator/goose/sqlite/202607190002_add_pages_source_runtime.sqlinternal/db/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql
版本号若已被其它分支占用,实施时只顺延编号,不改变 DDL/DML 拆分。
SQLite 0001 的 Down 必须通过重建受影响表完整移除新增列、约束与索引,不接受只删除 source/runtime 表却遗留 project/deployment 列的伪回滚;PostgreSQL 与 SQLite 都需要真实 Up/Down/Up 验证。
3.3 Agent、协议与归档库
[MODIFY] pkg/pagesarchive/entry.go、path.go、inspect.go、list.go、extract.go、limits.go
- tar family 流式检查/解压、实际字节限制、特殊条目拒绝与 ExtractFile。
[MODIFY] pkg/protocol/agent.go
- latest hash response 增加 package/file/total size。
[MODIFY] internal/apps/openflare/agent/routers.go
- 返回 Agent 限额 metadata,保持 package 流式响应。
[MODIFY] internal/apps/agent/httpclient/client.go
- package 下载从
[]byte改为受限流式写入。
[MODIFY] internal/apps/agent/sync/service.go、pages.go
- client interface、临时文件、hash race 复核、ExtractFile 与本地绝对 cap。
[MODIFY] internal/apps/openflare/config_version/pages_snapshot.go、internal/apps/openflare/pages/rebind.go
LocalRoot安全追加规范化 RootDir。
3.4 前端 Web
[MODIFY] frontend/lib/services/openflare/types.ts
- source union、action receipt、safe provenance;清理 deployment/upload 漂移字段。
[MODIFY] frontend/lib/services/openflare/pages.service.ts、index.ts
- 5 个 source API;本地 upload 只发 package;兼容 URL timeout。
[NEW] frontend/app/(main)/pages/detail/components/pages-source-card.tsx
- source query、能力分离、状态与 TaskExecution 轮询。
[NEW] frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx
- Remote/GitHub discriminated form、URL replacement 与 trusted warning。
[NEW] frontend/app/(main)/pages/detail/components/deployment-history.tsx
- deployment query、激活/删除、历史 provenance 和回滚提示。
[NEW] frontend/app/(main)/pages/detail/components/deployment-files-panel.tsx
- deployment files query 与错误态。
[MODIFY] frontend/app/(main)/pages/detail/page.tsx
- 直接承载路由骨架、标题、布局与 Suspense,并组合上述组件。
[DELETE] frontend/app/(main)/pages/detail/page-client.tsx
- 拆分完成后移除纯转发容器;业务逻辑归入 page 与就近子组件。
[MODIFY] frontend/app/(main)/pages/components/deployment-upload-dialog.tsx、pages-utils.ts
- 本地上传单模式、真实入口显示与 source query key。
[MODIFY/NEW TEST] 前端测试
frontend/tests/openflare/pages-service.test.tsfrontend/tests/openflare/pages-source-ui.test.tsxfrontend/tests/openflare/pages-source-auto-update.test.tsx
3.5 文档与生成物(代码实施时)
[MODIFY]
docs/design/pages-design.mddocs/design/index.mddocs/design/architecture.mddocs/guide/pages-usage.mdREADME.mddocs/changelog/index.md的[Unreleased](仅实际代码变更后)
API 实现后运行 make swagger 更新 docs/docs.go、docs/swagger.json、docs/swagger.yaml,禁止手工编辑生成物。本计划文档不加入 docs/config.ts 的用户文档导航。
4. 验证计划 (Verification Plan)
4.1 数据库与模型
- PostgreSQL/SQLite 空库 Up、现有库升级和 Down;
- source/runtime 同事务 1:1,无 source 项目保持 manual;
- config/runtime 无审核中已删除的宽表冗余列;
- identity 变化 reset runtime,query token/策略变化保留 cursor;
- 持久 source 同 revision 并发只产生一条 deployment;
- PostgreSQL/SQLite 的 create-or-load 使用 conflict-do-nothing 后可在同一事务读取 winner;deployment number 独立冲突能有限重试;
- 手动/旧 URL identity/revision 为 NULL,可重复导入相同包;
(project_id, deployment_number)并发唯一;- schedule seed 无固定 ID、可重复 Up,Down 不影响其它 schedule。
建议:
go test ./internal/db/migrator ./internal/model
4.2 source、Provider 与 API
- Remote/GitHub discriminated validation、默认值与非法模式字段;
- Remote
remote_url_set新建/保留/替换全部分支; - Remote/GitHub 双向切换会清空非当前 Provider 列,旧 query token 不残留;
- Remote 无扩展名 tar 与常见合法扩展名归档均能识别,redirect/Content-Disposition 不污染安全 label;
- URL/repository 规范化、identity 不包含凭据;
- source view、错误、task payload、deployment meta、日志均无 query token;
- GitHub latest/tag endpoint、exact asset、asset 缺失的有限候选列表;
- ETag/304、200/302 asset、403/404/429、Retry-After/reset;
- asset redirect 仅 HTTPS、最多 5 次、逐跳公网 IP 校验,并移除敏感 header/Referer;
- digest 正确、不匹配、缺失;
- Remote check 稳定 400;source delete 幂等且保留 active/history;
- 普通上传 API 提交保留 type
openflare_pages_deployment时稳定拒绝;管理员/用户通用删除 API 对该类型同样返回稳定冲突,已被 deployment 引用与暂时无引用两种情况都不能绕过; - source save 后 check 入队成功与“配置已保存但入队失败”的部分成功语义;
- Handler 全部使用 Abort*,Swagger 声明实际 Failure 状态。
- Provider/TaskResult/TaskExecution 的
error_message/log/result不含 Remote query token、临时下载 URL 或敏感 header。
GitHub/Remote 使用 httptest.Server 或可注入 RoundTripper,不在普通单测访问真实外网。
4.3 并发、状态与回滚
- 六态转换,无 queued/succeeded runtime 残留;
- 重复 action 只有一个 lease owner,其余 no-op;
- lease 到期但 token 尚未被接管时,旧 Worker 仍无法续租/写终态/激活;scanner 随后恢复 failed;
- source 编辑/删除期间的旧任务不能提交;
- 下载期间修改 RootDir/EntryFile,旧 content version 不能激活;
- scanner 单个 source 失败不阻塞后续 source;304 且已有待更新 revision 时仍能自动投递;
- scanner 看到 revision A、sync 执行时 latest 已变为 B:
target_revision不匹配,A/B 均不被该任务激活,并提前下一次检查; - auto=false 只更新 cursor,不下载;tag 不进入 scanner;
- 人工激活其它 deployment 时 fence 在途任务、关闭 auto,并正确更新/清空 applied;
- 同 identity 旧 revision 回滚同样关闭 auto;重复激活当前 active 不关闭;
- same-release replacement 进入 attention,错误 confirmed revision 不能绕过;digest mismatch 进入 failed;
- attention 后 latest 推进到不同 release ID 时恢复普通 update_available/auto 路径,不形成永久 hold;
- source sync 任何失败均保留旧 active。
- 复用已有 deployment 或人工激活时,目标 upload 已 deleted 会被拒绝,不产生失效 active pointer。
4.4 历史与上传补偿
- history=1 手动上传后保留 active + 最新 candidate;再次上传替换旧 candidate;
- candidate 激活后严格恢复 1 条;source sync 激活后只保留新 active;
- Ingest 成功但最终事务失败时 upload record 被软删除并记录补偿结果;
PolicyDedupNewRecord复用 object 时,新 record 保留本次请求的 Pages marker/项目/source metadata,仅继承既有 object 的Bucket;- 故障注入 dedup record 创建或统计失败,既有 upload/object 仍可读取,只有本次真实新写 object 才允许在持久化失败时删除;
- 立即
removePagesUploadIfUnreferenced失败时 record 保持used;scanner 隔离期后只处理带 V2 marker 且无 deployment 引用的 Pages upload; - 普通用户即使伪造保留 type、完整 marker 和真实项目/source ID,也因 HTTP 保留 type 校验与 system owner 双重条件不会进入 cleanup;
- 对普通 upload,
Remove、RemoveOwned并发删除同一 record 时只有一次RowsAffected=1,上传统计只扣减一次;Pages 补偿/cleanup/历史裁剪共享同一断言; - source lease 未过期时 cleanup 跳过;过期 Worker 不能续租,后续最终提交因 lease/upload 状态失败;
- cleanup 与最终部署事务按同一
project -> source -> runtime -> upload顺序竞争,分别覆盖 cleanup 先提交、deployment 先提交两种结果,断言不存在指向 deleted upload 的 deployment; - 管理员通用删除与人工激活并发时删除请求被保留 type 策略拒绝,激活只可能看到
usedtarget; - marker 缺失/损坏或仍存在的 source 归属不一致时只告警并跳过;项目/source 已删除时按合法 orphan 继续补偿;
- PostgreSQL JSONB 与 SQLite
json_extract候选查询只选 V2 marker,并以 100 条为批次上限; - cleanup 不物理删除 object、不硬删记录,dedup 共享 file path 不受影响;
system:cleanup对过期 pending record 只做一次软删除/统计扣减,不再调用 storage backend 删除共享 object;- record 补偿失败保留重试候选并产生可告警日志。
4.5 归档、网络与 Agent
- chunked 实际 body 超限、伪造 Content-Length、单文件/总量/文件数超限;
- zip/tar/tar.gz/tar.xz/tar.bz2/7z 的合法包与压缩炸弹;
- Zip-Slip、绝对路径、Windows drive、symlink/hardlink/special entry;
- public 拒绝 loopback/private/link-local、redirect 到私网和 DNS rebinding;
- public 拒绝自签 TLS,trusted_internal 仅显式选择后允许;
- Agent 大包不进入
[]byte,实际下载超过 metadata/absolute cap 即失败; - metadata 被伪造为超大值时本地 cap 仍生效;
- hash race 不切换 current;解压失败保留旧 current;
- RootDir + EntryFile 从上传检查到 OpenResty root/index 端到端一致;
- 自动激活后 Agent 通过现有周期 latest 对账拉取,无需发布新的主配置。
建议:
go test ./pkg/pagesarchive \
./internal/apps/openflare/pages \
./internal/apps/openflare/agent \
./internal/apps/agent/httpclient \
./internal/apps/agent/sync \
./internal/apps/upload/ingest \
./internal/apps/upload/handler \
./internal/apps/upload/task
4.6 前端
- 三类 source view 与 latest/tag/Remote 能力分离;
- Remote masked URL 不会被保存回后端,replace 开关 payload 正确;
- attention revision 确认、trusted warning、回滚关闭 auto 提示;
- TaskExecution pending/running/terminal 轮询与超时;
- scanner 自动更新后的低频 source 刷新;
- deployment query 错误不显示为空列表;
- 本地上传只发 package,显示真实入口;
- 用户输入完整 URL 时只存在于受控输入框与本地 form state;保存后不重新渲染原值,也不进入 masked view、toast、console、日志或测试 snapshot。输入框使用 password/reveal 交互。
建议:
cd frontend
pnpm exec vitest run
pnpm exec tsc --noEmit
pnpm lint
4.7 最终项目门禁与手工验收
代码完成后:
make swagger
make prettier
make code-check
手工最小矩阵:
- 本地上传 → candidate → 激活 → Agent current 更新;
- public Remote 同步相同/不同内容,验证复用与新 deployment;
- trusted internal Remote 的私网/自签场景与风险提示;
- GitHub tag 检查、同步;
- GitHub latest 检查到更新,auto off 只提示;auto on 自动激活;
- 自动更新后人工回滚,验证 auto 被关闭且下次 scanner 不打回 latest;
- asset 同 Release 替换,验证 attention 与精确 revision 确认;
- Server/Worker 在下载、Ingest、最终提交不同阶段中断,验证旧 active、lease 恢复与 orphan upload 记录补偿。
5. 分阶段实施
阶段 0:安全与一致性前置(已完成:4e8ec232)
- RootDir/EntryFile 严格路径与 LocalRoot 端到端一致;
- Server 真实归档限制和 tar 流式实现;
- Agent 流式下载、ExtractFile、metadata 与绝对 cap;
- history=1 candidate preserve/strict prune;
- created_by 真实 actor;
- upload dedup 新记录 metadata/对象所有权语义、幂等 CAS 软删除、HTTP 保留 type 创建/删除边界;现有 Pages prune/补偿切换到无引用复检删除并记录错误,
system:cleanup停止不安全的物理 object 删除;物理 blob GC 保持独立平台后续项。
验收:不引入 source 表/API 的情况下,现有本地上传与旧 URL 路径全部回归;大包内存与路径安全测试通过。
阶段 1:数据模型与 Remote 手动同步(已完成:38b05169)
0001DDL migration、model、source CRUD/view;- runtime 六态、lease、config/content fence;
- Remote public/trusted downloader;
- action sync、原子 create-or-load/activate;
- Remote source card/dialog;旧 URL UI 移除但 API 保留。
验收:Remote 只能手动同步并发布;相同内容幂等;失败保持旧 active;立即补偿失败可观测;URL 全链路脱敏。
本阶段 API/DTO 变化完成后立即运行 make swagger 并将生成物纳入阶段验证,不把 Swagger 漂移累积到阶段 4。
阶段 2:GitHub 手动检查与同步(已完成:c39a3edc)
- GitHub client、latest/tag、ETag、asset/digest、rate limit;
- action check、首次异步 check、update_available;
- attention + exact confirmed revision;
- latest 的 auto 开关在本阶段不出现在 UI,服务层拒绝
auto_update_enabled=true; - GitHub source UI 和 deployment provenance。
验收:tag/latest 均可手动检查/同步;auto 仍保持 false;asset 替换不能未经确认激活。
本阶段 API/DTO 变化后再次运行 make swagger,保证阶段 2 可独立合并发布。
阶段 3:latest scanner 与自动更新(已完成:848884d8、999428cf、67b051c2)
- scanner task、
0002schedule seed、过期 lease 恢复; - serial batch、jitter、退避、自动 sync dispatch;
- marker 白名单 orphan record 延迟补偿与统一锁顺序竞态测试;
- 自动更新开关与 interval;
- 人工回滚关闭 auto 的完整前后端交互;
- TaskExecution 与后台状态轮询。
验收:auto 默认 false;开启后只对 latest 生效;回滚不会被自动打回;多 source 失败隔离和 backlog 日志可用。
本阶段 API/DTO 变化后再次运行 make swagger,阶段 4 只做最终一致性复检。
阶段 4:文档、生成物与验证记录(代码收口已完成)
- 同步 Pages design/architecture/guide/README 与中文 changelog;
- 生成 Swagger;
- 运行前后端测试、
make prettier、make code-check,并如实记录全仓失败与未执行边界; - 整理 §4.7 手工矩阵,并记录本地环境未覆盖的真实外部场景。
每个阶段只提交本阶段明确路径并独立验证;阶段 0 不与后续 source 功能捆绑成一个大提交。
6. 生产验收完成定义
只有同时满足以下条件,V2 才视为生产验收完成:
- 三类来源能力边界与 UI/API 完全一致;
- 数据模型为 config/runtime 分离的瘦表,状态不超过六态;
- source sync 无 candidate/activate 分支,revision 幂等且原子激活;
- 人工回滚可以硬性终止自动覆盖;
- Remote/GitHub/日志/task/deployment 均无密钥泄漏;
- Server 与 Agent 都执行真实字节上限,Agent 不再全量内存下载;
- history=1、本地 candidate、source sync 和清理补偿均有自动化覆盖;
- PostgreSQL、SQLite、后端、Agent、前端及项目门禁全部通过;
- 实际代码、Swagger、中文设计/使用文档和 changelog 同步。
本轮状态中的“代码实施完成”表示功能、迁移、前后端交互、生成物和文档均已落地,范围内自动化门禁已经通过。真实 PostgreSQL、真实外部来源和多 Agent 故障矩阵仍是生产验收条件;未执行项及全仓存量失败不会在本文中伪报为通过,统一记录如下。
7. 实施结果与验证记录
7.1 已交付
- 完成部署包安全与一致性前置:项目 RootDir 生效、归档实际字节限制、Agent 流式下载与复核、历史裁剪、上传记录补偿及共享对象安全边界。
- 完成 Remote URL 与公开 GitHub Release source/runtime 模型、CRUD、手动 check/sync、revision 幂等、不可变 deployment 与原子激活。
- 完成 GitHub latest scanner、稳定批次、lease 恢复、ETag/304、403/429 退避、精确 revision 自动派发和 orphan upload 延迟补偿。
- 完成 Cloudflare Pages 风格的“当前生产部署 → 部署源 → 部署历史”前端交互,并保留独立
git_repositoryProvider、Server build executor 和统一 artifact pipeline 的后续边界。 - 完成 Swagger、中文 changelog、Pages 设计、总体架构、Agent 设计、使用指南与 README 同步。
7.2 已通过的自动化验证
make swagger:通过,生成物已随实现提交。make prettier:通过;格式化产生的三个无关存量文件变化已恢复,未混入提交。make code-check:通过;golangci-lint、前端 TypeScript 与全量 ESLint 均无错误。- Pages、Agent、GitHub Release integration、归档、上传和迁移相关 Go 定向测试通过;关键并发路径的 race 测试通过。
- SQLite Pages source 与 scanner schedule migration 的 Up/Down/Up 通过。
- 前端 Pages 四个测试文件共 34 条用例全部通过,相关 TypeScript 与 ESLint 检查通过。
- 全仓 Go 测试中
internal/apps/openflare/pages、internal/apps/openflare/agent、internal/integration/githubrelease、internal/db/migrator、internal/apps/upload/*与pkg/pagesarchive均通过。
7.3 全仓测试中仍存在的范围外失败
go test ./... -count=1未全绿:internal/apps/admin/system_config仍按旧快照断言 32 条默认配置和 3 条 business 配置,实际为 34 和 5;本分支未修改该模块。internal/apps/flared/frpc的TestUnexpectedExit0CPUProtection、TestBackoffReset及internal/apps/relay/frps的TestUnexpectedExitAndAutorestart存在进程状态时序失败。internal/apps/openflare/tasks的TestRunSSLRenewJobTriggersDueCertificates连接本机 Redis 时收到NOAUTH Authentication required,证书状态因此未进入applying。- 前端全仓 Vitest 共 101 条通过、1 条失败:
frontend/tests/zone/zone-page.test.tsx:116仍查找旧文案“唯一访问者”;Pages 的 34 条测试不受影响。
这些失败点不属于本次 Pages V2 功能路径,因此未通过扩大范围修改存量模块来掩盖;它们仍应在各自模块后续收敛。
7.4 本地环境未执行的生产验收
OPENFLARE_TEST_POSTGRES_DSN未设置,因此 PostgreSQL migration Up/Down/Up、JSONB orphan 候选矩阵和真实行锁竞争未执行;SQLite 对应路径已通过。- 未访问真实公开 GitHub Release,也未以真实服务验证 GitHub rate limit、asset redirect、Remote public DNS rebinding 或
trusted_internal自签 TLS;普通测试均使用可注入 client/httptest.Server覆盖协议分支。 - 未执行多 Agent 实机收敛,以及 Server/Worker 在下载、Ingest、最终提交阶段的进程级中断矩阵。
- 前端交互由 React Testing Library 覆盖,未执行真实浏览器 E2E。
7.5 剩余验证债务
当前 orphan/deployment 竞态测试能验证锁序与事务结果,但 SQLite 串行执行不能替代 PostgreSQL 下两个独立事务的真实行锁竞争。生产发布前应在可用 PostgreSQL 测试实例上补跑 migration、JSONB 候选和双事务交错测试,并按 §4.7 完成真实网络与多 Agent 最小矩阵。