feat: bind schema verification to release manifest

This commit is contained in:
2026-09-20 21:23:30 +08:00
parent cebb32138c
commit 30a8081bd9
7 changed files with 95 additions and 6 deletions

View File

@@ -6,8 +6,10 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"time" "time"
"git.yuntuo.hk/k2safe/orbit/internal/buildinfo" "git.yuntuo.hk/k2safe/orbit/internal/buildinfo"
@@ -69,9 +71,17 @@ func runRelease(args []string) error {
if len(args) != 1 || args[0] != "manifest" { if len(args) != 1 || args[0] != "manifest" {
return errors.New("usage: orbit-admin release manifest") return errors.New("usage: orbit-admin release manifest")
} }
manifest, err := currentReleaseManifest()
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(manifest)
}
func currentReleaseManifest() (releaseManifest, error) {
migrations, err := mysql.Migrations() migrations, err := mysql.Migrations()
if err != nil || len(migrations) == 0 { if err != nil || len(migrations) == 0 {
return errors.New("release migration manifest unavailable") return releaseManifest{}, errors.New("release migration manifest unavailable")
} }
items := make([]releaseMigrationManifest, 0, len(migrations)) items := make([]releaseMigrationManifest, 0, len(migrations))
for _, migration := range migrations { for _, migration := range migrations {
@@ -81,7 +91,7 @@ func runRelease(args []string) error {
Checksum: migration.Checksum, Checksum: migration.Checksum,
}) })
} }
manifest := releaseManifest{ return releaseManifest{
ManifestVersion: 1, ManifestVersion: 1,
Build: buildinfo.Current(), Build: buildinfo.Current(),
Schema: releaseSchemaManifest{ Schema: releaseSchemaManifest{
@@ -89,18 +99,52 @@ func runRelease(args []string) error {
MigrationCount: len(items), MigrationCount: len(items),
Migrations: items, Migrations: items,
}, },
}, nil
}
// validateReleaseManifest 确认外部保存的发布清单与当前管理二进制完全一致。
// 它只比较静态身份,不连接数据库,也不改变任何状态。
func validateReleaseManifest(path string) error {
file, err := os.Open(path)
if err != nil {
return errors.New("release manifest could not be opened")
} }
return json.NewEncoder(os.Stdout).Encode(manifest) defer file.Close()
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
var got releaseManifest
if err := decoder.Decode(&got); err != nil {
return errors.New("release manifest is invalid")
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return errors.New("release manifest contains trailing data")
}
want, err := currentReleaseManifest()
if err != nil {
return err
}
if !reflect.DeepEqual(got, want) {
return errors.New("release manifest does not match this binary")
}
return nil
} }
func runVerify(args []string) error { func runVerify(args []string) error {
flags := flag.NewFlagSet("verify", flag.ContinueOnError) flags := flag.NewFlagSet("verify", flag.ContinueOnError)
var manifestPath string
flags.StringVar(&manifestPath, "manifest", "", "saved release manifest to compare with this binary")
if err := flags.Parse(args); err != nil { if err := flags.Parse(args); err != nil {
return err return err
} }
if flags.NArg() != 0 { if flags.NArg() != 0 {
return errors.New("verify does not accept positional arguments") return errors.New("verify does not accept positional arguments")
} }
if manifestPath != "" {
if err := validateReleaseManifest(manifestPath); err != nil {
return err
}
}
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
return err return err

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"os" "os"
"path/filepath"
"testing" "testing"
"git.yuntuo.hk/k2safe/orbit/internal/buildinfo" "git.yuntuo.hk/k2safe/orbit/internal/buildinfo"
@@ -51,6 +52,35 @@ func TestReleaseManifestRejectsUnknownCommand(t *testing.T) {
} }
} }
func TestReleaseManifestValidationBindsBinaryIdentity(t *testing.T) {
manifest, err := currentReleaseManifest()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "release-manifest.json")
encoded, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, encoded, 0600); err != nil {
t.Fatal(err)
}
if err := validateReleaseManifest(path); err != nil {
t.Fatalf("matching manifest rejected: %v", err)
}
manifest.Build.Revision = "different-release"
encoded, err = json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, encoded, 0600); err != nil {
t.Fatal(err)
}
if err := validateReleaseManifest(path); err == nil {
t.Fatal("manifest with different revision accepted")
}
}
func captureStdout(t *testing.T) (*bytes.Buffer, func()) { func captureStdout(t *testing.T) (*bytes.Buffer, func()) {
t.Helper() t.Helper()
old := os.Stdout old := os.Stdout

View File

@@ -53,7 +53,7 @@
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 已完成平台功能阶段验收:[原功能及W01–W17逐项核对](reports/phase-4-5.md)、[版本/暂停/全停回归](reports/phase-4-5-races.md)、[提交故障边界](reports/phase-6-fault-boundaries.md)。研发发布、固定版本、顺序/分支/有限并行、信号/时间等待、公共API/SDK及可运行示例均已交付;生产拓扑与发布兼容继续P6/P7 | | P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 已完成平台功能阶段验收:[原功能及W01–W17逐项核对](reports/phase-4-5.md)、[版本/暂停/全停回归](reports/phase-4-5-races.md)、[提交故障边界](reports/phase-6-fault-boundaries.md)。研发发布、固定版本、顺序/分支/有限并行、信号/时间等待、公共API/SDK及可运行示例均已交付;生产拓扑与发布兼容继续P6/P7 |
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 已完成平台功能阶段验收:[原功能核对](reports/phase-4-5.md)。有界查询、独立预算、暂停继续/取消、六类恢复、历史/筛选/事件/来源与SDK场景均已交付;真实竞争、COMMIT断线及进程全停保留原身份、截止和预算。运维、容量与首版发布继续P6/P7 | | P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 已完成平台功能阶段验收:[原功能核对](reports/phase-4-5.md)。有界查询、独立预算、暂停继续/取消、六类恢复、历史/筛选/事件/来源与SDK场景均已交付;真实竞争、COMMIT断线及进程全停保留原身份、截止和预算。运维、容量与首版发布继续P6/P7 |
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 进行中;已补[精确故障边界](reports/phase-6-fault-boundaries.md)和[进程观测](reports/phase-6-observability.md);继续业务积压/阶段延迟、凭据轮换及其余生产门槛 | | P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 进行中;已补[精确故障边界](reports/phase-6-fault-boundaries.md)和[进程观测](reports/phase-6-observability.md);继续业务积压/阶段延迟、凭据轮换及其余生产门槛 |
| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 进行中;已补[通用文件转换示例](reports/phase-7-file-conversion.md)、[SDK独立发布门禁](reports/phase-7-sdk-release.md)、[当前协议兼容矩阵](reports/phase-7-compatibility.md)、[历史任务 API 双向演练](reports/phase-7-historical-compatibility.md)、[当前回归状态](reports/phase-7-regression-status.md)、[首版交付核对清单](release-checklist.md)、[发布物身份清单](api/operations.md#发布物身份清单)和[容器发布说明](21-container-deployment.md),镜像已完成 linux/amd64 构建验证,节点版本身份可由 `/version` 核对;仍需带标签的完整跨版本矩阵和目标拓扑验收 | | P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 进行中;已补[通用文件转换示例](reports/phase-7-file-conversion.md)、[SDK独立发布门禁](reports/phase-7-sdk-release.md)、[当前协议兼容矩阵](reports/phase-7-compatibility.md)、[历史任务 API 双向演练](reports/phase-7-historical-compatibility.md)、[当前回归状态](reports/phase-7-regression-status.md)、[首版交付核对清单](release-checklist.md)、[发布物身份清单](api/operations.md#发布物身份清单)和[容器发布说明](21-container-deployment.md),镜像已完成 linux/amd64 构建验证,节点版本身份可由 `/version` 核对;`verify --manifest` 已可在数据库核验前绑定候选二进制身份;仍需带标签的完整跨版本矩阵和目标拓扑验收 |
SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。 SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。

View File

@@ -55,6 +55,13 @@ docker run --rm \
--entrypoint /usr/local/bin/orbit-admin \ --entrypoint /usr/local/bin/orbit-admin \
registry.example/orbit:dev verify registry.example/orbit:dev verify
# 发布候选还应带上同一镜像生成的身份清单;不一致时停止发布。
docker run --rm \
--env ORBIT_MYSQL_DSN \
--entrypoint /usr/local/bin/orbit-admin \
-v "$PWD/orbit-release-manifest.json:/run/orbit-release-manifest.json:ro" \
registry.example/orbit:1.0.0 verify --manifest /run/orbit-release-manifest.json
docker run --rm \ docker run --rm \
--env ORBIT_MYSQL_DSN \ --env ORBIT_MYSQL_DSN \
--env ORBIT_HTTP_ADDR=0.0.0.0:8080 \ --env ORBIT_HTTP_ADDR=0.0.0.0:8080 \

View File

@@ -50,7 +50,14 @@ ORBIT_MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/orbit' \
{"schema_version":28,"migration_count":28,"foreign_key_count":0,"required_index_count":6,"complete":true} {"schema_version":28,"migration_count":28,"foreign_key_count":0,"required_index_count":6,"complete":true}
``` ```
`complete=true` 表示当前二进制的迁移账本、Orbit 表零外键,以及 000026–000028 的六个有界观测索引均满足要求。命令不会证明备份可恢复、复制已确认、业务副作用已核对或生产 RPO/RTO 达标;这些仍需隔离环境恢复演练和真实拓扑验收。失败时会先输出不含 DSN/密钥的检查结果,再以非零状态退出。 `complete=true` 表示当前二进制的迁移账本、Orbit 表零外键,以及 000026–000028 的六个有界观测索引均满足要求。需要同时核对发布物身份时,传入保存的清单:
```sh
ORBIT_MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/orbit' \
go run ./cmd/orbit-admin verify --manifest orbit-release-manifest.json
```
`--manifest` 会在连接数据库前比较清单与当前管理二进制的构建身份、协议版本和迁移校验和;不一致立即失败。命令不会证明备份可恢复、复制已确认、业务副作用已核对或生产 RPO/RTO 达标;这些仍需隔离环境恢复演练和真实拓扑验收。失败时会先输出不含 DSN/密钥的检查结果,再以非零状态退出。
发布流程应先停止不兼容的旧写入节点,在隔离环境和目标维护窗口应用 `orbit-migrate up`,随后执行 `orbit-admin verify`;只有输出完整且人工核对备份、权限、连接路由后才启动新版本。verify 不是运行时协调器,多个节点可并发读取,但不能替代一次性迁移锁。 发布流程应先停止不兼容的旧写入节点,在隔离环境和目标维护窗口应用 `orbit-migrate up`,随后执行 `orbit-admin verify`;只有输出完整且人工核对备份、权限、连接路由后才启动新版本。verify 不是运行时协调器,多个节点可并发读取,但不能替代一次性迁移锁。

View File

@@ -9,7 +9,7 @@
| 服务端与 SDK 代码来自同一提交 | `git rev-parse HEAD`;发布时记录不可变提交 | 待发布候选冻结 | | 服务端与 SDK 代码来自同一提交 | `git rev-parse HEAD`;发布时记录不可变提交 | 待发布候选冻结 |
| Go 工具链 | Go 1.27.1;Docker `golang:1.27.1`,真实 `go.mod`/`GOWORK=off` | 模块级 race/vet/build、MySQL 存储/进程组、24 个 SDK 示例及两个独立业务模块真实集成已通过;发布环境仍需复核 | | Go 工具链 | Go 1.27.1;Docker `golang:1.27.1`,真实 `go.mod`/`GOWORK=off` | 模块级 race/vet/build、MySQL 存储/进程组、24 个 SDK 示例及两个独立业务模块真实集成已通过;发布环境仍需复核 |
| 容器发布物 | `make image`;[容器发布说明](21-container-deployment.md) | native linux/arm64 与显式 linux/amd64 镜像构建已通过,支持注入版本身份并由 `/version` 核对;生产镜像标签、摘要、签名和目标编排环境仍需冻结 | | 容器发布物 | `make image`;[容器发布说明](21-container-deployment.md) | native linux/arm64 与显式 linux/amd64 镜像构建已通过,支持注入版本身份并由 `/version` 核对;生产镜像标签、摘要、签名和目标编排环境仍需冻结 |
| 发布物身份清单 | `orbit-admin release manifest`;[运维接口](api/operations.md) | 已实现无需数据库的机器可读清单,包含构建身份、协议版本和全部迁移校验和;正式发布仍需把清单、镜像摘要与目标库 `verify` 输出一并冻结 | | 发布物身份清单 | `orbit-admin release manifest`、`orbit-admin verify --manifest`;[运维接口](api/operations.md) | 已实现无需数据库的机器可读清单及部署前二进制绑定校验,包含构建身份、协议版本和全部迁移校验和;正式发布仍需把清单、镜像摘要与目标库 `verify` 输出一并冻结 |
| 服务端单元、竞态、vet、build | `make test && make vet && make build` | Go 1.27.1 Docker 等价命令已通过;宿主机 `make` 因未安装 Go 不能作为本机证据 | | 服务端单元、竞态、vet、build | `make test && make vet && make build` | Go 1.27.1 Docker 等价命令已通过;宿主机 `make` 因未安装 Go 不能作为本机证据 |
| 当前协议线一致 | `make protocol-check` | 已通过:HTTP `/v1`、Worker/Event v1、流程 schema 1 | | 当前协议线一致 | `make protocol-check` | 已通过:HTTP `/v1`、Worker/Event v1、流程 schema 1 |
| SDK 独立性和发布门禁 | `make sdk-check` | Go 1.27.1 Docker 下独立 module、示例和业务模块 race/vet/build 已通过 | | SDK 独立性和发布门禁 | `make sdk-check` | Go 1.27.1 Docker 下独立 module、示例和业务模块 race/vet/build 已通过 |

View File

@@ -8,6 +8,7 @@
| --- | --- | --- | | --- | --- | --- |
| 本地发布门禁统一入口 | 2026-09-20;`make release-local-check` | PASS;服务端、SDK、独立示例竞态测试、vet、build、当前协议门禁、146个 Markdown 本地链接和 SDK 独立依赖检查均通过;命令明确不替代真实 MySQL 集成与目标生产验收 | | 本地发布门禁统一入口 | 2026-09-20;`make release-local-check` | PASS;服务端、SDK、独立示例竞态测试、vet、build、当前协议门禁、146个 Markdown 本地链接和 SDK 独立依赖检查均通过;命令明确不替代真实 MySQL 集成与目标生产验收 |
| 发布物静态身份清单 | 2026-09-20;`make release-manifest`;无 `ORBIT_MYSQL_DSN` | PASS;输出构建/协议身份及28条迁移名称与SHA-256校验和,未读取数据库、凭据或业务数据;目标库仍需单独执行 `orbit-admin verify` | | 发布物静态身份清单 | 2026-09-20;`make release-manifest`;无 `ORBIT_MYSQL_DSN` | PASS;输出构建/协议身份及28条迁移名称与SHA-256校验和,未读取数据库、凭据或业务数据;目标库仍需单独执行 `orbit-admin verify` |
| 发布物与管理二进制绑定校验 | 2026-09-20;`go test -race ./cmd/orbit-admin`;匹配/修改 revision/未知命令场景 | PASS;`verify --manifest` 在数据库连接前拒绝身份、协议或迁移校验和不一致的清单,匹配清单通过;不修改数据库 |
| 就绪探针排空门控 | `go test -race ./internal/transport/httpserver ./cmd/orbit`;`TestReadinessEntersDrainBeforeShutdown` | PASS;SIGTERM 关闭路径先将 `/health/ready` 置为 503,再进入限定优雅关闭窗口;响应不泄露内部错误 | | 就绪探针排空门控 | `go test -race ./internal/transport/httpserver ./cmd/orbit`;`TestReadinessEntersDrainBeforeShutdown` | PASS;SIGTERM 关闭路径先将 `/health/ready` 置为 503,再进入限定优雅关闭窗口;响应不泄露内部错误 |
| 健康探针响应契约 | 2026-09-20;`go test ./internal/transport/httpserver && go test -race ./internal/transport/httpserver && make docs-check` | PASS;`/health/live` 与 `/health/ready` 返回 `Cache-Control: no-store`、`X-Content-Type-Options: nosniff`;未就绪和排空状态返回 `Retry-After: 1`,并由 HTTP 契约测试覆盖 | | 健康探针响应契约 | 2026-09-20;`go test ./internal/transport/httpserver && go test -race ./internal/transport/httpserver && make docs-check` | PASS;`/health/live` 与 `/health/ready` 返回 `Cache-Control: no-store`、`X-Content-Type-Options: nosniff`;未就绪和排空状态返回 `Retry-After: 1`,并由 HTTP 契约测试覆盖 |
| 数据模型中文注释门禁 | 2026-09-20;`go test ./internal/storage/mysql -run 'Test(FreshInstallBaselineHasChineseModelComments|FreshInstallBaselineContainsNoReferenceDDL|MigrationsAfterForeignKeyRemovalDoNotReintroduceReferences|PostRemovalMigrationGuard)$'` | PASS;当前无外键基线逐表、逐持久化字段检查中文 `COMMENT`,并继续阻止后续迁移重新引入引用约束 | | 数据模型中文注释门禁 | 2026-09-20;`go test ./internal/storage/mysql -run 'Test(FreshInstallBaselineHasChineseModelComments|FreshInstallBaselineContainsNoReferenceDDL|MigrationsAfterForeignKeyRemovalDoNotReintroduceReferences|PostRemovalMigrationGuard)$'` | PASS;当前无外键基线逐表、逐持久化字段检查中文 `COMMENT`,并继续阻止后续迁移重新引入引用约束 |