DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,45 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"longheng.io/server/internal/platform/datatable"
|
||||
)
|
||||
|
||||
type DataTableApplier struct {
|
||||
Registry *datatable.Registry
|
||||
Views *datatable.ViewManager
|
||||
}
|
||||
|
||||
func (a DataTableApplier) Apply(ctx context.Context, pkg Package) (ApplyResult, error) {
|
||||
if a.Registry == nil {
|
||||
return ApplyResult{}, datatable.ErrRegistryRequired
|
||||
}
|
||||
options := datatable.ReloadOptions{}
|
||||
if a.Views != nil {
|
||||
options.Validators = append(options.Validators, a.Views)
|
||||
}
|
||||
loadResult, err := a.Registry.ReloadDir(ctx, pkg.Directory, pkg.Version, options)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
result := ApplyResult{
|
||||
Version: pkg.Version,
|
||||
Applied: []string{
|
||||
"data_tables",
|
||||
},
|
||||
Details: map[string]any{
|
||||
"data_tables": loadResult,
|
||||
},
|
||||
}
|
||||
if a.Views != nil {
|
||||
// 视图刷新使用 WithoutCancel,避免外层发布请求取消后留下“表已切、视图未刷”的半成品状态。
|
||||
viewResult, err := a.Views.Refresh(context.WithoutCancel(ctx), a.Registry, loadResult)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
result.Applied = append(result.Applied, "data_table_views")
|
||||
result.Details["data_table_views"] = viewResult
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const BundleManifestName = "longheng_hotupdate_manifest.json"
|
||||
const BundleSignatureAlgorithm = "HMAC-SHA256"
|
||||
|
||||
const defMaxBundleBytes int64 = 256 << 20
|
||||
const maxBundleManifest int64 = 1 << 20
|
||||
|
||||
type BundleOptions struct {
|
||||
SigningKey []byte
|
||||
HashCache *FileHashCache
|
||||
}
|
||||
|
||||
type BundleVerifyOptions struct {
|
||||
Checksum string
|
||||
SigningKey []byte
|
||||
RequireSignature bool
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
type BundleManifest struct {
|
||||
Version string `json:"version"`
|
||||
Files []BundleFile `json:"files"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
SignatureAlg string `json:"signature_alg,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type BundleFile struct {
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
func BuildBundle(ctx context.Context, sourceDir, bundlePath, version string) (BundleManifest, error) {
|
||||
return BuildBundleWithOptions(ctx, sourceDir, bundlePath, version, BundleOptions{})
|
||||
}
|
||||
|
||||
func BuildBundleWithOptions(ctx context.Context, sourceDir, bundlePath, version string, options BundleOptions) (BundleManifest, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
sourceDir = strings.TrimSpace(sourceDir)
|
||||
bundlePath = strings.TrimSpace(bundlePath)
|
||||
version = strings.TrimSpace(version)
|
||||
if sourceDir == "" {
|
||||
return BundleManifest{}, ErrSourceRequired
|
||||
}
|
||||
if bundlePath == "" {
|
||||
return BundleManifest{}, fmt.Errorf("hot update bundle path is required")
|
||||
}
|
||||
if version == "" {
|
||||
return BundleManifest{}, ErrVersionRequired
|
||||
}
|
||||
info, err := os.Lstat(sourceDir)
|
||||
if err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return BundleManifest{}, fmt.Errorf("hot update bundle source %q must not be a symlink", sourceDir)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return BundleManifest{}, fmt.Errorf("hot update bundle source %q is not a directory", sourceDir)
|
||||
}
|
||||
files, err := bundleFilesHash(ctx, sourceDir, options.HashCache)
|
||||
if err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
manifest := BundleManifest{
|
||||
Version: version,
|
||||
Files: files,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
manifest.Bytes, manifest.Checksum = bundleSummary(files)
|
||||
if len(options.SigningKey) > 0 {
|
||||
manifest.SignatureAlg = BundleSignatureAlgorithm
|
||||
manifest.Signature = signBundleManifest(manifest, options.SigningKey)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(bundlePath), 0o750); err != nil && filepath.Dir(bundlePath) != "." {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
out, err := os.Create(bundlePath) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
zipper := zip.NewWriter(out)
|
||||
closeWithErr := func(first error) error {
|
||||
if err := zipper.Close(); first == nil {
|
||||
first = err
|
||||
}
|
||||
if err := out.Close(); first == nil {
|
||||
first = err
|
||||
}
|
||||
return first
|
||||
}
|
||||
for _, file := range files {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return BundleManifest{}, closeWithErr(err)
|
||||
}
|
||||
if err := addBundleFile(zipper, sourceDir, file.Path); err != nil {
|
||||
return BundleManifest{}, closeWithErr(err)
|
||||
}
|
||||
}
|
||||
manifestData, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return BundleManifest{}, closeWithErr(err)
|
||||
}
|
||||
entry, err := zipper.Create(BundleManifestName)
|
||||
if err != nil {
|
||||
return BundleManifest{}, closeWithErr(err)
|
||||
}
|
||||
if _, err := entry.Write(append(manifestData, '\n')); err != nil {
|
||||
return BundleManifest{}, closeWithErr(err)
|
||||
}
|
||||
if err := closeWithErr(nil); err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func VerifyBundle(ctx context.Context, bundlePath, wantChecksum string) (BundleManifest, error) {
|
||||
return VerifyBundleWithOptions(ctx, bundlePath, BundleVerifyOptions{Checksum: wantChecksum})
|
||||
}
|
||||
|
||||
func VerifyBundleWithOptions(ctx context.Context, bundlePath string, options BundleVerifyOptions) (BundleManifest, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
reader, err := zip.OpenReader(strings.TrimSpace(bundlePath))
|
||||
if err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
maxBytes := bundleReadLimit(options)
|
||||
manifest, err := readBundleManifest(&reader.Reader)
|
||||
if err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
if err := validBundleManifest(manifest); err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
if maxBytes > 0 && manifest.Bytes > maxBytes {
|
||||
return BundleManifest{}, fmt.Errorf("hot update bundle exceeds max bytes: got %d want <= %d", manifest.Bytes, maxBytes)
|
||||
}
|
||||
if strings.TrimSpace(options.Checksum) != "" && !strings.EqualFold(options.Checksum, manifest.Checksum) {
|
||||
return BundleManifest{}, fmt.Errorf("hot update bundle checksum mismatch: got %s want %s", manifest.Checksum, options.Checksum)
|
||||
}
|
||||
actual, err := bundleManifestZip(ctx, &reader.Reader, manifest.Version, maxBytes)
|
||||
if err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
if actual.Checksum != manifest.Checksum || actual.Bytes != manifest.Bytes || !sameBundleFiles(actual.Files, manifest.Files) {
|
||||
return BundleManifest{}, fmt.Errorf("hot update bundle manifest mismatch")
|
||||
}
|
||||
if err := verifyBundleSig(manifest, options); err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func ExtractBundle(ctx context.Context, bundlePath, destination, wantChecksum string) (Package, error) {
|
||||
return ExtractBundleWithOptions(ctx, bundlePath, destination, BundleVerifyOptions{Checksum: wantChecksum})
|
||||
}
|
||||
|
||||
func ExtractBundleWithOptions(ctx context.Context, bundlePath, destination string, options BundleVerifyOptions) (Package, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
bundlePath = strings.TrimSpace(bundlePath)
|
||||
destination = strings.TrimSpace(destination)
|
||||
if destination == "" {
|
||||
return Package{}, fmt.Errorf("hot update bundle destination is required")
|
||||
}
|
||||
manifest, err := VerifyBundleWithOptions(ctx, bundlePath, options)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
reader, err := zip.OpenReader(bundlePath)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
parent := filepath.Dir(destination)
|
||||
if err := os.MkdirAll(parent, 0o750); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
tempDir, err := os.MkdirTemp(parent, ".longheng-hotupdate-*")
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
if err := preflightEntries(ctx, &reader.Reader, tempDir); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
for _, file := range reader.File {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
name := filepath.ToSlash(file.Name)
|
||||
if name == BundleManifestName {
|
||||
continue
|
||||
}
|
||||
if file.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
return Package{}, fmt.Errorf("hot update bundle rejects symlink %q", name)
|
||||
}
|
||||
target, err := safeBundleTarget(tempDir, name)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if file.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(target, 0o750); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := extractBundleFile(file, target, bundleReadLimit(options)); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
}
|
||||
if err := replaceDirectory(destination, tempDir); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
return Package{
|
||||
Version: manifest.Version,
|
||||
SourceURI: bundlePath,
|
||||
Directory: destination,
|
||||
Checksum: manifest.Checksum,
|
||||
Bytes: manifest.Bytes,
|
||||
SignatureAlg: manifest.SignatureAlg,
|
||||
Signature: manifest.Signature,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func replaceDirectory(destination, source string) error {
|
||||
backup := ""
|
||||
if _, err := os.Stat(destination); err == nil {
|
||||
backup = destination + ".rollback-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
if err := os.Rename(destination, backup); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(source, destination); err != nil {
|
||||
if backup != "" {
|
||||
_ = os.Rename(backup, destination)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if backup != "" {
|
||||
return os.RemoveAll(backup)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightEntries(ctx context.Context, reader *zip.Reader, destination string) error {
|
||||
// 预检逐个计算目标路径,拒绝绝对路径、.. 逃逸和非法文件名,防止 zip slip。
|
||||
for _, file := range reader.File {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
name := filepath.ToSlash(file.Name)
|
||||
if name == BundleManifestName {
|
||||
continue
|
||||
}
|
||||
if file.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("hot update bundle rejects symlink %q", name)
|
||||
}
|
||||
if _, err := safeBundleTarget(destination, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bundleFiles(ctx context.Context, root string) ([]BundleFile, error) {
|
||||
var files []BundleFile
|
||||
if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctxErr := contextErr(ctx); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("hot update bundle rejects symlink %q", path)
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
files = append(files, BundleFile{
|
||||
Path: filepath.ToSlash(rel),
|
||||
Bytes: int64(len(data)),
|
||||
Checksum: hex.EncodeToString(sum[:]),
|
||||
})
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func bundleSummary(files []BundleFile) (int64, string) {
|
||||
hash := sha256.New()
|
||||
var bytes int64
|
||||
for _, file := range files {
|
||||
bytes += file.Bytes
|
||||
_, _ = hash.Write([]byte(file.Path))
|
||||
_, _ = hash.Write([]byte{0})
|
||||
_, _ = hash.Write([]byte(file.Checksum))
|
||||
_, _ = hash.Write([]byte{0})
|
||||
}
|
||||
return bytes, hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
func signBundleManifest(manifest BundleManifest, key []byte) string {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
writeSigSeed(mac, manifest)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func verifyBundleSig(manifest BundleManifest, options BundleVerifyOptions) error {
|
||||
// 签名只覆盖 manifest 中的文件 hash,不覆盖外部元信息;生产必须配置 RequireSignature 和 signing key。
|
||||
if len(options.SigningKey) == 0 {
|
||||
if options.RequireSignature {
|
||||
return fmt.Errorf("hot update bundle signing key is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(manifest.Signature) == "" {
|
||||
return fmt.Errorf("hot update bundle signature is required")
|
||||
}
|
||||
if strings.TrimSpace(manifest.SignatureAlg) == "" {
|
||||
return fmt.Errorf("hot update bundle signature algorithm is required")
|
||||
}
|
||||
if manifest.SignatureAlg != BundleSignatureAlgorithm {
|
||||
return fmt.Errorf("hot update bundle signature algorithm %q is unsupported", manifest.SignatureAlg)
|
||||
}
|
||||
want := signBundleManifest(manifest, options.SigningKey)
|
||||
got, err := hex.DecodeString(manifest.Signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hot update bundle signature is invalid: %w", err)
|
||||
}
|
||||
expected, _ := hex.DecodeString(want)
|
||||
if !hmac.Equal(got, expected) {
|
||||
return fmt.Errorf("hot update bundle signature mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSigSeed(w io.Writer, manifest BundleManifest) {
|
||||
files := append([]BundleFile(nil), manifest.Files...)
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
|
||||
_, _ = io.WriteString(w, manifest.Version)
|
||||
_, _ = w.Write([]byte{0})
|
||||
_, _ = io.WriteString(w, manifest.SignatureAlg)
|
||||
_, _ = w.Write([]byte{0})
|
||||
_, _ = io.WriteString(w, manifest.CreatedAt.UTC().Format(time.RFC3339Nano))
|
||||
_, _ = w.Write([]byte{0})
|
||||
_, _ = io.WriteString(w, fmt.Sprintf("%d", manifest.Bytes))
|
||||
_, _ = w.Write([]byte{0})
|
||||
_, _ = io.WriteString(w, manifest.Checksum)
|
||||
_, _ = w.Write([]byte{0})
|
||||
for _, file := range files {
|
||||
_, _ = io.WriteString(w, file.Path)
|
||||
_, _ = w.Write([]byte{0})
|
||||
_, _ = io.WriteString(w, fmt.Sprintf("%d", file.Bytes))
|
||||
_, _ = w.Write([]byte{0})
|
||||
_, _ = io.WriteString(w, file.Checksum)
|
||||
_, _ = w.Write([]byte{0})
|
||||
}
|
||||
}
|
||||
|
||||
func addBundleFile(zipper *zip.Writer, root, rel string) error {
|
||||
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.ToSlash(rel)
|
||||
header.Method = zip.Deflate
|
||||
entry, err := zipper.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
_, err = io.Copy(entry, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func readBundleManifest(reader *zip.Reader) (BundleManifest, error) {
|
||||
data, err := readBundleZipPart(reader, BundleManifestName, maxBundleManifest)
|
||||
if err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
var manifest BundleManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
if manifest.Version == "" || manifest.Checksum == "" {
|
||||
return BundleManifest{}, fmt.Errorf("hot update bundle manifest is invalid")
|
||||
}
|
||||
sort.Slice(manifest.Files, func(i, j int) bool { return manifest.Files[i].Path < manifest.Files[j].Path })
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func bundleManifestZip(ctx context.Context, reader *zip.Reader, version string, maxBytes int64) (BundleManifest, error) {
|
||||
files := make([]BundleFile, 0, len(reader.File))
|
||||
seen := make(map[string]struct{}, len(reader.File))
|
||||
var totalBytes int64
|
||||
for _, file := range reader.File {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
name := filepath.ToSlash(file.Name)
|
||||
if name == BundleManifestName || file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if file.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
return BundleManifest{}, fmt.Errorf("hot update bundle rejects symlink %q", name)
|
||||
}
|
||||
if err := validateBundlePath(name); err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
name = path.Clean(name)
|
||||
if _, ok := seen[name]; ok {
|
||||
return BundleManifest{}, fmt.Errorf("hot update bundle path %q is duplicated", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
data, err := readBundleZipPart(reader, name, maxBytes)
|
||||
if err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
if maxBytes > 0 && totalBytes > maxBytes-int64(len(data)) {
|
||||
return BundleManifest{}, fmt.Errorf("hot update bundle exceeds max bytes: got > %d", maxBytes)
|
||||
}
|
||||
totalBytes += int64(len(data))
|
||||
sum := sha256.Sum256(data)
|
||||
files = append(files, BundleFile{Path: name, Bytes: int64(len(data)), Checksum: hex.EncodeToString(sum[:])})
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
|
||||
bytes, checksum := bundleSummary(files)
|
||||
return BundleManifest{Version: version, Files: files, Bytes: bytes, Checksum: checksum}, nil
|
||||
}
|
||||
|
||||
func readBundleZipPart(reader *zip.Reader, name string, maxBytes int64) ([]byte, error) {
|
||||
for _, file := range reader.File {
|
||||
if filepath.ToSlash(file.Name) != filepath.ToSlash(name) {
|
||||
continue
|
||||
}
|
||||
if maxBytes > 0 && file.UncompressedSize64 > uint64(maxBytes) {
|
||||
return nil, fmt.Errorf("hot update bundle part %s exceeds max bytes: got %d want <= %d", name, file.UncompressedSize64, maxBytes)
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rc.Close() }()
|
||||
return readAllLimited(rc, maxBytes, "hot update bundle part "+name)
|
||||
}
|
||||
return nil, fmt.Errorf("hot update bundle part %s not found", name)
|
||||
}
|
||||
|
||||
func readAllLimited(r io.Reader, maxBytes int64, label string) ([]byte, error) {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defMaxBundleBytes
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(r, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, fmt.Errorf("%s exceeds max bytes: got > %d", label, maxBytes)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func bundleReadLimit(options BundleVerifyOptions) int64 {
|
||||
if options.MaxBytes > 0 {
|
||||
return options.MaxBytes
|
||||
}
|
||||
return defMaxBundleBytes
|
||||
}
|
||||
|
||||
func safeBundleTarget(root, name string) (string, error) {
|
||||
// 所有 bundle 文件最终路径必须仍在 root 下,防止通过清理后的相对路径写出工作区。
|
||||
if err := validateBundlePath(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
name = path.Clean(filepath.ToSlash(name))
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
target, err := filepath.Abs(filepath.Join(absRoot, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel, err := filepath.Rel(absRoot, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
|
||||
return "", fmt.Errorf("hot update bundle target %q escapes %q", target, absRoot)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func validBundleManifest(manifest BundleManifest) error {
|
||||
if strings.TrimSpace(manifest.Version) == "" || strings.TrimSpace(manifest.Checksum) == "" {
|
||||
return fmt.Errorf("hot update bundle manifest is invalid")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(manifest.Files))
|
||||
for idx, file := range manifest.Files {
|
||||
if err := validateBundlePath(file.Path); err != nil {
|
||||
return fmt.Errorf("hot update bundle manifest file %d: %w", idx, err)
|
||||
}
|
||||
clean := path.Clean(filepath.ToSlash(file.Path))
|
||||
if _, ok := seen[clean]; ok {
|
||||
return fmt.Errorf("hot update bundle manifest file %q is duplicated", clean)
|
||||
}
|
||||
seen[clean] = struct{}{}
|
||||
if file.Bytes < 0 {
|
||||
return fmt.Errorf("hot update bundle manifest file %q has negative bytes", clean)
|
||||
}
|
||||
if len(file.Checksum) != sha256.Size*2 {
|
||||
return fmt.Errorf("hot update bundle manifest file %q has invalid checksum", clean)
|
||||
}
|
||||
if _, err := hex.DecodeString(file.Checksum); err != nil {
|
||||
return fmt.Errorf("hot update bundle manifest file %q has invalid checksum: %w", clean, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBundlePath(name string) error {
|
||||
raw := strings.TrimSpace(filepath.ToSlash(name))
|
||||
if raw == "" || raw == "." || strings.HasPrefix(raw, "/") || filepath.IsAbs(name) || filepath.VolumeName(filepath.FromSlash(raw)) != "" {
|
||||
return fmt.Errorf("hot update bundle path %q is invalid", name)
|
||||
}
|
||||
clean := path.Clean(raw)
|
||||
trimmedRaw := strings.TrimSuffix(raw, "/")
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || clean != trimmedRaw {
|
||||
return fmt.Errorf("hot update bundle path %q is invalid", name)
|
||||
}
|
||||
for _, part := range strings.Split(clean, "/") {
|
||||
if part == "" || part == "." || part == ".." {
|
||||
return fmt.Errorf("hot update bundle path %q is invalid", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameBundleFiles(left, right []BundleFile) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
left = append([]BundleFile(nil), left...)
|
||||
right = append([]BundleFile(nil), right...)
|
||||
sort.Slice(left, func(i, j int) bool { return left[i].Path < left[j].Path })
|
||||
sort.Slice(right, func(i, j int) bool { return right[i].Path < right[j].Path })
|
||||
for idx := range left {
|
||||
leftPath := path.Clean(filepath.ToSlash(left[idx].Path))
|
||||
rightPath := path.Clean(filepath.ToSlash(right[idx].Path))
|
||||
if leftPath != rightPath || left[idx].Bytes != right[idx].Bytes || !strings.EqualFold(left[idx].Checksum, right[idx].Checksum) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func extractBundleFile(file *zip.File, target string, maxBytes int64) error {
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if maxBytes > 0 && file.UncompressedSize64 > uint64(maxBytes) {
|
||||
return fmt.Errorf("hot update bundle file %s exceeds max bytes: got %d want <= %d", filepath.ToSlash(file.Name), file.UncompressedSize64, maxBytes)
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = rc.Close() }()
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, file.FileInfo().Mode()) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defMaxBundleBytes
|
||||
}
|
||||
written, err := io.Copy(out, io.LimitReader(rc, maxBytes+1))
|
||||
if err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
if written > maxBytes {
|
||||
_ = out.Close()
|
||||
return fmt.Errorf("hot update bundle file %s exceeds max bytes: got > %d", filepath.ToSlash(file.Name), maxBytes)
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BundleDiff struct {
|
||||
PreviousVersion string `json:"previous_version,omitempty"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
Added []BundleFile `json:"added,omitempty"`
|
||||
Modified []BundleFile `json:"modified,omitempty"`
|
||||
Removed []BundleFile `json:"removed,omitempty"`
|
||||
Unchanged []BundleFile `json:"unchanged,omitempty"`
|
||||
ChangedBytes int64 `json:"changed_bytes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func ScanBundleManifest(ctx context.Context, sourceDir, version string) (BundleManifest, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
files, err := bundleFiles(ctx, sourceDir)
|
||||
if err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
bytes, checksum := bundleSummary(files)
|
||||
return BundleManifest{
|
||||
Version: version,
|
||||
Files: cloneBundleFiles(files),
|
||||
Bytes: bytes,
|
||||
Checksum: checksum,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func DiffBundleManifests(previous, current BundleManifest) BundleDiff {
|
||||
previousByPath := bundleFilesByPath(previous.Files)
|
||||
currentByPath := bundleFilesByPath(current.Files)
|
||||
diff := BundleDiff{
|
||||
PreviousVersion: previous.Version,
|
||||
CurrentVersion: current.Version,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
for path, file := range currentByPath {
|
||||
old, ok := previousByPath[path]
|
||||
switch {
|
||||
case !ok:
|
||||
diff.Added = append(diff.Added, file)
|
||||
diff.ChangedBytes += file.Bytes
|
||||
case old.Checksum != file.Checksum || old.Bytes != file.Bytes:
|
||||
diff.Modified = append(diff.Modified, file)
|
||||
diff.ChangedBytes += file.Bytes
|
||||
default:
|
||||
diff.Unchanged = append(diff.Unchanged, file)
|
||||
}
|
||||
}
|
||||
for path, file := range previousByPath {
|
||||
if _, ok := currentByPath[path]; ok {
|
||||
continue
|
||||
}
|
||||
diff.Removed = append(diff.Removed, file)
|
||||
}
|
||||
sortBundleFiles(diff.Added)
|
||||
sortBundleFiles(diff.Modified)
|
||||
sortBundleFiles(diff.Removed)
|
||||
sortBundleFiles(diff.Unchanged)
|
||||
return diff
|
||||
}
|
||||
|
||||
func bundleFilesByPath(files []BundleFile) map[string]BundleFile {
|
||||
out := make(map[string]BundleFile, len(files))
|
||||
for _, file := range files {
|
||||
if err := validateBundlePath(file.Path); err != nil {
|
||||
continue
|
||||
}
|
||||
out[file.Path] = file
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneBundleFiles(files []BundleFile) []BundleFile {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]BundleFile(nil), files...)
|
||||
}
|
||||
|
||||
func sortBundleFiles(files []BundleFile) {
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/eventlog"
|
||||
)
|
||||
|
||||
const (
|
||||
EventLogIntentStream = "hotupdate.intent"
|
||||
EventLogProgressStream = "hotupdate.progress"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrControlLogRequired = errors.New("hot update eventlog control requires eventlog")
|
||||
ErrControlConflict = errors.New("hot update control idempotency key conflicts with existing intent")
|
||||
ErrInvalidControlEvent = errors.New("hot update control event payload is invalid")
|
||||
ErrControlPollInterval = errors.New("hot update control poll interval is invalid")
|
||||
ErrControlInstanceID = errors.New("hot update control instance id generation failed")
|
||||
defControlPoll = 2 * time.Second
|
||||
)
|
||||
|
||||
var controlRandRead = rand.Read
|
||||
|
||||
type EventLogControlOptions struct {
|
||||
Log *eventlog.Log
|
||||
PollInterval time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type EventLogControl struct {
|
||||
log *eventlog.Log
|
||||
pollInterval time.Duration
|
||||
now func() time.Time
|
||||
|
||||
ops sync.RWMutex
|
||||
mu sync.Mutex
|
||||
intents map[string]Intent
|
||||
progress map[string][]Progress
|
||||
watchers map[string]map[chan Intent]struct{}
|
||||
done chan struct{}
|
||||
closed bool
|
||||
instanceID string
|
||||
progressSequence uint64
|
||||
}
|
||||
|
||||
type intentEnvelope struct {
|
||||
Target string `json:"target"`
|
||||
Intent Intent `json:"intent"`
|
||||
}
|
||||
|
||||
type progressEnvelope struct {
|
||||
Progress Progress `json:"progress"`
|
||||
}
|
||||
|
||||
func NewEventLogControl(options EventLogControlOptions) (*EventLogControl, error) {
|
||||
if options.Log == nil {
|
||||
return nil, ErrControlLogRequired
|
||||
}
|
||||
pollInterval := options.PollInterval
|
||||
if pollInterval == 0 {
|
||||
pollInterval = defControlPoll
|
||||
}
|
||||
if pollInterval < 0 {
|
||||
return nil, ErrControlPollInterval
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
instanceID, err := newControlInstanceID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EventLogControl{
|
||||
log: options.Log,
|
||||
pollInterval: pollInterval,
|
||||
now: now,
|
||||
intents: make(map[string]Intent),
|
||||
progress: make(map[string][]Progress),
|
||||
watchers: make(map[string]map[chan Intent]struct{}),
|
||||
done: make(chan struct{}),
|
||||
instanceID: instanceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *EventLogControl) Publish(ctx context.Context, target string, intent Intent) error {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
now, err := c.ready()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err = normalizeTarget(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
intent, err = normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release, err := c.beginWrite()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
// EventLog 控制面把 intent 持久化为事件,再通知 watcher;watcher 丢通知时可通过轮询补回最新 intent。
|
||||
key := intentIdempotencyKey(target, intent)
|
||||
existing, ok, err := c.log.GetByIdempotencyKey(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
existingIntent, err := decodeIntentEvent(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !reflect.DeepEqual(existingIntent, intent) {
|
||||
return fmt.Errorf("%w: target=%s version=%s", ErrControlConflict, target, intent.Version)
|
||||
}
|
||||
c.storeIntentAndNotify(ctx, target, existingIntent)
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(intentEnvelope{Target: target, Intent: intent})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.log.Append(ctx, eventlog.Event{
|
||||
Stream: EventLogIntentStream,
|
||||
Type: string(intent.Action),
|
||||
AggregateID: target,
|
||||
IdempotencyKey: key,
|
||||
Payload: payload,
|
||||
Status: eventlog.StatusPublished,
|
||||
PublishedAt: now().UTC(),
|
||||
Headers: map[string]string{
|
||||
"hotupdate_target": target,
|
||||
"hotupdate_action": string(intent.Action),
|
||||
"hotupdate_version": intent.Version,
|
||||
},
|
||||
}); err != nil {
|
||||
if errors.Is(err, eventlog.ErrIdempotencyConflict) {
|
||||
return fmt.Errorf("%w: target=%s version=%s", ErrControlConflict, target, intent.Version)
|
||||
}
|
||||
return err
|
||||
}
|
||||
c.storeIntentAndNotify(ctx, target, intent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *EventLogControl) Watch(ctx context.Context, target string) (<-chan Intent, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if _, err := c.ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, err := normalizeTarget(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Watch 先注册 channel,再加载最新 intent,避免注册窗口内发布的新 intent 被漏掉。
|
||||
ch := make(chan Intent, 1)
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
if c.watchers[target] == nil {
|
||||
c.watchers[target] = make(map[chan Intent]struct{})
|
||||
}
|
||||
c.watchers[target][ch] = struct{}{}
|
||||
if intent, ok := c.intents[target]; ok {
|
||||
ch <- intent
|
||||
}
|
||||
c.mu.Unlock()
|
||||
go c.watchLoop(ctx, target, ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (c *EventLogControl) Report(ctx context.Context, progress Progress) error {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
now, err := c.ready()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
progress, err = normalizeProgress(progress, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release, err := c.beginWrite()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
payload, err := json.Marshal(progressEnvelope{Progress: progress})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instanceID, sequence := c.nextProgressKeyParts()
|
||||
if _, err := c.log.Append(ctx, eventlog.Event{
|
||||
Stream: EventLogProgressStream,
|
||||
Type: string(progress.Stage),
|
||||
AggregateID: progress.Target,
|
||||
IdempotencyKey: controlProgressKey(progress, instanceID, sequence),
|
||||
Payload: payload,
|
||||
Status: eventlog.StatusPublished,
|
||||
PublishedAt: now().UTC(),
|
||||
Headers: map[string]string{
|
||||
"hotupdate_target": progress.Target,
|
||||
"hotupdate_node": progress.NodeID,
|
||||
"hotupdate_version": progress.Version,
|
||||
"hotupdate_stage": string(progress.Stage),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
if !c.closed {
|
||||
c.progress[progress.Target] = append(c.progress[progress.Target], progress)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *EventLogControl) beginWrite() (func(), error) {
|
||||
c.ops.RLock()
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
c.ops.RUnlock()
|
||||
return nil, ErrControlClosed
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return c.ops.RUnlock, nil
|
||||
}
|
||||
|
||||
func (c *EventLogControl) ready() (func() time.Time, error) {
|
||||
if c == nil || c.log == nil {
|
||||
return nil, ErrControlLogRequired
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.pollInterval < 0 {
|
||||
return nil, ErrControlPollInterval
|
||||
}
|
||||
if c.pollInterval == 0 {
|
||||
c.pollInterval = defControlPoll
|
||||
}
|
||||
if c.now == nil {
|
||||
c.now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
if c.intents == nil {
|
||||
c.intents = make(map[string]Intent)
|
||||
}
|
||||
if c.progress == nil {
|
||||
c.progress = make(map[string][]Progress)
|
||||
}
|
||||
if c.watchers == nil {
|
||||
c.watchers = make(map[string]map[chan Intent]struct{})
|
||||
}
|
||||
if c.done == nil {
|
||||
c.done = make(chan struct{})
|
||||
}
|
||||
if c.instanceID == "" {
|
||||
instanceID, err := newControlInstanceID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.instanceID = instanceID
|
||||
}
|
||||
return c.now, nil
|
||||
}
|
||||
|
||||
func (c *EventLogControl) Snapshot() ControlSnapshot {
|
||||
if c == nil {
|
||||
return ControlSnapshot{}
|
||||
}
|
||||
c.mu.Lock()
|
||||
intents := make(map[string]Intent, len(c.intents))
|
||||
for target, intent := range c.intents {
|
||||
intents[target] = intent
|
||||
}
|
||||
progress := make(map[string][]Progress, len(c.progress))
|
||||
for target, items := range c.progress {
|
||||
progress[target] = append([]Progress(nil), items...)
|
||||
}
|
||||
watchers := make(map[string]int, len(c.watchers))
|
||||
for target, list := range c.watchers {
|
||||
watchers[target] = len(list)
|
||||
}
|
||||
snapshot := ControlSnapshot{Intents: intents, Progress: progress, Watchers: watchers, Closed: c.closed}
|
||||
c.mu.Unlock()
|
||||
if len(snapshot.Intents) == 0 {
|
||||
snapshot.Intents = nil
|
||||
}
|
||||
if len(snapshot.Progress) == 0 {
|
||||
snapshot.Progress = nil
|
||||
}
|
||||
if len(snapshot.Watchers) == 0 {
|
||||
snapshot.Watchers = nil
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (c *EventLogControl) Close() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.ops.Lock()
|
||||
defer c.ops.Unlock()
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.closed = true
|
||||
// Close 关闭所有 watcher channel;EventLog 里的 intent/progress 仍保留用于审计。
|
||||
if c.done == nil {
|
||||
c.done = make(chan struct{})
|
||||
}
|
||||
close(c.done)
|
||||
for target, watchers := range c.watchers {
|
||||
for ch := range watchers {
|
||||
close(ch)
|
||||
}
|
||||
delete(c.watchers, target)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *EventLogControl) watchLoop(ctx context.Context, target string, ch chan Intent) {
|
||||
defer c.removeWatcher(target, ch)
|
||||
// watchLoop 定期从 EventLog 读取最新 intent,作为内存通知丢失后的补偿。
|
||||
if c.isDone() {
|
||||
return
|
||||
}
|
||||
_ = c.reloadLatestIntent(ctx, target)
|
||||
if c.pollInterval <= 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-c.done:
|
||||
}
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(c.pollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = c.reloadLatestIntent(ctx, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *EventLogControl) isDone() bool {
|
||||
select {
|
||||
case <-c.done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *EventLogControl) removeWatcher(target string, ch chan Intent) {
|
||||
c.mu.Lock()
|
||||
if watchers := c.watchers[target]; watchers != nil {
|
||||
if _, ok := watchers[ch]; ok {
|
||||
delete(watchers, ch)
|
||||
close(ch)
|
||||
}
|
||||
if len(watchers) == 0 {
|
||||
delete(c.watchers, target)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *EventLogControl) reloadLatestIntent(ctx context.Context, target string) error {
|
||||
events, err := c.log.ListByStreamAggregate(ctx, EventLogIntentStream, target, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
intent, err := decodeIntentEvent(events[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.storeIntentAndNotify(ctx, target, intent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *EventLogControl) storeIntentAndNotify(_ context.Context, target string, intent Intent) {
|
||||
c.mu.Lock()
|
||||
// 内存 latest 是读优化,权威记录仍是 EventLog;通知失败不会删除持久化 intent。
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if existing, ok := c.intents[target]; ok && reflect.DeepEqual(existing, intent) {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.intents[target] = intent
|
||||
for ch := range c.watchers[target] {
|
||||
sendLatestIntent(ch, intent)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func decodeIntentEvent(event eventlog.Event) (Intent, error) {
|
||||
var envelope intentEnvelope
|
||||
if len(event.Payload) == 0 {
|
||||
return Intent{}, ErrInvalidControlEvent
|
||||
}
|
||||
if err := json.Unmarshal(event.Payload, &envelope); err != nil {
|
||||
return Intent{}, fmt.Errorf("%w: %w", ErrInvalidControlEvent, err)
|
||||
}
|
||||
intent, err := normalizeIntent(envelope.Intent)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
func intentIdempotencyKey(target string, intent Intent) string {
|
||||
if intent.Sequence > 0 {
|
||||
return fmt.Sprintf("hotupdate:intent:v1:%s:%d", encodeEventKeyPart(target), intent.Sequence)
|
||||
}
|
||||
return fmt.Sprintf("hotupdate:intent:v1:%s:%s:%s", encodeEventKeyPart(target), encodeEventKeyPart(string(intent.Action)), encodeEventKeyPart(intent.Version))
|
||||
}
|
||||
|
||||
func progressIdemKey(progress Progress) string {
|
||||
parts := []string{
|
||||
"hotupdate:progress",
|
||||
"v1",
|
||||
encodeEventKeyPart(progress.Target),
|
||||
encodeEventKeyPart(progress.NodeID),
|
||||
encodeEventKeyPart(progress.Version),
|
||||
encodeEventKeyPart(string(progress.Stage)),
|
||||
progressFingerprint(progress),
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func controlProgressKey(progress Progress, instanceID string, sequence uint64) string {
|
||||
parts := []string{
|
||||
progressIdemKey(progress),
|
||||
encodeEventKeyPart(instanceID),
|
||||
fmt.Sprint(sequence),
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func (c *EventLogControl) nextProgressKeyParts() (string, uint64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.progressSequence++
|
||||
return c.instanceID, c.progressSequence
|
||||
}
|
||||
|
||||
func newControlInstanceID() (string, error) {
|
||||
var raw [8]byte
|
||||
if _, err := controlRandRead(raw[:]); err != nil {
|
||||
return "", fmt.Errorf("%w: %w", ErrControlInstanceID, err)
|
||||
}
|
||||
return hex.EncodeToString(raw[:]), nil
|
||||
}
|
||||
|
||||
func encodeEventKeyPart(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "_"
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
|
||||
func progressFingerprint(progress Progress) string {
|
||||
payload := strings.Join([]string{
|
||||
strings.TrimSpace(progress.Target),
|
||||
strings.TrimSpace(progress.NodeID),
|
||||
strings.TrimSpace(progress.Version),
|
||||
strings.TrimSpace(string(progress.Stage)),
|
||||
strings.TrimSpace(progress.Status),
|
||||
strings.TrimSpace(progress.Detail),
|
||||
progress.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}, "\x00")
|
||||
sum := sha256.Sum256([]byte(payload))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FileHashEntry struct {
|
||||
Path string `json:"path"`
|
||||
AbsPath string `json:"abs_path,omitempty"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
ModTimeUnix int64 `json:"mod_time_unix"`
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
type FileHashCacheStats struct {
|
||||
Entries int `json:"entries"`
|
||||
Hits int `json:"hits"`
|
||||
Misses int `json:"misses"`
|
||||
}
|
||||
|
||||
type FileHashCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]FileHashEntry
|
||||
hits int
|
||||
misses int
|
||||
}
|
||||
|
||||
func NewFileHashCache(entries ...FileHashEntry) *FileHashCache {
|
||||
cache := &FileHashCache{entries: make(map[string]FileHashEntry, len(entries))}
|
||||
for _, entry := range entries {
|
||||
if entry.AbsPath == "" || entry.Checksum == "" {
|
||||
continue
|
||||
}
|
||||
cache.entries[entry.AbsPath] = entry
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
func (c *FileHashCache) Snapshot() []FileHashEntry {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
out := make([]FileHashEntry, 0, len(c.entries))
|
||||
for _, entry := range c.entries {
|
||||
out = append(out, entry)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].AbsPath < out[j].AbsPath })
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *FileHashCache) Stats() FileHashCacheStats {
|
||||
if c == nil {
|
||||
return FileHashCacheStats{}
|
||||
}
|
||||
c.mu.Lock()
|
||||
stats := FileHashCacheStats{Entries: len(c.entries), Hits: c.hits, Misses: c.misses}
|
||||
c.mu.Unlock()
|
||||
return stats
|
||||
}
|
||||
|
||||
func ScanBundleWithCache(ctx context.Context, sourceDir, version string, cache *FileHashCache) (BundleManifest, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
files, err := bundleFilesHash(ctx, sourceDir, cache)
|
||||
if err != nil {
|
||||
return BundleManifest{}, err
|
||||
}
|
||||
bytes, checksum := bundleSummary(files)
|
||||
return BundleManifest{
|
||||
Version: version,
|
||||
Files: cloneBundleFiles(files),
|
||||
Bytes: bytes,
|
||||
Checksum: checksum,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func bundleFilesHash(ctx context.Context, root string, cache *FileHashCache) ([]BundleFile, error) {
|
||||
if cache == nil {
|
||||
return bundleFiles(ctx, root)
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
var files []BundleFile
|
||||
if err := filepath.WalkDir(root, func(filePath string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctxErr := contextErr(ctx); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("hot update bundle rejects symlink %q", filePath)
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := cache.bundleFile(ctx, filePath, filepath.ToSlash(rel), info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, file)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (c *FileHashCache) bundleFile(ctx context.Context, filePath, rel string, info os.FileInfo) (BundleFile, error) {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return BundleFile{}, err
|
||||
}
|
||||
modUnix := info.ModTime().UTC().UnixNano()
|
||||
c.mu.Lock()
|
||||
if entry, ok := c.entries[absPath]; ok && entry.Bytes == info.Size() && entry.ModTimeUnix == modUnix && entry.Checksum != "" {
|
||||
c.hits++
|
||||
c.mu.Unlock()
|
||||
return BundleFile{Path: rel, Bytes: entry.Bytes, Checksum: entry.Checksum}, nil
|
||||
}
|
||||
c.misses++
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return BundleFile{}, err
|
||||
}
|
||||
data, err := os.ReadFile(filePath) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if err != nil {
|
||||
return BundleFile{}, err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
entry := FileHashEntry{
|
||||
Path: rel,
|
||||
AbsPath: absPath,
|
||||
Bytes: int64(len(data)),
|
||||
ModTimeUnix: modUnix,
|
||||
Checksum: hex.EncodeToString(sum[:]),
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.entries[absPath] = entry
|
||||
c.mu.Unlock()
|
||||
return BundleFile{Path: rel, Bytes: entry.Bytes, Checksum: entry.Checksum}, nil
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ManagerOptions struct {
|
||||
Target string
|
||||
NodeID string
|
||||
Workspace string
|
||||
ApplyTimeout time.Duration
|
||||
ReportTimeout time.Duration
|
||||
Control Control
|
||||
Source Source
|
||||
Applier Applier
|
||||
Logger *slog.Logger
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
target string
|
||||
nodeID string
|
||||
workspace string
|
||||
applyTimeout time.Duration
|
||||
reportTimeout time.Duration
|
||||
control Control
|
||||
source Source
|
||||
applier Applier
|
||||
logger *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
mu sync.RWMutex
|
||||
started bool
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
snapshot Snapshot
|
||||
|
||||
lifecycleCtx context.Context
|
||||
lifecycleCancel context.CancelFunc
|
||||
autoRestoreTimer *time.Timer
|
||||
}
|
||||
|
||||
type patchWindowApplier interface {
|
||||
PatchLiveDuration() time.Duration
|
||||
}
|
||||
|
||||
func NewManager(options ManagerOptions) (*Manager, error) {
|
||||
target, err := normalizeTarget(options.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if options.Control == nil {
|
||||
options.Control = NewMemoryControl()
|
||||
}
|
||||
if options.Source == nil {
|
||||
options.Source = LocalSource{}
|
||||
}
|
||||
if options.Applier == nil {
|
||||
return nil, ErrApplyRequired
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
nodeID := strings.TrimSpace(options.NodeID)
|
||||
if nodeID == "" {
|
||||
nodeID = "local"
|
||||
}
|
||||
reportTimeout := options.ReportTimeout
|
||||
if reportTimeout <= 0 {
|
||||
reportTimeout = 30 * time.Second
|
||||
}
|
||||
lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background())
|
||||
return &Manager{
|
||||
target: target,
|
||||
nodeID: nodeID,
|
||||
workspace: strings.TrimSpace(options.Workspace),
|
||||
applyTimeout: options.ApplyTimeout,
|
||||
reportTimeout: reportTimeout,
|
||||
control: options.Control,
|
||||
source: options.Source,
|
||||
applier: options.Applier,
|
||||
logger: options.Logger,
|
||||
now: now,
|
||||
lifecycleCtx: lifecycleCtx,
|
||||
lifecycleCancel: lifecycleCancel,
|
||||
snapshot: Snapshot{
|
||||
Target: target,
|
||||
NodeID: nodeID,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Name() string {
|
||||
if m == nil {
|
||||
return "hot-update"
|
||||
}
|
||||
return "hot-update-" + m.target
|
||||
}
|
||||
|
||||
func (m *Manager) Start(ctx context.Context) error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.started {
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
m.ensureLifeLocked()
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
m.started = true
|
||||
m.cancel = cancel
|
||||
m.done = done
|
||||
m.snapshot.Started = true
|
||||
m.mu.Unlock()
|
||||
// Manager 只监听控制面 intent;真正下载、校验、替换由 source/applier 执行,便于替换成 etcd/SQL 控制面。
|
||||
go m.loop(runCtx, done)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(ctx context.Context) error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
m.mu.Lock()
|
||||
cancel := m.cancel
|
||||
done := m.done
|
||||
autoRestoreTimer := m.autoRestoreTimer
|
||||
lifecycleCancel := m.lifecycleCancel
|
||||
m.cancel = nil
|
||||
m.done = nil
|
||||
m.autoRestoreTimer = nil
|
||||
m.lifecycleCtx = nil
|
||||
m.lifecycleCancel = nil
|
||||
m.started = false
|
||||
m.snapshot.Started = false
|
||||
m.snapshot.AutoRestoreAt = time.Time{}
|
||||
m.mu.Unlock()
|
||||
if lifecycleCancel != nil {
|
||||
lifecycleCancel()
|
||||
}
|
||||
if autoRestoreTimer != nil {
|
||||
autoRestoreTimer.Stop()
|
||||
}
|
||||
// Stop 取消 watcher 和自动恢复计时器,并等待 loop 退出,避免停机后继续应用热更。
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Process(ctx context.Context, intent Intent) (ApplyResult, error) {
|
||||
if m == nil {
|
||||
return ApplyResult{}, errors.New("hot update manager is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
intent, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
// Process 是单个 intent 的状态机入口:restore 走回滚,其余动作按下载、校验、应用、报告推进。
|
||||
m.setIntent(intent)
|
||||
m.report(ctx, intent, StageAccepted, "ok", "")
|
||||
if intent.Action == ActionRestore {
|
||||
return m.restore(ctx, intent)
|
||||
}
|
||||
m.report(ctx, intent, StageDownloading, "ok", "")
|
||||
pkg, err := m.source.Fetch(ctx, intent, m.workspace)
|
||||
if err != nil {
|
||||
m.fail(ctx, intent, err)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.setPackage(pkg)
|
||||
if wait := time.Until(intent.AvailableAt); !intent.AvailableAt.IsZero() && wait > 0 {
|
||||
m.report(ctx, intent, StageScheduled, "ok", intent.AvailableAt.Format(time.RFC3339))
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
m.fail(ctx, intent, ctx.Err())
|
||||
return ApplyResult{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
m.report(ctx, intent, StageApplying, "ok", "")
|
||||
applyCtx := ctx
|
||||
cancel := func() {}
|
||||
if m.applyTimeout > 0 {
|
||||
applyCtx, cancel = context.WithTimeout(ctx, m.applyTimeout)
|
||||
}
|
||||
defer cancel()
|
||||
result, err := m.applier.Apply(applyCtx, pkg)
|
||||
if err != nil {
|
||||
m.fail(ctx, intent, err)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.snapshot.LastApplied = m.now()
|
||||
m.mu.Unlock()
|
||||
m.report(ctx, intent, StageApplied, "ok", "")
|
||||
m.scheduleAutoRestore(intent)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) restore(ctx context.Context, intent Intent) (ApplyResult, error) {
|
||||
restorer, ok := m.applier.(Restorer)
|
||||
if !ok {
|
||||
err := ErrRestoreRequired
|
||||
m.fail(ctx, intent, err)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.cancelAutoRestore()
|
||||
// restore 会取消自动恢复计时器,避免手动回滚和定时回滚并发执行。
|
||||
m.report(ctx, intent, StageRestoring, "ok", "")
|
||||
applyCtx := ctx
|
||||
cancel := func() {}
|
||||
if m.applyTimeout > 0 {
|
||||
applyCtx, cancel = context.WithTimeout(ctx, m.applyTimeout)
|
||||
}
|
||||
defer cancel()
|
||||
result, err := restorer.Restore(applyCtx, intent)
|
||||
if err != nil {
|
||||
m.fail(ctx, intent, err)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.snapshot.LastApplied = m.now()
|
||||
m.mu.Unlock()
|
||||
m.report(ctx, intent, StageRestored, "ok", "")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) NativePatchEnabled() bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := m.applier.(NativePatchApplier)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *Manager) Snapshot() Snapshot {
|
||||
if m == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
m.mu.RLock()
|
||||
snapshot := m.snapshot
|
||||
m.mu.RUnlock()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (m *Manager) Control() Control {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return m.control
|
||||
}
|
||||
|
||||
func (m *Manager) scheduleAutoRestore(intent Intent) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
duration := time.Duration(0)
|
||||
if applier, ok := m.applier.(patchWindowApplier); ok {
|
||||
duration = applier.PatchLiveDuration()
|
||||
}
|
||||
if duration <= 0 {
|
||||
m.cancelAutoRestore()
|
||||
return
|
||||
}
|
||||
// 原生补丁必须有存活窗口,到期自动 restore,防止紧急 patch 长期遗留在线上。
|
||||
restoreAt := m.now().UTC().Add(duration)
|
||||
restoreIntent := Intent{
|
||||
Action: ActionRestore,
|
||||
Version: intent.Version,
|
||||
Reason: "native patch auto restore after live window",
|
||||
RequestedBy: "system",
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.ensureLifeLocked()
|
||||
lifecycleCtx := m.lifecycleCtx
|
||||
if m.autoRestoreTimer != nil {
|
||||
m.autoRestoreTimer.Stop()
|
||||
}
|
||||
m.snapshot.AutoRestoreAt = restoreAt
|
||||
m.autoRestoreTimer = time.AfterFunc(duration, func() {
|
||||
if lifecycleCtx != nil && lifecycleCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if _, err := m.restore(lifecycleCtx, restoreIntent); err != nil && m.logger != nil && !errors.Is(err, context.Canceled) {
|
||||
m.logger.Warn("native patch auto restore failed", "target", m.target, "version", restoreIntent.Version, "error", err)
|
||||
}
|
||||
})
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) cancelAutoRestore() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.autoRestoreTimer != nil {
|
||||
m.autoRestoreTimer.Stop()
|
||||
m.autoRestoreTimer = nil
|
||||
}
|
||||
m.snapshot.AutoRestoreAt = time.Time{}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) ensureLifeLocked() {
|
||||
if m.lifecycleCtx != nil && m.lifecycleCtx.Err() == nil && m.lifecycleCancel != nil {
|
||||
return
|
||||
}
|
||||
m.lifecycleCtx, m.lifecycleCancel = context.WithCancel(context.Background())
|
||||
}
|
||||
|
||||
func (m *Manager) loop(ctx context.Context, done chan struct{}) {
|
||||
defer close(done)
|
||||
// Watch 返回的是控制面变更流;同一版本重复 intent 由 Process/Progress 负责幂等记录。
|
||||
changes, err := m.control.Watch(ctx, m.target)
|
||||
if err != nil {
|
||||
if m.logger != nil {
|
||||
m.logger.Warn("hot update watch failed", "target", m.target, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case intent, ok := <-changes:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := m.Process(ctx, intent); err != nil && m.logger != nil {
|
||||
m.logger.Warn("hot update apply failed", "target", m.target, "version", intent.Version, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) report(ctx context.Context, intent Intent, stage Stage, status, detail string) {
|
||||
// progress 写失败只记录日志,不阻断 apply;因此 release gate 必须检查报告完整性。
|
||||
progress := Progress{
|
||||
Target: m.target,
|
||||
NodeID: m.nodeID,
|
||||
Version: intent.Version,
|
||||
Stage: stage,
|
||||
Status: strings.TrimSpace(status),
|
||||
Detail: strings.TrimSpace(detail),
|
||||
UpdatedAt: m.now(),
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.snapshot.LastProgress = progress
|
||||
m.mu.Unlock()
|
||||
reportCtx, cancel := m.reportContext(ctx)
|
||||
defer cancel()
|
||||
if err := m.control.Report(reportCtx, progress); err != nil {
|
||||
m.mu.Lock()
|
||||
m.snapshot.Failures++
|
||||
m.mu.Unlock()
|
||||
if m.logger != nil && !errors.Is(err, context.Canceled) {
|
||||
m.logger.Warn("hot update progress report failed", "target", m.target, "version", intent.Version, "stage", stage, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) reportContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return ctx, func() {}
|
||||
}
|
||||
timeout := 30 * time.Second
|
||||
if m != nil && m.reportTimeout > 0 {
|
||||
timeout = m.reportTimeout
|
||||
}
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
}
|
||||
|
||||
func (m *Manager) fail(ctx context.Context, intent Intent, err error) {
|
||||
detail := ""
|
||||
if err != nil {
|
||||
detail = err.Error()
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.snapshot.Failures++
|
||||
m.mu.Unlock()
|
||||
m.report(ctx, intent, StageFailed, "failed", detail)
|
||||
}
|
||||
|
||||
func (m *Manager) setIntent(intent Intent) {
|
||||
m.mu.Lock()
|
||||
m.snapshot.LastIntent = intent
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) setPackage(pkg Package) {
|
||||
m.mu.Lock()
|
||||
m.snapshot.LastPackage = pkg
|
||||
m.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrControlRequired = errors.New("hot update control is required")
|
||||
|
||||
type MemoryControl struct {
|
||||
mu sync.RWMutex
|
||||
intents map[string]Intent
|
||||
keys map[string]string
|
||||
progress map[string][]Progress
|
||||
watchers map[string]map[chan Intent]struct{}
|
||||
done chan struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewMemoryControl() *MemoryControl {
|
||||
return &MemoryControl{
|
||||
intents: make(map[string]Intent),
|
||||
keys: make(map[string]string),
|
||||
progress: make(map[string][]Progress),
|
||||
watchers: make(map[string]map[chan Intent]struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MemoryControl) Publish(ctx context.Context, target string, intent Intent) error {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := normalizeTarget(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
intent, err = normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := intentIdempotencyKey(target, intent)
|
||||
// 内存控制面只保留每个 target 的最新 intent,适合本地演练,不适合多节点生产推进。
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return ErrControlClosed
|
||||
}
|
||||
if existingKey, ok := c.keys[target]; ok && existingKey == key {
|
||||
existing := c.intents[target]
|
||||
if !reflect.DeepEqual(existing, intent) {
|
||||
c.mu.Unlock()
|
||||
return ErrControlConflict
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.intents[target] = intent
|
||||
c.keys[target] = key
|
||||
for ch := range c.watchers[target] {
|
||||
sendLatestIntent(ch, intent)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MemoryControl) Watch(ctx context.Context, target string) (<-chan Intent, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := c.ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, err := normalizeTarget(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// watcher channel 容量为 1,新 intent 会覆盖旧待处理通知,避免慢 watcher 堆积内存。
|
||||
ch := make(chan Intent, 1)
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
if c.watchers[target] == nil {
|
||||
c.watchers[target] = make(map[chan Intent]struct{})
|
||||
}
|
||||
c.watchers[target][ch] = struct{}{}
|
||||
if intent, ok := c.intents[target]; ok {
|
||||
ch <- intent
|
||||
}
|
||||
c.mu.Unlock()
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-c.done:
|
||||
}
|
||||
c.mu.Lock()
|
||||
if watchers := c.watchers[target]; watchers != nil {
|
||||
if _, ok := watchers[ch]; ok {
|
||||
delete(watchers, ch)
|
||||
close(ch)
|
||||
}
|
||||
if len(watchers) == 0 {
|
||||
delete(c.watchers, target)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (c *MemoryControl) Report(ctx context.Context, progress Progress) error {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
progress, err := normalizeProgress(progress, time.Now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return ErrControlClosed
|
||||
}
|
||||
c.progress[progress.Target] = append(c.progress[progress.Target], progress)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendLatestIntent(ch chan Intent, intent Intent) {
|
||||
select {
|
||||
case ch <- intent:
|
||||
return
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case ch <- intent:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MemoryControl) Snapshot() ControlSnapshot {
|
||||
if c == nil {
|
||||
return ControlSnapshot{}
|
||||
}
|
||||
c.mu.RLock()
|
||||
intents := make(map[string]Intent, len(c.intents))
|
||||
for target, intent := range c.intents {
|
||||
intents[target] = intent
|
||||
}
|
||||
progress := make(map[string][]Progress, len(c.progress))
|
||||
for target, items := range c.progress {
|
||||
progress[target] = append([]Progress(nil), items...)
|
||||
}
|
||||
watchers := make(map[string]int, len(c.watchers))
|
||||
for target, list := range c.watchers {
|
||||
watchers[target] = len(list)
|
||||
}
|
||||
snapshot := ControlSnapshot{Intents: intents, Progress: progress, Watchers: watchers, Closed: c.closed}
|
||||
c.mu.RUnlock()
|
||||
if len(snapshot.Intents) == 0 {
|
||||
snapshot.Intents = nil
|
||||
}
|
||||
if len(snapshot.Progress) == 0 {
|
||||
snapshot.Progress = nil
|
||||
}
|
||||
if len(snapshot.Watchers) == 0 {
|
||||
snapshot.Watchers = nil
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (c *MemoryControl) Close() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.closed = true
|
||||
// Close 会关闭所有 watcher;之后 Publish/Watch 均拒绝,防止停机后继续推进热更。
|
||||
if c.done == nil {
|
||||
c.done = make(chan struct{})
|
||||
}
|
||||
close(c.done)
|
||||
for target, watchers := range c.watchers {
|
||||
for ch := range watchers {
|
||||
close(ch)
|
||||
}
|
||||
delete(c.watchers, target)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MemoryControl) ready() error {
|
||||
if c == nil {
|
||||
return ErrControlRequired
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.intents == nil {
|
||||
c.intents = make(map[string]Intent)
|
||||
}
|
||||
if c.keys == nil {
|
||||
c.keys = make(map[string]string)
|
||||
}
|
||||
if c.progress == nil {
|
||||
c.progress = make(map[string][]Progress)
|
||||
}
|
||||
if c.watchers == nil {
|
||||
c.watchers = make(map[string]map[chan Intent]struct{})
|
||||
}
|
||||
if c.done == nil {
|
||||
c.done = make(chan struct{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/nativepatch"
|
||||
)
|
||||
|
||||
type NativePatchApplier struct {
|
||||
Engine nativepatch.Engine
|
||||
Policy nativepatch.Policy
|
||||
}
|
||||
|
||||
func (a NativePatchApplier) Apply(ctx context.Context, pkg Package) (ApplyResult, error) {
|
||||
// Native patch applier 只处理已验签、已解包的补丁目录;函数级替换策略由 nativepatch.Policy 再校验一次。
|
||||
engine := a.engine()
|
||||
nativePkg, err := nativepatch.LoadPackage(ctx, pkg.Directory)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if err := a.Policy.Validate(nativePkg.Plan); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
result, err := engine.Apply(ctx, nativePkg)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
return patchApplyResult("native_patch", result.Version, result.Target, result.Applied, result.StartedAt, result.EndedAt), nil
|
||||
}
|
||||
|
||||
func (a NativePatchApplier) Restore(ctx context.Context, intent Intent) (ApplyResult, error) {
|
||||
// Restore 走 engine 的快照恢复,不重新加载包;失败时必须保留错误给控制面和 release gate。
|
||||
engine := a.engine()
|
||||
result, err := engine.Restore(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
version := intent.Version
|
||||
if version == "" {
|
||||
version = result.Version
|
||||
}
|
||||
return patchApplyResult("native_patch_restore", version, result.Target, result.Applied, result.StartedAt, result.EndedAt), nil
|
||||
}
|
||||
|
||||
func (a NativePatchApplier) engine() nativepatch.Engine {
|
||||
if a.Engine != nil {
|
||||
return a.Engine
|
||||
}
|
||||
return nativepatch.NewNativeEngine()
|
||||
}
|
||||
|
||||
func (a NativePatchApplier) PatchLiveDuration() time.Duration {
|
||||
// MaxLiveDuration 是原生补丁自动恢复窗口,0 表示不自动恢复,生产不建议关闭。
|
||||
return a.Policy.MaxLiveDuration
|
||||
}
|
||||
|
||||
func patchApplyResult(name, version, target string, symbols int, startedAt, endedAt time.Time) ApplyResult {
|
||||
return ApplyResult{
|
||||
Version: version,
|
||||
Applied: []string{
|
||||
name,
|
||||
},
|
||||
Details: map[string]any{
|
||||
"target": target,
|
||||
"symbols": symbols,
|
||||
"started_at": startedAt,
|
||||
"ended_at": endedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRolloutInvalid = errors.New("hot update rollout is invalid")
|
||||
ErrRolloutBatchPending = errors.New("hot update rollout batch is pending")
|
||||
ErrRolloutComplete = errors.New("hot update rollout is complete")
|
||||
)
|
||||
|
||||
type RolloutSpec struct {
|
||||
Target string `json:"target"`
|
||||
Version string `json:"version"`
|
||||
SourceURI string `json:"source_uri"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
NodeIDs []string `json:"node_ids"`
|
||||
BatchSize int `json:"batch_size"`
|
||||
AvailableAt time.Time `json:"available_at,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
RequestedBy string `json:"requested_by,omitempty"`
|
||||
SequenceBase int64 `json:"sequence_base,omitempty"`
|
||||
RollbackVersion string `json:"rollback_version,omitempty"`
|
||||
}
|
||||
|
||||
type RolloutNodeStatus struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Target string `json:"target"`
|
||||
Batch int `json:"batch"`
|
||||
Published bool `json:"published"`
|
||||
ApplyPublished bool `json:"apply_published"`
|
||||
RollbackPublished bool `json:"rollback_published"`
|
||||
Applied bool `json:"applied"`
|
||||
Failed bool `json:"failed"`
|
||||
Restored bool `json:"restored"`
|
||||
Stage Stage `json:"stage,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type RolloutBatchStatus struct {
|
||||
Index int `json:"index"`
|
||||
NodeIDs []string `json:"node_ids"`
|
||||
Published int `json:"published"`
|
||||
ApplyPublished int `json:"apply_published"`
|
||||
RollbackPublished int `json:"rollback_published"`
|
||||
Unpublished int `json:"unpublished"`
|
||||
Pending int `json:"pending"`
|
||||
InProgress int `json:"in_progress"`
|
||||
Applied int `json:"applied"`
|
||||
Failed int `json:"failed"`
|
||||
Restored int `json:"restored"`
|
||||
Complete bool `json:"complete"`
|
||||
RollbackComplete bool `json:"rollback_complete"`
|
||||
Nodes []RolloutNodeStatus `json:"nodes,omitempty"`
|
||||
}
|
||||
|
||||
type RolloutReport struct {
|
||||
Target string `json:"target"`
|
||||
Version string `json:"version"`
|
||||
BatchSize int `json:"batch_size"`
|
||||
Total int `json:"total"`
|
||||
Published int `json:"published"`
|
||||
ApplyPublished int `json:"apply_published"`
|
||||
RollbackPublished int `json:"rollback_published"`
|
||||
Unpublished int `json:"unpublished"`
|
||||
Pending int `json:"pending"`
|
||||
InProgress int `json:"in_progress"`
|
||||
Applied int `json:"applied"`
|
||||
Failed int `json:"failed"`
|
||||
Restored int `json:"restored"`
|
||||
Complete bool `json:"complete"`
|
||||
RollbackComplete bool `json:"rollback_complete"`
|
||||
NeedsRollback bool `json:"needs_rollback"`
|
||||
Batches []RolloutBatchStatus `json:"batches,omitempty"`
|
||||
}
|
||||
|
||||
func PublishNextBatch(ctx context.Context, control Control, spec RolloutSpec) (RolloutBatchStatus, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return RolloutBatchStatus{}, err
|
||||
}
|
||||
normalized, err := normalizeRolloutSpec(spec, true)
|
||||
if err != nil {
|
||||
return RolloutBatchStatus{}, err
|
||||
}
|
||||
report, err := EvaluateRollout(control, normalized)
|
||||
if err != nil {
|
||||
return RolloutBatchStatus{}, err
|
||||
}
|
||||
// 分批发布只推进第一个尚未发布且前序已完成的批次。
|
||||
// 如果当前批次还没全部成功,返回 ErrRolloutBatchPending,避免误把后续节点一起放开。
|
||||
for _, batch := range report.Batches {
|
||||
if batch.Published == 0 {
|
||||
if err := publishApplyBatch(ctx, control, normalized, batch.Index); err != nil {
|
||||
return RolloutBatchStatus{}, err
|
||||
}
|
||||
updated, err := EvaluateRollout(control, normalized)
|
||||
if err != nil {
|
||||
return RolloutBatchStatus{}, err
|
||||
}
|
||||
if batch.Index < len(updated.Batches) {
|
||||
return updated.Batches[batch.Index], nil
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
if !batch.Complete {
|
||||
return batch, ErrRolloutBatchPending
|
||||
}
|
||||
}
|
||||
return RolloutBatchStatus{}, ErrRolloutComplete
|
||||
}
|
||||
|
||||
func PublishRollback(ctx context.Context, control Control, spec RolloutSpec, nodeIDs []string) error {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
normalized, err := normalizeRolloutSpec(spec, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(normalized.RollbackVersion) == "" {
|
||||
return fmt.Errorf("%w: rollback_version is required", ErrRolloutInvalid)
|
||||
}
|
||||
targets := normRollbackNodes(normalized.NodeIDs, nodeIDs)
|
||||
if len(targets) == 0 {
|
||||
return fmt.Errorf("%w: rollback node_ids are required", ErrRolloutInvalid)
|
||||
}
|
||||
// 回滚按节点发布 restore 意图,不直接操作节点本地数据;节点侧仍要上报进度。
|
||||
for _, nodeID := range targets {
|
||||
intent := Intent{
|
||||
Action: ActionRestore,
|
||||
Version: normalized.RollbackVersion,
|
||||
Reason: firstNonEmpty(normalized.Reason, "rollback"),
|
||||
RequestedBy: normalized.RequestedBy,
|
||||
Sequence: rolloutSequence(normalized, 10000+rolloutNodeIndex(normalized.NodeIDs, nodeID), 0),
|
||||
}
|
||||
if err := control.Publish(ctx, rolloutNodeTarget(normalized.Target, nodeID), intent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EvaluateRollout(control Control, spec RolloutSpec) (RolloutReport, error) {
|
||||
if control == nil {
|
||||
return RolloutReport{}, ErrControlLogRequired
|
||||
}
|
||||
normalized, err := normalizeRolloutSpec(spec, false)
|
||||
if err != nil {
|
||||
return RolloutReport{}, err
|
||||
}
|
||||
snapshot := control.Snapshot()
|
||||
report := RolloutReport{
|
||||
Target: normalized.Target,
|
||||
Version: normalized.Version,
|
||||
BatchSize: normalized.BatchSize,
|
||||
Total: len(normalized.NodeIDs),
|
||||
}
|
||||
batches := rolloutBatches(normalized.NodeIDs, normalized.BatchSize)
|
||||
report.Batches = make([]RolloutBatchStatus, 0, len(batches))
|
||||
// 评估只读取控制面快照,不产生副作用;release gate 和 GM 页面都可以安全调用。
|
||||
for batchIndex, nodes := range batches {
|
||||
batch := RolloutBatchStatus{
|
||||
Index: batchIndex,
|
||||
NodeIDs: append([]string(nil), nodes...),
|
||||
Nodes: make([]RolloutNodeStatus, 0, len(nodes)),
|
||||
}
|
||||
for _, nodeID := range nodes {
|
||||
status := rolloutNodeStatus(snapshot, normalized, batchIndex, nodeID)
|
||||
if status.Published {
|
||||
report.Published++
|
||||
batch.Published++
|
||||
}
|
||||
if status.ApplyPublished {
|
||||
report.ApplyPublished++
|
||||
batch.ApplyPublished++
|
||||
}
|
||||
if status.RollbackPublished {
|
||||
report.RollbackPublished++
|
||||
batch.RollbackPublished++
|
||||
}
|
||||
if !status.Published {
|
||||
report.Unpublished++
|
||||
batch.Unpublished++
|
||||
}
|
||||
if rolloutNodePending(status) {
|
||||
report.Pending++
|
||||
batch.Pending++
|
||||
}
|
||||
if rolloutNodeBusy(status) {
|
||||
report.InProgress++
|
||||
batch.InProgress++
|
||||
}
|
||||
if status.Applied {
|
||||
report.Applied++
|
||||
batch.Applied++
|
||||
}
|
||||
if status.Failed {
|
||||
report.Failed++
|
||||
batch.Failed++
|
||||
}
|
||||
if status.Restored {
|
||||
report.Restored++
|
||||
batch.Restored++
|
||||
}
|
||||
batch.Nodes = append(batch.Nodes, status)
|
||||
}
|
||||
batch.Complete = len(nodes) > 0 && batch.Applied == len(nodes)
|
||||
batch.RollbackComplete = batch.RollbackPublished > 0 && batch.Restored == batch.RollbackPublished
|
||||
report.Batches = append(report.Batches, batch)
|
||||
}
|
||||
report.Complete = report.Total > 0 && report.Applied == report.Total
|
||||
report.RollbackComplete = report.RollbackPublished > 0 && report.Restored == report.RollbackPublished
|
||||
report.NeedsRollback = report.Failed > 0
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func publishApplyBatch(ctx context.Context, control Control, spec RolloutSpec, batchIndex int) error {
|
||||
if control == nil {
|
||||
return ErrControlLogRequired
|
||||
}
|
||||
batches := rolloutBatches(spec.NodeIDs, spec.BatchSize)
|
||||
if batchIndex < 0 || batchIndex >= len(batches) {
|
||||
return fmt.Errorf("%w: batch %d out of range", ErrRolloutInvalid, batchIndex)
|
||||
}
|
||||
// 每个节点写独立 target,方便单节点失败、补偿和回滚定位。
|
||||
for idx, nodeID := range batches[batchIndex] {
|
||||
intent := Intent{
|
||||
Action: ActionApply,
|
||||
Version: spec.Version,
|
||||
SourceURI: spec.SourceURI,
|
||||
Checksum: spec.Checksum,
|
||||
AvailableAt: spec.AvailableAt,
|
||||
Reason: spec.Reason,
|
||||
RequestedBy: spec.RequestedBy,
|
||||
Sequence: rolloutSequence(spec, batchIndex, idx),
|
||||
}
|
||||
if err := control.Publish(ctx, rolloutNodeTarget(spec.Target, nodeID), intent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rolloutNodeStatus(snapshot ControlSnapshot, spec RolloutSpec, batch int, nodeID string) RolloutNodeStatus {
|
||||
target := rolloutNodeTarget(spec.Target, nodeID)
|
||||
status := RolloutNodeStatus{NodeID: nodeID, Target: target, Batch: batch}
|
||||
// 同一个 target 只看匹配版本的 apply/restore 意图,避免旧发布污染当前报告。
|
||||
if intent, ok := snapshot.Intents[target]; ok && intent.Version == spec.Version && intent.Action == ActionApply {
|
||||
status.ApplyPublished = true
|
||||
status.Published = true
|
||||
}
|
||||
if rollbackVersion := strings.TrimSpace(spec.RollbackVersion); rollbackVersion != "" {
|
||||
if intent, ok := snapshot.Intents[target]; ok && intent.Version == rollbackVersion && intent.Action == ActionRestore {
|
||||
status.RollbackPublished = true
|
||||
status.Published = true
|
||||
}
|
||||
}
|
||||
for _, progress := range snapshot.Progress[target] {
|
||||
if progress.Version != spec.Version && progress.Version != spec.RollbackVersion {
|
||||
continue
|
||||
}
|
||||
// 进度按 UpdatedAt 取最新一条,节点重复上报不会把状态倒退。
|
||||
if progress.UpdatedAt.Before(status.UpdatedAt) {
|
||||
continue
|
||||
}
|
||||
status.Stage = progress.Stage
|
||||
status.Status = progress.Status
|
||||
status.Detail = progress.Detail
|
||||
status.UpdatedAt = progress.UpdatedAt
|
||||
status.Applied = progress.Version == spec.Version && progress.Stage == StageApplied
|
||||
status.Failed = progress.Version == spec.Version && progress.Stage == StageFailed
|
||||
status.Restored = progress.Version == spec.RollbackVersion && progress.Stage == StageRestored
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func rolloutNodePending(status RolloutNodeStatus) bool {
|
||||
if !status.Published || status.Applied || status.Failed || status.Restored {
|
||||
return false
|
||||
}
|
||||
return status.Stage == ""
|
||||
}
|
||||
|
||||
func rolloutNodeBusy(status RolloutNodeStatus) bool {
|
||||
if !status.Published || status.Applied || status.Failed || status.Restored {
|
||||
return false
|
||||
}
|
||||
switch status.Stage {
|
||||
case StageAccepted, StageDownloading, StageScheduled, StageApplying, StageRestoring:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRolloutSpec(spec RolloutSpec, requireSource bool) (RolloutSpec, error) {
|
||||
target, err := normalizeTarget(spec.Target)
|
||||
if err != nil {
|
||||
return RolloutSpec{}, err
|
||||
}
|
||||
spec.Target = strings.Trim(target, "/")
|
||||
spec.Version = strings.TrimSpace(spec.Version)
|
||||
spec.SourceURI = strings.TrimSpace(spec.SourceURI)
|
||||
spec.Checksum = strings.TrimSpace(spec.Checksum)
|
||||
spec.Reason = strings.TrimSpace(spec.Reason)
|
||||
spec.RequestedBy = strings.TrimSpace(spec.RequestedBy)
|
||||
spec.RollbackVersion = strings.TrimSpace(spec.RollbackVersion)
|
||||
if spec.Version == "" {
|
||||
return RolloutSpec{}, ErrVersionRequired
|
||||
}
|
||||
if requireSource && spec.SourceURI == "" {
|
||||
return RolloutSpec{}, ErrSourceRequired
|
||||
}
|
||||
spec.NodeIDs = normalizeNodeIDs(spec.NodeIDs)
|
||||
if len(spec.NodeIDs) == 0 {
|
||||
return RolloutSpec{}, fmt.Errorf("%w: node_ids are required", ErrRolloutInvalid)
|
||||
}
|
||||
if spec.BatchSize <= 0 {
|
||||
spec.BatchSize = 1
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func normalizeNodeIDs(nodeIDs []string) []string {
|
||||
seen := make(map[string]struct{}, len(nodeIDs))
|
||||
out := make([]string, 0, len(nodeIDs))
|
||||
for _, nodeID := range nodeIDs {
|
||||
nodeID = strings.Trim(strings.TrimSpace(nodeID), "/")
|
||||
if nodeID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[nodeID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[nodeID] = struct{}{}
|
||||
out = append(out, nodeID)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func normRollbackNodes(all []string, selected []string) []string {
|
||||
if len(selected) == 0 {
|
||||
return append([]string(nil), all...)
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(all))
|
||||
for _, nodeID := range all {
|
||||
allowed[nodeID] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(selected))
|
||||
seen := make(map[string]struct{}, len(selected))
|
||||
for _, nodeID := range normalizeNodeIDs(selected) {
|
||||
if _, ok := allowed[nodeID]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[nodeID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[nodeID] = struct{}{}
|
||||
out = append(out, nodeID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rolloutBatches(nodeIDs []string, batchSize int) [][]string {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1
|
||||
}
|
||||
var batches [][]string
|
||||
for start := 0; start < len(nodeIDs); start += batchSize {
|
||||
end := start + batchSize
|
||||
if end > len(nodeIDs) {
|
||||
end = len(nodeIDs)
|
||||
}
|
||||
batches = append(batches, append([]string(nil), nodeIDs[start:end]...))
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
func rolloutNodeTarget(target, nodeID string) string {
|
||||
return strings.Trim(strings.TrimSpace(target), "/") + "/" + strings.Trim(strings.TrimSpace(nodeID), "/")
|
||||
}
|
||||
|
||||
func rolloutSequence(spec RolloutSpec, batchIndex int, nodeIndex int) int64 {
|
||||
if spec.SequenceBase == 0 {
|
||||
return 0
|
||||
}
|
||||
return spec.SequenceBase + int64(batchIndex*1000+nodeIndex+1)
|
||||
}
|
||||
|
||||
func rolloutNodeIndex(nodes []string, nodeID string) int {
|
||||
for idx, current := range nodes {
|
||||
if current == nodeID {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const RolloutEvidenceKind = "hotupdate_rollout_evidence"
|
||||
|
||||
type RolloutEvidence struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Spec RolloutSpec `json:"spec"`
|
||||
Report RolloutReport `json:"report"`
|
||||
Result string `json:"result"`
|
||||
Valid bool `json:"valid"`
|
||||
Violations []string `json:"violations,omitempty"`
|
||||
}
|
||||
|
||||
func BuildRolloutEvidence(name string, spec RolloutSpec, control Control, generatedAt time.Time) (RolloutEvidence, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = "hotupdate-rollout"
|
||||
}
|
||||
normalized, err := normalizeRolloutSpec(spec, false)
|
||||
if err != nil {
|
||||
return RolloutEvidence{}, err
|
||||
}
|
||||
report, err := EvaluateRollout(control, normalized)
|
||||
if err != nil {
|
||||
return RolloutEvidence{}, err
|
||||
}
|
||||
if generatedAt.IsZero() {
|
||||
generatedAt = time.Now().UTC()
|
||||
}
|
||||
violations := ValidateRolloutReport(report)
|
||||
return RolloutEvidence{
|
||||
SchemaVersion: 1,
|
||||
Kind: RolloutEvidenceKind,
|
||||
Name: strings.TrimSpace(name),
|
||||
GeneratedAt: generatedAt.UTC(),
|
||||
Spec: normalized,
|
||||
Report: report,
|
||||
Result: rolloutEvResult(report),
|
||||
Valid: len(violations) == 0,
|
||||
Violations: violations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ValidateRolloutEvidence(evidence RolloutEvidence) error {
|
||||
var violations []string
|
||||
if evidence.SchemaVersion != 1 {
|
||||
violations = append(violations, fmt.Sprintf("schema_version must be 1, got %d", evidence.SchemaVersion))
|
||||
}
|
||||
if evidence.Kind != RolloutEvidenceKind {
|
||||
violations = append(violations, fmt.Sprintf("kind must be %q, got %q", RolloutEvidenceKind, evidence.Kind))
|
||||
}
|
||||
if strings.TrimSpace(evidence.Name) == "" {
|
||||
violations = append(violations, "name is required")
|
||||
}
|
||||
if evidence.GeneratedAt.IsZero() {
|
||||
violations = append(violations, "generated_at is required")
|
||||
}
|
||||
violations = append(violations, ValidateRolloutReport(evidence.Report)...)
|
||||
if len(evidence.Violations) > 0 {
|
||||
violations = append(violations, evidence.Violations...)
|
||||
}
|
||||
if len(violations) > 0 {
|
||||
return fmt.Errorf("hotupdate rollout evidence invalid: %s", strings.Join(violations, "; "))
|
||||
}
|
||||
if !evidence.Valid {
|
||||
return fmt.Errorf("hotupdate rollout evidence invalid: valid=false")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateRolloutReport(report RolloutReport) []string {
|
||||
var violations []string
|
||||
if strings.TrimSpace(report.Target) == "" {
|
||||
violations = append(violations, "target is required")
|
||||
}
|
||||
if strings.TrimSpace(report.Version) == "" {
|
||||
violations = append(violations, "version is required")
|
||||
}
|
||||
if report.BatchSize <= 0 {
|
||||
violations = append(violations, "batch_size must be positive")
|
||||
}
|
||||
if report.Total < 0 {
|
||||
violations = append(violations, "total must not be negative")
|
||||
}
|
||||
if report.Published > report.Total {
|
||||
violations = append(violations, "published must not exceed total")
|
||||
}
|
||||
if report.ApplyPublished > report.Published {
|
||||
violations = append(violations, "apply_published must not exceed published")
|
||||
}
|
||||
if report.RollbackPublished > report.Published {
|
||||
violations = append(violations, "rollback_published must not exceed published")
|
||||
}
|
||||
if report.Unpublished+report.Published != report.Total {
|
||||
violations = append(violations, "unpublished + published must equal total")
|
||||
}
|
||||
if report.Pending+report.InProgress+report.Applied+report.Failed+report.Restored > report.Total {
|
||||
violations = append(violations, "node terminal/in-flight counts exceed total")
|
||||
}
|
||||
if report.Complete && (report.Applied != report.Total || report.Failed != 0 || report.NeedsRollback) {
|
||||
violations = append(violations, "complete report must have every node applied and no rollback need")
|
||||
}
|
||||
if report.RollbackComplete && (report.RollbackPublished == 0 || report.Restored != report.RollbackPublished || report.NeedsRollback) {
|
||||
violations = append(violations, "rollback_complete report must have every rollback intent restored and no rollback need")
|
||||
}
|
||||
var sumTotal, sumPublished, sumApplyPublished, sumRollbackPublished, sumUnpublished int
|
||||
var sumPending, sumInProgress, sumApplied, sumFailed, sumRestored int
|
||||
for _, batch := range report.Batches {
|
||||
batchTotal := len(batch.NodeIDs)
|
||||
sumTotal += batchTotal
|
||||
sumPublished += batch.Published
|
||||
sumApplyPublished += batch.ApplyPublished
|
||||
sumRollbackPublished += batch.RollbackPublished
|
||||
sumUnpublished += batch.Unpublished
|
||||
sumPending += batch.Pending
|
||||
sumInProgress += batch.InProgress
|
||||
sumApplied += batch.Applied
|
||||
sumFailed += batch.Failed
|
||||
sumRestored += batch.Restored
|
||||
if len(batch.Nodes) != batchTotal {
|
||||
violations = append(violations, fmt.Sprintf("batch %d nodes length does not match node_ids", batch.Index))
|
||||
}
|
||||
if batch.Published > batchTotal {
|
||||
violations = append(violations, fmt.Sprintf("batch %d published exceeds node count", batch.Index))
|
||||
}
|
||||
if batch.Unpublished+batch.Published != batchTotal {
|
||||
violations = append(violations, fmt.Sprintf("batch %d unpublished + published must equal node count", batch.Index))
|
||||
}
|
||||
if batch.Complete && batch.Applied != batchTotal {
|
||||
violations = append(violations, fmt.Sprintf("batch %d complete requires every node applied", batch.Index))
|
||||
}
|
||||
if batch.RollbackComplete && (batch.RollbackPublished == 0 || batch.Restored != batch.RollbackPublished) {
|
||||
violations = append(violations, fmt.Sprintf("batch %d rollback_complete requires every rollback intent restored", batch.Index))
|
||||
}
|
||||
}
|
||||
if sumTotal != report.Total {
|
||||
violations = append(violations, "batch node count sum must equal total")
|
||||
}
|
||||
if sumPublished != report.Published || sumApplyPublished != report.ApplyPublished || sumRollbackPublished != report.RollbackPublished {
|
||||
violations = append(violations, "batch published counts must match report totals")
|
||||
}
|
||||
if sumUnpublished != report.Unpublished || sumPending != report.Pending || sumInProgress != report.InProgress {
|
||||
violations = append(violations, "batch in-flight counts must match report totals")
|
||||
}
|
||||
if sumApplied != report.Applied || sumFailed != report.Failed || sumRestored != report.Restored {
|
||||
violations = append(violations, "batch terminal counts must match report totals")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func rolloutEvResult(report RolloutReport) string {
|
||||
switch {
|
||||
case report.Complete:
|
||||
return "complete"
|
||||
case report.RollbackComplete:
|
||||
return "rollback_complete"
|
||||
case report.NeedsRollback:
|
||||
return "rollback_required"
|
||||
case report.InProgress > 0:
|
||||
return "in_progress"
|
||||
case report.Pending > 0 || report.Unpublished > 0:
|
||||
return "pending"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LocalSource struct {
|
||||
VerifyOptions BundleVerifyOptions
|
||||
}
|
||||
|
||||
func (s LocalSource) Fetch(ctx context.Context, intent Intent, workspace string) (Package, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
intent, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
// LocalSource 把版本目录复制到独立 workspace,避免加载过程直接读写原始发布目录。
|
||||
workspace = strings.TrimSpace(workspace)
|
||||
if workspace == "" {
|
||||
workspace = filepath.Join(os.TempDir(), "longheng-hotupdate")
|
||||
}
|
||||
sourcePath, err := localPath(intent.SourceURI)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
info, err := os.Lstat(sourcePath)
|
||||
if err != nil {
|
||||
return Package{}, fmt.Errorf("stat hot update source %q: %w", sourcePath, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return Package{}, fmt.Errorf("hot update source %q must not be a symlink", sourcePath)
|
||||
}
|
||||
destination, err := hotUpdateDestination(workspace, intent.Version)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if !info.IsDir() && strings.EqualFold(filepath.Ext(sourcePath), ".zip") {
|
||||
verifyOptions := s.VerifyOptions
|
||||
if intent.Checksum != "" {
|
||||
verifyOptions.Checksum = intent.Checksum
|
||||
}
|
||||
pkg, err := ExtractBundleWithOptions(ctx, sourcePath, destination, verifyOptions)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
pkg.SourceURI = intent.SourceURI
|
||||
return pkg, nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return Package{}, fmt.Errorf("hot update source %q is not a directory", sourcePath)
|
||||
}
|
||||
if err := preflightSourceDir(ctx, sourcePath); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if err := os.RemoveAll(destination); err != nil {
|
||||
return Package{}, fmt.Errorf("clear hot update workspace %q: %w", destination, err)
|
||||
}
|
||||
if err := copyDir(ctx, sourcePath, destination); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
checksum, bytes, err := checksumDir(ctx, destination)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if intent.Checksum != "" && !strings.EqualFold(intent.Checksum, checksum) {
|
||||
return Package{}, fmt.Errorf("hot update checksum mismatch: got %s want %s", checksum, intent.Checksum)
|
||||
}
|
||||
return Package{
|
||||
Version: intent.Version,
|
||||
SourceURI: intent.SourceURI,
|
||||
Directory: destination,
|
||||
Checksum: checksum,
|
||||
Bytes: bytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func localPath(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", ErrSourceRequired
|
||||
}
|
||||
if filepath.VolumeName(raw) != "" || filepath.IsAbs(raw) {
|
||||
return raw, nil
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err == nil && parsed.Scheme == "file" {
|
||||
if parsed.Path == "" {
|
||||
return "", ErrSourceRequired
|
||||
}
|
||||
return filepath.FromSlash(parsed.Path), nil
|
||||
}
|
||||
if err == nil && parsed.Scheme != "" && parsed.Scheme != "file" {
|
||||
return "", fmt.Errorf("unsupported hot update source scheme %q", parsed.Scheme)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func preflightSourceDir(ctx context.Context, source string) error {
|
||||
// 源目录预检拒绝软链,避免热更包借软链读取或复制工作区外文件。
|
||||
return filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctxErr := contextErr(ctx); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("hot update package rejects symlink %q", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func copyDir(ctx context.Context, source, destination string) error {
|
||||
// copyDir 在预检后执行真实复制,仍重复拒绝软链,防止预检和复制之间的 TOCTOU 风险扩大。
|
||||
return filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctxErr := contextErr(ctx); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("hot update package rejects symlink %q", path)
|
||||
}
|
||||
rel, err := filepath.Rel(source, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(destination, rel)
|
||||
if entry.IsDir() {
|
||||
return os.MkdirAll(target, 0o750)
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return copyFile(path, target, info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(source, destination string, mode fs.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(source) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
out, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func checksumDir(ctx context.Context, root string) (string, int64, error) {
|
||||
var files []string
|
||||
if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctxErr := contextErr(ctx); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("hot update checksum rejects symlink %q", path)
|
||||
}
|
||||
if !entry.IsDir() {
|
||||
files = append(files, path)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
hash := sha256.New()
|
||||
var total int64
|
||||
for _, path := range files {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
total += int64(len(data))
|
||||
_, _ = hash.Write([]byte(filepath.ToSlash(rel)))
|
||||
_, _ = hash.Write([]byte{0})
|
||||
_, _ = hash.Write(data)
|
||||
_, _ = hash.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), total, nil
|
||||
}
|
||||
|
||||
func hotUpdateDestination(workspace, version string) (string, error) {
|
||||
// version 只能生成 workspace 下的目标目录,防止恶意版本号写出热更工作区。
|
||||
root, err := filepath.Abs(strings.TrimSpace(workspace))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info, err := os.Lstat(root); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("hot update workspace %q must not be a symlink", root)
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("stat hot update workspace %q: %w", root, err)
|
||||
}
|
||||
base := safePathName(version)
|
||||
destination, err := filepath.Abs(filepath.Join(root, base))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel, err := filepath.Rel(root, destination)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", fmt.Errorf("hot update destination %q escapes workspace %q", destination, root)
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
|
||||
func safePathName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
}
|
||||
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "..", "_")
|
||||
value = strings.Trim(replacer.Replace(value), " .")
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package hotupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrVersionRequired = errors.New("hot update version is required")
|
||||
ErrSourceRequired = errors.New("hot update source is required")
|
||||
ErrTargetRequired = errors.New("hot update target is required")
|
||||
ErrApplyRequired = errors.New("hot update applier is required")
|
||||
ErrActionRequired = errors.New("hot update action is required")
|
||||
ErrRestoreRequired = errors.New("hot update restorer is required")
|
||||
ErrProgressInvalid = errors.New("hot update progress is invalid")
|
||||
ErrControlClosed = errors.New("hot update control is closed")
|
||||
)
|
||||
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionApply Action = "apply"
|
||||
ActionRestore Action = "restore"
|
||||
)
|
||||
|
||||
type Stage string
|
||||
|
||||
const (
|
||||
StageAccepted Stage = "accepted"
|
||||
StageDownloading Stage = "downloading"
|
||||
StageScheduled Stage = "scheduled"
|
||||
StageApplying Stage = "applying"
|
||||
StageApplied Stage = "applied"
|
||||
StageRestoring Stage = "restoring"
|
||||
StageRestored Stage = "restored"
|
||||
StageFailed Stage = "failed"
|
||||
StageSkipped Stage = "skipped"
|
||||
)
|
||||
|
||||
type Intent struct {
|
||||
Action Action `json:"action,omitempty"`
|
||||
Version string `json:"version"`
|
||||
SourceURI string `json:"source_uri"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
AvailableAt time.Time `json:"available_at,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
RequestedBy string `json:"requested_by,omitempty"`
|
||||
Sequence int64 `json:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
type Package struct {
|
||||
Version string `json:"version"`
|
||||
SourceURI string `json:"source_uri"`
|
||||
Directory string `json:"directory"`
|
||||
Checksum string `json:"checksum"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
SignatureAlg string `json:"signature_alg,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
}
|
||||
|
||||
type ApplyResult struct {
|
||||
Version string `json:"version"`
|
||||
Applied []string `json:"applied,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type Progress struct {
|
||||
Target string `json:"target"`
|
||||
NodeID string `json:"node_id"`
|
||||
Version string `json:"version"`
|
||||
Stage Stage `json:"stage"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Target string `json:"target"`
|
||||
NodeID string `json:"node_id"`
|
||||
Started bool `json:"started"`
|
||||
LastIntent Intent `json:"last_intent,omitempty"`
|
||||
LastProgress Progress `json:"last_progress,omitempty"`
|
||||
LastPackage Package `json:"last_package,omitempty"`
|
||||
LastApplied time.Time `json:"last_applied,omitempty"`
|
||||
AutoRestoreAt time.Time `json:"auto_restore_at,omitempty"`
|
||||
Failures int64 `json:"failures"`
|
||||
}
|
||||
|
||||
type Control interface {
|
||||
Publish(context.Context, string, Intent) error
|
||||
Watch(context.Context, string) (<-chan Intent, error)
|
||||
Report(context.Context, Progress) error
|
||||
Snapshot() ControlSnapshot
|
||||
Close() error
|
||||
}
|
||||
|
||||
type ControlSnapshot struct {
|
||||
Intents map[string]Intent `json:"intents,omitempty"`
|
||||
Progress map[string][]Progress `json:"progress,omitempty"`
|
||||
Watchers map[string]int `json:"watchers,omitempty"`
|
||||
Closed bool `json:"closed"`
|
||||
}
|
||||
|
||||
type Source interface {
|
||||
Fetch(context.Context, Intent, string) (Package, error)
|
||||
}
|
||||
|
||||
type Applier interface {
|
||||
Apply(context.Context, Package) (ApplyResult, error)
|
||||
}
|
||||
|
||||
type Restorer interface {
|
||||
Restore(context.Context, Intent) (ApplyResult, error)
|
||||
}
|
||||
|
||||
func normalizeTarget(target string) (string, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return "", ErrTargetRequired
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func normalizeIntent(intent Intent) (Intent, error) {
|
||||
intent.Action = normalizeAction(intent.Action)
|
||||
intent.Version = strings.TrimSpace(intent.Version)
|
||||
intent.SourceURI = strings.TrimSpace(intent.SourceURI)
|
||||
intent.Checksum = strings.TrimSpace(intent.Checksum)
|
||||
intent.Reason = strings.TrimSpace(intent.Reason)
|
||||
intent.RequestedBy = strings.TrimSpace(intent.RequestedBy)
|
||||
if intent.Action == "" {
|
||||
return Intent{}, ErrActionRequired
|
||||
}
|
||||
if intent.Action != ActionApply && intent.Action != ActionRestore {
|
||||
return Intent{}, fmt.Errorf("%w: %s", ErrActionRequired, intent.Action)
|
||||
}
|
||||
if intent.Action == ActionRestore && intent.Version == "" {
|
||||
intent.Version = "restore"
|
||||
}
|
||||
if intent.Version == "" {
|
||||
return Intent{}, ErrVersionRequired
|
||||
}
|
||||
if intent.Action == ActionApply && intent.SourceURI == "" {
|
||||
return Intent{}, ErrSourceRequired
|
||||
}
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
func normalizeAction(action Action) Action {
|
||||
action = Action(strings.TrimSpace(string(action)))
|
||||
if action == "" {
|
||||
return ActionApply
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
func normalizeProgress(progress Progress, now func() time.Time) (Progress, error) {
|
||||
target, err := normalizeTarget(progress.Target)
|
||||
if err != nil {
|
||||
return Progress{}, err
|
||||
}
|
||||
progress.Target = target
|
||||
progress.NodeID = strings.TrimSpace(progress.NodeID)
|
||||
progress.Version = strings.TrimSpace(progress.Version)
|
||||
progress.Stage = Stage(strings.TrimSpace(string(progress.Stage)))
|
||||
progress.Status = strings.TrimSpace(progress.Status)
|
||||
progress.Detail = strings.TrimSpace(progress.Detail)
|
||||
if progress.NodeID == "" {
|
||||
return Progress{}, fmt.Errorf("%w: node_id is required", ErrProgressInvalid)
|
||||
}
|
||||
if progress.Version == "" {
|
||||
return Progress{}, ErrVersionRequired
|
||||
}
|
||||
if !knownStage(progress.Stage) {
|
||||
return Progress{}, fmt.Errorf("%w: unsupported stage %q", ErrProgressInvalid, progress.Stage)
|
||||
}
|
||||
if progress.Status == "" {
|
||||
progress.Status = "ok"
|
||||
}
|
||||
if now == nil {
|
||||
now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
if progress.UpdatedAt.IsZero() {
|
||||
progress.UpdatedAt = now().UTC()
|
||||
} else {
|
||||
progress.UpdatedAt = progress.UpdatedAt.UTC()
|
||||
}
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
func knownStage(stage Stage) bool {
|
||||
switch stage {
|
||||
case StageAccepted, StageDownloading, StageScheduled, StageApplying, StageApplied, StageRestoring, StageRestored, StageFailed, StageSkipped:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func contextErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
Reference in new issue
Block a user