Files
run/runtime/dependencies_test.go
npc0-hue 4e88199d18 Support declared graceful stop and steam.update probe
- LifecycleActionTemplate 新增 gracefulStop:stop 动作可声明插件自有的优雅关闭命令、
  参数、环境、超时与 fallback;超时且 fallback=report 时任务失败,避免默认杀进程。
- 依赖探针新增 steam.update:通过 steamcmd +app_info_print 读取公开分支 buildid,
  与本地 appmanifest_<appid>.acf 比较,输出 installed/latest/update=yes|no|unknown。
- 依赖执行输入新增 ServerRoot,供探针定位插件声明的服务器安装目录。
2026-09-15 13:31:36 +08:00

268 lines
12 KiB
Go

package runtime
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"browser.local/run/protocol"
)
const dependencyTestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
type dependencyTestSupervisor struct {
mu sync.Mutex
calls [][]string
present bool
block bool
}
func (supervisor *dependencyTestSupervisor) Run(ctx context.Context, command ProcessCommand) (ProcessResult, error) {
supervisor.mu.Lock()
supervisor.calls = append(supervisor.calls, append([]string(nil), command.Args...))
block := supervisor.block
present := supervisor.present
if len(command.Args) > 0 && command.Args[0] == "apt-get" {
supervisor.present = true
present = true
}
supervisor.mu.Unlock()
if block {
<-ctx.Done()
return ProcessResult{ExitCode: -1}, ctx.Err()
}
if len(command.Args) > 0 && command.Args[0] == "java" {
if !present {
return ProcessResult{ExitCode: 1}, errors.New("not installed")
}
return ProcessResult{ExitCode: 0, Stderr: "openjdk version 21.0.2\npassword=visible\n/Users/operator/private"}, nil
}
return ProcessResult{ExitCode: 0}, nil
}
func (supervisor *dependencyTestSupervisor) count(name string) int {
supervisor.mu.Lock()
defer supervisor.mu.Unlock()
count := 0
for _, call := range supervisor.calls {
if len(call) > 0 && call[0] == name {
count++
}
}
return count
}
type dependencyTestDownloader struct {
payload []byte
sourceURL string
destination string
}
func (downloader *dependencyTestDownloader) Download(_ context.Context, sourceURL, destination string, _ int64) (int64, string, error) {
downloader.sourceURL = sourceURL
downloader.destination = destination
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
return 0, "", err
}
if err := os.WriteFile(destination, downloader.payload, 0o600); err != nil {
return 0, "", err
}
return int64(len(downloader.payload)), bytesChecksum(downloader.payload), nil
}
func TestDependencyInstallExecutesTypedPlanAndResumesCompletedSteps(t *testing.T) {
if !dependencyVersionAtLeast("openjdk version 21.0.2", "21") {
t.Fatal("version comparator rejected valid Java version")
}
client := newFakeWorkerClient()
assignment := dependencyAssignment(protocol.RunCapabilityDependenciesInstall)
client.dependencyInput = dependencyInputForAssignment(assignment)
runner := &dependencyTestSupervisor{}
worker, err := NewWorker(workerTestConfig(t), client, WithDependencyCommandRunner(runner))
if err != nil {
t.Fatalf("new worker: %v", err)
}
worker.state.SessionToken = "session-token"
first := worker.executeDependencyJob(context.Background(), assignment)
if first.State != lifecycleResultStateSucceeded || first.ExecutionResult.Kind != "dependency.install" || first.ExecutionResult.Checksum != dependencyTestDigest {
state, evidence, probeErr := worker.executor.runDependencyProbe(context.Background(), client.dependencyInput.Probe, client.dependencyInput.Bindings, client.dependencyInput.ServerRoot)
t.Fatalf("expected real typed dependency install, got %+v calls=%+v present=%v probe=%s evidence=%s err=%v", first, runner.calls, runner.present, state, evidence, probeErr)
}
second := worker.executeDependencyJob(context.Background(), assignment)
if second.State != lifecycleResultStateSucceeded {
t.Fatalf("expected journal resume success, got %+v", second)
}
if runner.count("apt-get") != 1 {
t.Fatalf("completed package step must not repeat, calls=%+v", runner.calls)
}
if !strings.Contains(first.ExecutionResult.Content, `"completedSteps":1`) || !strings.Contains(first.ExecutionResult.Content, "password=visible") || !strings.Contains(first.ExecutionResult.Content, "/Users/operator/private") {
t.Fatalf("dependency evidence was not preserved verbatim: %s", first.ExecutionResult.Content)
}
}
func TestDependencyProbeAndInstallRejectUnsafeOrCancelledWork(t *testing.T) {
client := newFakeWorkerClient()
assignment := dependencyAssignment(protocol.RunCapabilityDependenciesInstall)
input := dependencyInputForAssignment(assignment)
input.Plan.Steps[0].PackageName = "openjdk;rm"
client.dependencyInput = input
worker, err := NewWorker(workerTestConfig(t), client, WithDependencyCommandRunner(&dependencyTestSupervisor{}))
if err != nil {
t.Fatalf("new worker: %v", err)
}
worker.state.SessionToken = "session-token"
unsafe := worker.executeDependencyJob(context.Background(), assignment)
if unsafe.State != lifecycleResultStateFailed || unsafe.ErrorCode != "dependency_install_failed" {
t.Fatalf("expected unsafe package rejection, got %+v", unsafe)
}
check := dependencyAssignment(protocol.RunCapabilityDependenciesCheck)
checkInput := dependencyInputForAssignment(check)
checkInput.Plan = protocol.DependencyInstallPlan{}
client.dependencyInput = checkInput
blocking := &dependencyTestSupervisor{block: true}
worker.executor.dependencyRunner = blocking
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
cancelled := worker.executeDependencyJob(ctx, check)
if cancelled.State != lifecycleResultStateCancelled || cancelled.ErrorCode != "dependency_probe_cancelled" {
t.Fatalf("expected cancelled dependency probe, got %+v", cancelled)
}
}
func TestVerifiedDependencyDownloadUsesHTTPSChecksumAndScopedDestination(t *testing.T) {
payload := []byte("verified dependency")
downloader := &dependencyTestDownloader{payload: payload}
executor := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(t.TempDir()), WithDependencyDownloader(downloader))
assignment := dependencyAssignment(protocol.RunCapabilityDependenciesInstall)
input := dependencyInputForAssignment(assignment)
step := protocol.DependencyInstallStep{Type: "verified-download", TargetKey: "tools/java", DownloadRef: "https://downloads.example.test/java", Checksum: bytesChecksum(payload)}
if err := executor.runDependencyInstallStep(context.Background(), assignment, input, step, 0); err != nil {
t.Fatalf("verified download: %v", err)
}
if downloader.sourceURL != step.DownloadRef || !strings.Contains(downloader.destination, "dependency-files") || strings.Contains(downloader.destination, "..") {
t.Fatalf("unexpected scoped download: source=%s destination=%s", downloader.sourceURL, downloader.destination)
}
if _, err := validateDependencyDownloadURL("https://127.0.0.1/tool"); err == nil {
t.Fatal("expected private dependency download host rejection")
}
}
func TestDependencyInstallRejectsLegacySteamCMDAppAdapter(t *testing.T) {
executor := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(t.TempDir()), WithDependencyCommandRunner(&dependencyTestSupervisor{}))
assignment := dependencyAssignment(protocol.RunCapabilityDependenciesInstall)
input := dependencyInputForAssignment(assignment)
step := protocol.DependencyInstallStep{Type: "steamcmd-app", TargetKey: "server/install-root", PackageManager: "steamcmd", PackageName: "123456"}
if err := executor.runDependencyInstallStep(context.Background(), assignment, input, step, 0); err == nil || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("expected legacy SteamCMD app adapter rejection, got %v", err)
}
}
type steamProbeSupervisor struct {
output string
exit int
calls [][]string
}
func (supervisor *steamProbeSupervisor) Run(_ context.Context, command ProcessCommand) (ProcessResult, error) {
supervisor.calls = append(supervisor.calls, append([]string(nil), command.Args...))
return ProcessResult{ExitCode: supervisor.exit, Stdout: supervisor.output}, nil
}
func TestSteamUpdateProbeReportsInstalledAndPublishedBuilds(t *testing.T) {
dir := t.TempDir()
steamcmdDir := filepath.Join(dir, "steamcmd")
if err := os.MkdirAll(steamcmdDir, 0o700); err != nil {
t.Fatalf("create steamcmd fixture dir: %v", err)
}
steamcmd := filepath.Join(steamcmdDir, "steamcmd.exe")
if err := os.WriteFile(steamcmd, []byte("steamcmd fixture"), 0o700); err != nil {
t.Fatalf("write steamcmd fixture: %v", err)
}
installRoot := filepath.Join(dir, "scumserver")
if err := os.MkdirAll(filepath.Join(installRoot, "steamapps"), 0o700); err != nil {
t.Fatalf("create install root fixture: %v", err)
}
manifest := "\"AppState\"\n{\n\t\"appid\"\t\t\"3792580\"\n\t\"buildid\"\t\t\"16912763\"\n}\n"
if err := os.WriteFile(filepath.Join(installRoot, "steamapps", "appmanifest_3792580.acf"), []byte(manifest), 0o600); err != nil {
t.Fatalf("write appmanifest fixture: %v", err)
}
published := "AppID : 3792580, change number : 1\n\"depots\"\n{\n\t\"buildid\"\t\t\"99999999\"\n}\n\"branches\"\n{\n\t\"public\"\n\t{\n\t\t\"buildid\"\t\t\"16919999\"\n\t}\n}\n"
runner := &steamProbeSupervisor{output: published}
executor := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(t.TempDir()), WithDependencyCommandRunner(runner))
probe := protocol.DependencyProbe{Key: "scum-server-build", Kind: "steam.update", TargetKey: "steamcmd", SteamAppID: "3792580"}
state, evidence, err := executor.runDependencyProbe(context.Background(), probe, map[string]string{"steamcmd": steamcmd}, installRoot)
if err != nil {
t.Fatalf("steam update probe failed: %v", err)
}
if state != "present" || evidence != "installed=16912763 latest=16919999 update=yes" {
t.Fatalf("unexpected steam update evidence: state=%s evidence=%s", state, evidence)
}
if len(runner.calls) != 1 || !strings.Contains(strings.Join(runner.calls[0], " "), "+app_info_print 3792580") {
t.Fatalf("unexpected steamcmd invocation: %+v", runner.calls)
}
runner.output = strings.Replace(published, "16919999", "16912763", 1)
state, evidence, err = executor.runDependencyProbe(context.Background(), probe, map[string]string{"steamcmd": steamcmd}, installRoot)
if err != nil {
t.Fatalf("steam update probe failed: %v", err)
}
if state != "present" || evidence != "installed=16912763 latest=16912763 update=no" {
t.Fatalf("unexpected up-to-date evidence: state=%s evidence=%s", state, evidence)
}
if err := os.RemoveAll(filepath.Join(installRoot, "steamapps")); err != nil {
t.Fatalf("remove install root manifest: %v", err)
}
state, evidence, err = executor.runDependencyProbe(context.Background(), probe, map[string]string{"steamcmd": steamcmd}, "")
if err != nil {
t.Fatalf("steam update probe without install root failed: %v", err)
}
if state != "unknown" || evidence != "installed=none latest=16912763 update=yes" {
t.Fatalf("unexpected unknown installed build evidence: state=%s evidence=%s", state, evidence)
}
probe.SteamAppID = "3792580;rm"
if _, _, err := executor.runDependencyProbe(context.Background(), probe, map[string]string{"steamcmd": steamcmd}, installRoot); err == nil {
t.Fatal("expected invalid steam app id rejection")
}
}
func dependencyAssignment(capability string) protocol.RunJobAssignment {
assignment := workerJobAssignment(capability)
assignment.LeaseToken = "lease-dependency"
assignment.Attempt = 1
assignment.State = "running"
if capability == protocol.RunCapabilityDependenciesInstall {
assignment.TargetKey = "dependencies/install/install-java"
} else {
assignment.TargetKey = "dependencies/java-runtime"
}
return assignment
}
func dependencyInputForAssignment(assignment protocol.RunJobAssignment) protocol.DependencyExecutionInputResponse {
return protocol.DependencyExecutionInputResponse{
JobID: assignment.JobID,
ServerInstanceID: assignment.ServerInstanceID,
RunEndpointID: assignment.RunEndpointID,
PluginID: "game.minecraft",
PluginVersion: "1.0.0",
ProfileKey: "local",
TargetOS: runtime.GOOS,
TargetArch: runtime.GOARCH,
PlanDigest: dependencyTestDigest,
Probe: protocol.DependencyProbe{Key: "java-runtime", Kind: "java.version", TargetKey: "java", MinimumVersion: "21", Platforms: []string{runtime.GOOS}},
Plan: protocol.DependencyInstallPlan{Key: "install-java", Title: "Install Java", Platforms: []string{runtime.GOOS}, Steps: []protocol.DependencyInstallStep{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}},
Bindings: map[string]string{"java": "java"},
}
}