diff --git a/Makefile b/Makefile index fc3e6f4..d8fb44b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ GO ?= go -.PHONY: fmt test vet build image integration mysql-replication mysql-compatibility sdk-check protocol-check docs-check schedule-backlog release-local-check +.PHONY: fmt test vet build image integration mysql-replication mysql-compatibility sdk-check protocol-check docs-check schedule-backlog release-manifest release-local-check fmt: $(GO) fmt ./... cd sdk/go && $(GO) fmt ./... @@ -47,6 +47,9 @@ protocol-check: docs-check: ./scripts/check-doc-links.sh +release-manifest: + $(GO) run ./cmd/orbit-admin release manifest + release-local-check: ./scripts/check-release-local.sh diff --git a/cmd/orbit-admin/main.go b/cmd/orbit-admin/main.go index 44b4a38..c2ec1bf 100644 --- a/cmd/orbit-admin/main.go +++ b/cmd/orbit-admin/main.go @@ -10,6 +10,7 @@ import ( "path/filepath" "time" + "git.yuntuo.hk/k2safe/orbit/internal/buildinfo" "git.yuntuo.hk/k2safe/orbit/internal/config" "git.yuntuo.hk/k2safe/orbit/internal/storage/mysql" core "git.yuntuo.hk/k2safe/orbit/model" @@ -23,9 +24,11 @@ func main() { } func run() error { if len(os.Args) < 2 { - return errors.New("usage: orbit-admin bootstrap|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply") + return errors.New("usage: orbit-admin release manifest|bootstrap|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply") } switch os.Args[1] { + case "release": + return runRelease(os.Args[2:]) case "bootstrap": return runBootstrap(os.Args[2:]) case "verify": @@ -37,7 +40,57 @@ func run() error { case "credential": return runCredential(os.Args[2:]) } - return errors.New("usage: orbit-admin bootstrap|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply") + return errors.New("usage: orbit-admin release manifest|bootstrap|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply") +} + +// releaseManifest 是发布物自身的静态身份清单,不读取数据库、不包含 DSN、凭据或业务数据。 +// 部署前可以保存它,与镜像摘要、git 提交和目标数据库的 verify 输出一起作为发布证据。 +type releaseManifest struct { + ManifestVersion int `json:"manifest_version"` // ManifestVersion 清单格式版本,变更时递增。 + Build buildinfo.Info `json:"build"` // Build 当前二进制及协议身份。 + Schema releaseSchemaManifest `json:"schema"` // Schema 当前内置迁移链摘要。 +} + +// releaseSchemaManifest 描述当前二进制可执行的前向迁移链;SQL 正文不写入清单。 +type releaseSchemaManifest struct { + CurrentVersion uint64 `json:"current_version"` // CurrentVersion 最新迁移版本。 + MigrationCount int `json:"migration_count"` // MigrationCount 迁移数量。 + Migrations []releaseMigrationManifest `json:"migrations"` // Migrations 按版本排序的名称和校验和。 +} + +// releaseMigrationManifest 是单条迁移的不可变身份,不暴露迁移 SQL。 +type releaseMigrationManifest struct { + Version uint64 `json:"version"` // Version 迁移版本。 + Name string `json:"name"` // Name 迁移文件名。 + Checksum string `json:"checksum"` // Checksum 迁移内容 SHA-256。 +} + +func runRelease(args []string) error { + if len(args) != 1 || args[0] != "manifest" { + return errors.New("usage: orbit-admin release manifest") + } + migrations, err := mysql.Migrations() + if err != nil || len(migrations) == 0 { + return errors.New("release migration manifest unavailable") + } + items := make([]releaseMigrationManifest, 0, len(migrations)) + for _, migration := range migrations { + items = append(items, releaseMigrationManifest{ + Version: migration.Version, + Name: migration.Name, + Checksum: migration.Checksum, + }) + } + manifest := releaseManifest{ + ManifestVersion: 1, + Build: buildinfo.Current(), + Schema: releaseSchemaManifest{ + CurrentVersion: migrations[len(migrations)-1].Version, + MigrationCount: len(items), + Migrations: items, + }, + } + return json.NewEncoder(os.Stdout).Encode(manifest) } func runVerify(args []string) error { diff --git a/cmd/orbit-admin/release_test.go b/cmd/orbit-admin/release_test.go new file mode 100644 index 0000000..df6e0d2 --- /dev/null +++ b/cmd/orbit-admin/release_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "testing" + + "git.yuntuo.hk/k2safe/orbit/internal/buildinfo" + "git.yuntuo.hk/k2safe/orbit/internal/storage/mysql" +) + +func TestReleaseManifestIsDatabaseIndependent(t *testing.T) { + output, restore := captureStdout(t) + err := runRelease([]string{"manifest"}) + restore() + if err != nil { + t.Fatal(err) + } + var got releaseManifest + if err := json.Unmarshal(output.Bytes(), &got); err != nil { + t.Fatal(err) + } + migrations, err := mysql.Migrations() + if err != nil || len(migrations) == 0 { + t.Fatalf("migrations: %v", err) + } + if got.ManifestVersion != 1 { + t.Fatalf("manifest version=%d", got.ManifestVersion) + } + if got.Build != buildinfo.Current() { + t.Fatalf("build identity=%+v want=%+v", got.Build, buildinfo.Current()) + } + if got.Schema.CurrentVersion != migrations[len(migrations)-1].Version || got.Schema.MigrationCount != len(migrations) { + t.Fatalf("schema summary=%+v migrations=%d", got.Schema, len(migrations)) + } + if len(got.Schema.Migrations) != len(migrations) { + t.Fatalf("manifest migrations=%d want=%d", len(got.Schema.Migrations), len(migrations)) + } + for i, migration := range migrations { + item := got.Schema.Migrations[i] + if item.Version != migration.Version || item.Name != migration.Name || item.Checksum != migration.Checksum { + t.Fatalf("migration[%d]=%+v want version=%d name=%s checksum=%s", i, item, migration.Version, migration.Name, migration.Checksum) + } + } +} + +func TestReleaseManifestRejectsUnknownCommand(t *testing.T) { + if err := runRelease([]string{"status"}); err == nil { + t.Fatal("unknown release command accepted") + } +} + +func captureStdout(t *testing.T) (*bytes.Buffer, func()) { + t.Helper() + old := os.Stdout + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = write + var output bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = output.ReadFrom(read) + close(done) + }() + return &output, func() { + _ = write.Close() + os.Stdout = old + <-done + _ = read.Close() + } +} diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index db09e3f..7e8256a 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -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 | | 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);继续业务积压/阶段延迟、凭据轮换及其余生产门槛 | -| 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)和[容器发布说明](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` 核对;仍需带标签的完整跨版本矩阵和目标拓扑验收 | SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。 diff --git a/docs/21-container-deployment.md b/docs/21-container-deployment.md index fe3057c..e4a0b8e 100644 --- a/docs/21-container-deployment.md +++ b/docs/21-container-deployment.md @@ -29,6 +29,15 @@ docker build --platform linux/amd64 \ 镜像同时写入 OCI `org.opencontainers.image.version`、`org.opencontainers.image.revision` 和 `org.opencontainers.image.created` 标签;可以先核对标签,再在节点启动后通过 `GET /version` 核对进程实际使用的值。`registry.example` 只是占位符,不是平台依赖。 +构建完成后先保存发布物身份清单: + +```sh +docker run --rm --entrypoint /usr/local/bin/orbit-admin \ + registry.example/orbit:1.0.0 release manifest > orbit-release-manifest.json +``` + +清单把当前构建身份、协议版本和内置迁移校验和固定下来;部署前再用 `orbit-admin verify` 对目标数据库逐项核对。两者不一致时停止发布,不通过人工修改清单来“对齐”。 + ## 2. 迁移、核验和启动 迁移与服务使用同一镜像但分开执行;迁移账户和运行账户由数据库权限策略隔离。以下命令只展示容器契约,凭据必须来自秘密管理: diff --git a/docs/api/operations.md b/docs/api/operations.md index 468a1d9..a6c86d4 100644 --- a/docs/api/operations.md +++ b/docs/api/operations.md @@ -1,3 +1,40 @@ +# 发布物身份清单 + +`orbit-admin release manifest` 不连接数据库,输出当前二进制内置的构建身份、协议版本和完整迁移名称/校验和。它用于在构建阶段冻结发布证据;输出不含 DSN、凭据、迁移 SQL 或业务数据。 + +```sh +docker run --rm --entrypoint /usr/local/bin/orbit-admin registry.example/orbit:1.0.0 release manifest > orbit-release-manifest.json +# 本地工作区也可执行: +make release-manifest > orbit-release-manifest.json +``` + +清单中的 `build.revision` 应与源码提交和镜像 OCI revision 标签一致,`schema` 应与部署前 `orbit-admin verify` 返回的迁移账本逐项一致。清单只证明发布物声明的身份,不能证明目标数据库已切换、备份可恢复或目标拓扑满足 RPO/RTO。 + +示例结构: + +```json +{ + "manifest_version": 1, + "build": { + "version": "1.0.0", + "revision": "", + "built_at": "2026-09-20T00:00:00Z", + "go_version": "go1.27.1", + "http_protocol_version": "1", + "worker_protocol_version": "1", + "event_protocol_version": "1", + "workflow_schema_version": 1 + }, + "schema": { + "current_version": 28, + "migration_count": 28, + "migrations": [ + {"version": 1, "name": "000001_initial.up.sql", "checksum": ""} + ] + } +} +``` + # 发布前结构核验 `orbit-admin verify` 是只读的发布、迁移和恢复前置检查。它不会执行迁移、修复脏账本、删除数据或修改权限;数据库账户通过 `ORBIT_MYSQL_DSN` 注入,建议使用受限只读账户。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 942ce83..d4d1af5 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -9,6 +9,7 @@ | 服务端与 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 示例及两个独立业务模块真实集成已通过;发布环境仍需复核 | | 容器发布物 | `make image`;[容器发布说明](21-container-deployment.md) | native linux/arm64 与显式 linux/amd64 镜像构建已通过,支持注入版本身份并由 `/version` 核对;生产镜像标签、摘要、签名和目标编排环境仍需冻结 | +| 发布物身份清单 | `orbit-admin release manifest`;[运维接口](api/operations.md) | 已实现无需数据库的机器可读清单,包含构建身份、协议版本和全部迁移校验和;正式发布仍需把清单、镜像摘要与目标库 `verify` 输出一并冻结 | | 服务端单元、竞态、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 | | SDK 独立性和发布门禁 | `make sdk-check` | Go 1.27.1 Docker 下独立 module、示例和业务模块 race/vet/build 已通过 | diff --git a/docs/reports/phase-7-regression-status.md b/docs/reports/phase-7-regression-status.md index cc0b0de..b929fe0 100644 --- a/docs/reports/phase-7-regression-status.md +++ b/docs/reports/phase-7-regression-status.md @@ -7,6 +7,7 @@ | 检查 | 环境/命令 | 结果 | | --- | --- | --- | | 本地发布门禁统一入口 | 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` | | 就绪探针排空门控 | `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/storage/mysql -run 'Test(FreshInstallBaselineHasChineseModelComments|FreshInstallBaselineContainsNoReferenceDDL|MigrationsAfterForeignKeyRemovalDoNotReintroduceReferences|PostRemovalMigrationGuard)$'` | PASS;当前无外键基线逐表、逐持久化字段检查中文 `COMMENT`,并继续阻止后续迁移重新引入引用约束 |