Files
2026-08-17 10:10:14 +08:00

365 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package backendupdate
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"yms-daemon/internal/deploymentconfig"
"yms-daemon/internal/hostnginx"
"yms-daemon/internal/nativebackendexecutor"
"yms-daemon/internal/systemd"
"yms-daemon/internal/transaction"
"yms-daemon/internal/updatepackage"
)
// TestUpdaterMigratesLegacyBackendAndCommits 验证通过 repack 包更新时,能把仍在运行的旧版后端单元
// ymsback.service)迁移到模板单元并完成提交。
func TestUpdaterMigratesLegacyBackendAndCommits(t *testing.T) {
testUpdaterMigratesLegacyBackendAndCommits(t, false)
}
// TestUpdaterCommitsDirectNativeJAR 验证直接安装 native JAR 时能完成提交,
// 并进一步验证重启事务的创建、恢复与槽位旋转。
func TestUpdaterCommitsDirectNativeJAR(t *testing.T) {
testUpdaterMigratesLegacyBackendAndCommits(t, true)
}
// testUpdaterMigratesLegacyBackendAndCommits 上述两个测试共用的驱动函数。
// 当 direct 为 false 时走 repack 更新路径,为 true 时走直接 JAR 安装路径,
// 并额外验证重启事务的创建、恢复以及槽位旋转行为。
func testUpdaterMigratesLegacyBackendAndCommits(t *testing.T, direct bool) {
t.Helper()
ctx := context.Background()
root := t.TempDir()
store, err := transaction.OpenStore(ctx, filepath.Join(root, "transactions.db"))
if err != nil {
t.Fatalf("open transaction store: %v", err)
}
defer store.Close()
coordinator, err := transaction.NewCoordinator(store, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create transaction coordinator: %v", err)
}
releaseDir := filepath.Join(root, "releases")
activeJAR := filepath.Join(root, "lib", "glory-soft-yms.jar")
if err := os.MkdirAll(filepath.Dir(activeJAR), 0o750); err != nil {
t.Fatalf("create backend lib directory: %v", err)
}
if err := os.WriteFile(activeJAR, []byte("legacy backend JAR"), 0o644); err != nil {
t.Fatalf("write legacy backend JAR: %v", err)
}
config := deploymentconfig.Config{Backend: deploymentconfig.Backend{
Type: deploymentconfig.BackendTypeNative,
ReleaseDir: releaseDir,
ActiveJAR: activeJAR,
Slot: deploymentconfig.BackendSlots{
Port8080: deploymentconfig.BackendSlot{
Unit: "yms-backend@8080.service",
JAR: filepath.Join(root, "lib", "glory-soft-yms-8080.jar"),
HealthEndpoint: "http://127.0.0.1:8080/yms/actuator/health",
},
Port8081: deploymentconfig.BackendSlot{
Unit: "yms-backend@8081.service",
JAR: filepath.Join(root, "lib", "glory-soft-yms-8081.jar"),
HealthEndpoint: "http://127.0.0.1:8081/yms/actuator/health",
},
},
}}
units := &updateUnitManager{units: map[string]systemd.Unit{
"yms-backend@8080.service": {Name: "yms-backend@8080.service", LoadState: "loaded", ActiveState: "inactive"},
"yms-backend@8081.service": {Name: "yms-backend@8081.service", LoadState: "loaded", ActiveState: "inactive"},
legacyUnit8080: {Name: legacyUnit8080, LoadState: "loaded", ActiveState: "failed"},
legacyUnit8081: {Name: legacyUnit8081, LoadState: "loaded", ActiveState: "activating"},
}}
gateway := &memoryGateway{snapshot: hostnginx.Snapshot{Content: []byte(serverConfiguration8081), ActivePort: 8081}}
executor := &preparingExecutor{store: store, releaseDir: releaseDir, units: units}
updater := &Updater{
config: config,
workRoot: filepath.Join(root, "work"),
store: store,
coordinator: coordinator,
units: units,
gateway: gateway,
executor: executor,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
drain: 0,
}
var record transaction.Transaction
var installedPath string
var progress []Progress
report := func(event Progress) {
progress = append(progress, event)
}
if direct {
jarPath := writeDirectBackendJAR(t, []byte("new backend JAR"))
jar, err := updatepackage.OpenDirectNativeJAR(jarPath)
if err != nil {
t.Fatalf("inspect direct native backend JAR: %v", err)
}
installedPath = filepath.Join(releaseDir, "direct", jar.SHA256[:12], jar.FileName)
record, err = updater.UpdateNativeJAR(ctx, jarPath, report)
} else {
packagePath := writeNativeBackendPackage(t, []byte("new backend JAR"))
installedPath = filepath.Join(releaseDir, "glory-soft-yms-test.jar")
record, err = updater.UpdateRepack(ctx, packagePath, report)
}
if err != nil {
t.Fatalf("update native backend: %v", err)
}
if record.State != transaction.StateCommitted || gateway.snapshot.ActivePort != 8080 {
t.Fatalf("unexpected committed update: record=%+v gateway=%+v", record, gateway.snapshot)
}
if target, err := os.Readlink(activeJAR); err != nil || target != installedPath {
t.Fatalf("unexpected compatibility link: target=%q installed=%q err=%v", target, installedPath, err)
}
if units.units[legacyUnit8081].ActiveState != "failed" || units.stopped != legacyUnit8081 {
t.Fatalf("legacy backend unit was not stopped: unit=%+v stopped=%s", units.units[legacyUnit8081], units.stopped)
}
if len(progress) == 0 || progress[len(progress)-1].State != transaction.StateCommitted {
t.Fatalf("missing committed update progress: %+v", progress)
}
if direct {
previousTransactionID := record.ID
restartSource, sourceErr := currentReleaseSource(activeJAR)
if sourceErr != nil {
t.Fatalf("resolve restart source: %v", sourceErr)
}
restartJAR, sourceErr := updatepackage.OpenDirectNativeJAR(restartSource)
if sourceErr != nil {
t.Fatalf("open restart source: %v", sourceErr)
}
restartReleasePath, sourceErr := updater.restartReleasePath(restartJAR)
if sourceErr != nil {
t.Fatalf("resolve restart release path: %v", sourceErr)
}
pendingRestart, _, created, sourceErr := updater.createOrResume(ctx, updateInput{
IdempotencyKey: "backend:restart:test-resume",
InputType: inputTypeCurrentRelease,
SourcePath: restartJAR.Path,
SourceSHA256: restartJAR.SHA256,
ArtifactFileName: restartJAR.FileName,
ArtifactIdentity: restartJAR.Identity,
ReleasePath: restartReleasePath,
Materialize: restartJAR.CopyArtifact,
})
if sourceErr != nil || !created {
t.Fatalf("create interrupted restart transaction: record=%+v created=%t err=%v", pendingRestart, created, sourceErr)
}
progress = nil
record, err = updater.Restart(ctx, report)
if err != nil {
t.Fatalf("restart native backend: %v", err)
}
if record.ID == previousTransactionID || record.ID != pendingRestart.ID || record.State != transaction.StateCommitted {
t.Fatalf("unexpected resumed restart transaction: previous=%s pending=%s record=%+v", previousTransactionID, pendingRestart.ID, record)
}
if gateway.snapshot.ActivePort != 8081 || units.units["yms-backend@8081.service"].ActiveState != "active" || units.units["yms-backend@8080.service"].ActiveState != "failed" {
t.Fatalf("restart did not rotate native backend slots: gateway=%+v units=%+v", gateway.snapshot, units.units)
}
if len(progress) == 0 || progress[len(progress)-1].Message != "Native backend restart committed" {
t.Fatalf("missing committed restart progress: %+v", progress)
}
}
}
// serverConfiguration8081 测试用的 host Nginx 配置片段,其活跃后端端口为 8081。
const serverConfiguration8081 = `http {
upstream yms-server {
# yms-update managed upstream begin
# server 10.11.1.117:8080 max_fails=1 fail_timeout=2s;
server 10.11.1.117:8081 max_fails=1 fail_timeout=2s;
# yms-update managed upstream end
}
}
`
// memoryGateway 网关控制器的测试替身,在内存中保存 host Nginx 快照,
// 记录 Apply 调用次数,并支持在应用前注入失败以模拟切换故障。
type memoryGateway struct {
snapshot hostnginx.Snapshot
applyCount int
beforeApply func(hostnginx.Snapshot) error
}
// Read 返回当前保存的网关快照副本。
func (g *memoryGateway) Read() (hostnginx.Snapshot, error) {
return hostnginx.Snapshot{Content: append([]byte(nil), g.snapshot.Content...), ActivePort: g.snapshot.ActivePort}, nil
}
// Apply 在 beforeApply 钩子通过后,将给定快照保存为当前状态并累计应用次数。
func (g *memoryGateway) Apply(_ context.Context, snapshot hostnginx.Snapshot) error {
if g.beforeApply != nil {
if err := g.beforeApply(snapshot); err != nil {
return err
}
}
g.snapshot = hostnginx.Snapshot{Content: append([]byte(nil), snapshot.Content...), ActivePort: snapshot.ActivePort}
g.applyCount++
return nil
}
// updateUnitManager systemd 单元管理器的测试替身,在内存中维护单元状态,
// 并记录最近一次被停止的单元名称。
type updateUnitManager struct {
units map[string]systemd.Unit
stopped string
}
// Inspect 返回指定名称的单元状态。
func (m *updateUnitManager) Inspect(_ context.Context, name string) (systemd.Unit, error) {
return m.units[name], nil
}
// Start 将指定单元置为 active。
func (m *updateUnitManager) Start(_ context.Context, name string) error {
unit := m.units[name]
unit.ActiveState = "active"
m.units[name] = unit
return nil
}
// Stop 将指定单元置为 failed 并记录其名称。
func (m *updateUnitManager) Stop(_ context.Context, name string) error {
unit := m.units[name]
unit.ActiveState = "failed"
m.units[name] = unit
m.stopped = name
return nil
}
// preparingExecutor 原生后端执行器的测试替身:它把制品写入发布目录、
// 建立槽位软链接、启动目标单元并推进事务状态,模拟执行器在真实环境中的工作。
type preparingExecutor struct {
store *transaction.Store
releaseDir string
units *updateUnitManager
}
// Run 将请求中的制品落盘到发布目录,创建槽位软链接,启动目标单元,
// 并把事务依次推进到 Starting 状态,用于驱动后续切换与提交逻辑。
func (e *preparingExecutor) Run(ctx context.Context, transactionID string, request nativebackendexecutor.Request) error {
if err := os.MkdirAll(e.releaseDir, 0o750); err != nil {
return err
}
content, err := os.ReadFile(request.ArtifactPath)
if err != nil {
return err
}
installedPath := filepath.Join(e.releaseDir, request.ReleasePath)
if err := os.MkdirAll(filepath.Dir(installedPath), 0o750); err != nil {
return err
}
if err := os.WriteFile(installedPath, content, 0o640); err != nil {
return err
}
if err := os.Symlink(installedPath, request.SlotJarPath); err != nil {
return err
}
if err := e.units.Start(ctx, request.UnitName); err != nil {
return err
}
for _, state := range []transaction.State{
transaction.StateValidating,
transaction.StatePrepared,
transaction.StateStarting,
transaction.StateSwitching,
} {
if _, err := e.store.Transition(ctx, transactionID, state, "test transition"); err != nil {
return err
}
}
return nil
}
// writeNativeBackendPackage 生成一个包含 artifact-selection.json 清单和
// glory-soft-yms-test.jar 制品的 repack ZIP 包,返回其路径。
func writeNativeBackendPackage(t *testing.T, jar []byte) string {
t.Helper()
digest := sha256.Sum256(jar)
manifest := map[string]any{
"customerCode": "customer-01",
"customerDisplayName": "Customer 01",
"versionId": "V1.1.8",
"items": []string{"deploy-sync.sh"},
"backendArtifacts": []any{map[string]any{
"id": int64(42),
"versionCode": "V1.1.8",
"artifactKind": "BACKEND",
"type": "native",
"selectedType": "native",
"platform": nil,
"fileName": "glory-soft-yms-test.jar",
"filePath": "/archive/glory-soft-yms-test.jar",
"sha256": hex.EncodeToString(digest[:]),
"imageRef": nil,
}},
"frontendArtifacts": []any{},
"nodeSsrArtifacts": []any{},
"remark": nil,
}
manifestContent, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("encode artifact selection: %v", err)
}
packagePath := filepath.Join(t.TempDir(), "package.zip")
file, err := os.Create(packagePath)
if err != nil {
t.Fatalf("create update package: %v", err)
}
writer := zip.NewWriter(file)
for name, content := range map[string][]byte{
"artifact-selection.json": manifestContent,
"glory-soft-yms-test.jar": jar,
} {
entry, err := writer.Create(name)
if err != nil {
t.Fatalf("create update package entry: %v", err)
}
if _, err := entry.Write(content); err != nil {
t.Fatalf("write update package entry: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close update package writer: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close update package: %v", err)
}
return packagePath
}
// writeDirectBackendJAR 生成一个包含 BOOT-INF/classes/application.properties 的直接 native JAR
// 返回其路径,供直接安装更新路径使用。
func writeDirectBackendJAR(t *testing.T, content []byte) string {
t.Helper()
jarPath := filepath.Join(t.TempDir(), "glory-soft-yms.jar")
file, err := os.Create(jarPath)
if err != nil {
t.Fatalf("create direct backend JAR: %v", err)
}
writer := zip.NewWriter(file)
entry, err := writer.Create("BOOT-INF/classes/application.properties")
if err != nil {
t.Fatalf("create direct backend JAR entry: %v", err)
}
if _, err := entry.Write(content); err != nil {
t.Fatalf("write direct backend JAR entry: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close direct backend JAR writer: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close direct backend JAR: %v", err)
}
return jarPath
}