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 }