feat: add release identity manifest

This commit is contained in:
2026-09-20 21:18:09 +08:00
parent 014591560a
commit cebb32138c
8 changed files with 182 additions and 4 deletions

View File

@@ -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 {

View File

@@ -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()
}
}