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,供探针定位插件声明的服务器安装目录。
This commit is contained in:
npc0-hue
2026-09-15 13:31:36 +08:00
parent a695aafd0f
commit 4e88199d18
7 changed files with 464 additions and 26 deletions
+126 -3
View File
@@ -30,6 +30,8 @@ const (
var (
dependencyTokenPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:+@/-]{0,119}$`)
steamAppIDPattern = regexp.MustCompile(`^[0-9]{1,10}$`)
steamBuildIDLinePattern = regexp.MustCompile(`^"buildid"\s+"([0-9]{1,20})"$`)
dependencyVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:+~-]{0,79}$`)
)
@@ -139,7 +141,7 @@ func (worker *Worker) executeDependencyJob(ctx context.Context, assignment proto
}
if assignment.Capability == protocol.RunCapabilityDependenciesCheck {
state, evidence, probeErr := worker.executor.runDependencyProbe(ctx, input.Probe, input.Bindings)
state, evidence, probeErr := worker.executor.runDependencyProbe(ctx, input.Probe, input.Bindings, input.ServerRoot)
if probeErr != nil {
if errors.Is(probeErr, context.Canceled) || errors.Is(probeErr, context.DeadlineExceeded) {
return LifecycleExecutionResult{State: lifecycleResultStateCancelled, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "dependency probe cancelled"}, Message: "dependency probe cancelled", ErrorCode: "dependency_probe_cancelled"}
@@ -178,7 +180,7 @@ func (worker *Worker) executeDependencyJob(ctx context.Context, assignment proto
return lifecycleFailure("dependency_journal_failed", err.Error())
}
}
state, evidence, probeErr := worker.executor.runDependencyProbe(ctx, input.Probe, input.Bindings)
state, evidence, probeErr := worker.executor.runDependencyProbe(ctx, input.Probe, input.Bindings, input.ServerRoot)
if probeErr != nil {
return lifecycleFailure("dependency_verify_failed", probeErr.Error())
}
@@ -206,6 +208,11 @@ func validateDependencyInput(assignment protocol.RunJobAssignment, input protoco
if !protocol.ValidLogicalFileKey(input.Probe.Key) || !protocol.ValidLogicalFileKey(input.Probe.TargetKey) {
return fmt.Errorf("dependency probe is unsafe")
}
if root := strings.TrimSpace(input.ServerRoot); root != "" {
if !filepath.IsAbs(root) || strings.ContainsRune(root, 0) {
return fmt.Errorf("dependency install root is unsafe")
}
}
if assignment.Capability == protocol.RunCapabilityDependenciesCheck {
if assignment.TargetKey != "dependencies/"+input.Probe.Key || input.Plan.Key != "" {
return fmt.Errorf("dependency check declaration does not match job")
@@ -232,7 +239,7 @@ func validDependencyInstallStepType(value string) bool {
}
}
func (executor LifecycleExecutor) runDependencyProbe(ctx context.Context, probe protocol.DependencyProbe, bindings map[string]string) (string, string, error) {
func (executor LifecycleExecutor) runDependencyProbe(ctx context.Context, probe protocol.DependencyProbe, bindings map[string]string, installRoot string) (string, string, error) {
target := strings.TrimSpace(bindings[probe.TargetKey])
if target == "" {
target = probe.TargetKey
@@ -262,6 +269,8 @@ func (executor LifecycleExecutor) runDependencyProbe(ctx context.Context, probe
return "missing", "declared package executable is not present", nil
}
return "present", "declared package executable is present", nil
case "steam.update":
return executor.runSteamUpdateProbe(ctx, probe, target, installRoot)
case "command.version", "java.version", "docker.available":
if err := validateDependencyExecutable(target); err != nil {
return "", "", err
@@ -303,6 +312,120 @@ func (executor LifecycleExecutor) runDependencyProbe(ctx context.Context, probe
}
}
// runSteamUpdateProbe compares the installed Steam app build with the build
// published on the app's public branch. The declared probe target is the
// SteamCMD executable, the app id is plugin-declared, and the installed app
// manifest is read from the server's declared install root before falling back
// to the SteamCMD directory. All three values are generic Steam facts, so no
// game-specific behavior lives here.
func (executor LifecycleExecutor) runSteamUpdateProbe(ctx context.Context, probe protocol.DependencyProbe, target string, installRoot string) (string, string, error) {
if err := validateDependencyExecutable(target); err != nil {
return "", "", err
}
if !steamAppIDPattern.MatchString(probe.SteamAppID) {
return "", "", fmt.Errorf("dependency steam app id is invalid")
}
executable := target
if !filepath.IsAbs(executable) {
resolved, err := exec.LookPath(executable)
if err != nil {
return "missing", "declared SteamCMD executable is not available", nil
}
executable = resolved
}
installed, installedKnown := readInstalledSteamBuildID(steamInstallRoots(executable, installRoot), probe.SteamAppID)
result, err := executor.dependencyRunner.Run(ctx, ProcessCommand{
Args: []string{executable, "+login", "anonymous", "+app_info_update", "1", "+app_info_print", probe.SteamAppID, "+quit"},
Timeout: 60 * time.Second,
Capability: "dependency.probe",
Action: probe.Kind,
})
if err != nil || result.ExitCode != 0 {
if ctx.Err() != nil {
return "", "", ctx.Err()
}
return "missing", "declared SteamCMD app info query is not available", nil
}
latest, latestKnown := parseSteamPublicBuildID(processOutputEvidence(result.Stdout, result.Stderr))
switch {
case !installedKnown && !latestKnown:
return "unknown", "installed and published Steam builds are unknown", nil
case !installedKnown:
return "unknown", fmt.Sprintf("installed=none latest=%s update=yes", latest), nil
case !latestKnown:
return "unknown", fmt.Sprintf("installed=%s latest=none update=unknown", installed), nil
case installed == latest:
return "present", fmt.Sprintf("installed=%s latest=%s update=no", installed, latest), nil
default:
return "present", fmt.Sprintf("installed=%s latest=%s update=yes", installed, latest), nil
}
}
// steamInstallRoots lists the directories that can hold the installed Steam
// app manifest, most specific first. SteamCMD writes the manifest into the app
// install root when a force_install_dir is used, and into its own directory
// otherwise.
func steamInstallRoots(steamcmdExecutable string, installRoot string) []string {
roots := make([]string, 0, 2)
if trimmed := strings.TrimSpace(installRoot); trimmed != "" && filepath.IsAbs(trimmed) {
roots = append(roots, filepath.Clean(trimmed))
}
return append(roots, filepath.Dir(steamcmdExecutable))
}
func readInstalledSteamBuildID(roots []string, appID string) (string, bool) {
for _, root := range roots {
body, err := os.ReadFile(filepath.Join(root, "steamapps", "appmanifest_"+appID+".acf"))
if err != nil {
continue
}
if buildID, ok := steamBuildIDFromManifest(string(body)); ok {
return buildID, true
}
}
return "", false
}
func steamBuildIDFromManifest(body string) (string, bool) {
for _, line := range strings.Split(body, "\n") {
trimmed := strings.TrimSpace(line)
matches := steamBuildIDLinePattern.FindStringSubmatch(trimmed)
if matches == nil {
continue
}
return matches[1], true
}
return "", false
}
// parseSteamPublicBuildID reads the build id of the app's public branch from
// SteamCMD `app_info_print` output. Depot sections carry their own build ids,
// so the parser only accepts the build id that directly follows the public
// branch entry.
func parseSteamPublicBuildID(output string) (string, bool) {
inPublicBranch := false
for _, line := range strings.Split(output, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if trimmed == "\"public\"" {
inPublicBranch = true
continue
}
if !inPublicBranch {
continue
}
if matches := steamBuildIDLinePattern.FindStringSubmatch(trimmed); matches != nil {
return matches[1], true
}
if trimmed != "{" {
inPublicBranch = false
}
}
return "", false
}
func (executor LifecycleExecutor) runDependencyInstallStep(ctx context.Context, assignment protocol.RunJobAssignment, input protocol.DependencyExecutionInputResponse, step protocol.DependencyInstallStep, index int) error {
if !protocol.ValidLogicalFileKey(step.TargetKey) {
return fmt.Errorf("dependency install target is unsafe")