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:
+126
-3
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user