DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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
}
+208
View File
@@ -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()
}