f536987a7e
- doc: add comment
365 lines
13 KiB
Go
365 lines
13 KiB
Go
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
|
||
}
|