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
+134 -20
View File
@@ -534,18 +534,32 @@ func (executor LifecycleExecutor) writeProcessLogs(ctx context.Context, assignme
}
type LifecycleActionTemplate struct {
Version int `json:"version,omitempty"`
Action string `json:"action,omitempty"`
Mode string `json:"mode,omitempty"`
ExecutableKey string `json:"executableKey,omitempty"`
TargetExecutableKey string `json:"targetExecutableKey,omitempty"`
Arguments []string `json:"arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
OutputMode string `json:"outputMode,omitempty"`
StopTimeoutMS int `json:"stopTimeoutMs,omitempty"`
Command []string `json:"command"`
Env map[string]string `json:"env,omitempty"`
TimeoutMS int `json:"timeoutMs,omitempty"`
Version int `json:"version,omitempty"`
Action string `json:"action,omitempty"`
Mode string `json:"mode,omitempty"`
ExecutableKey string `json:"executableKey,omitempty"`
TargetExecutableKey string `json:"targetExecutableKey,omitempty"`
Arguments []string `json:"arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
OutputMode string `json:"outputMode,omitempty"`
StopTimeoutMS int `json:"stopTimeoutMs,omitempty"`
GracefulStop *GracefulStopActionTemplate `json:"gracefulStop,omitempty"`
Command []string `json:"command"`
Env map[string]string `json:"env,omitempty"`
TimeoutMS int `json:"timeoutMs,omitempty"`
}
// GracefulStopActionTemplate declares the plugin-owned shutdown step that runs
// before a supervised process is terminated. The command is ordinary
// plugin-declared game policy: it can announce a shutdown, ask the game to
// save, and wait for the game to exit on its own. Run only reports and keeps
// the bounded wait here; it never inspects the command text or its output.
type GracefulStopActionTemplate struct {
ExecutableKey string `json:"executableKey,omitempty"`
Arguments []string `json:"arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
TimeoutMS int `json:"timeoutMs,omitempty"`
Fallback string `json:"fallback,omitempty"`
}
func (executor LifecycleExecutor) loadLifecycleTemplate(assignment protocol.RunJobAssignment) (LifecycleActionTemplate, string, error) {
@@ -608,14 +622,7 @@ func (executor LifecycleExecutor) executeManaged(ctx context.Context, assignment
return processExecutionResult(item, "process started")
}
if assignment.Capability == protocol.RunCapabilityProcessStop {
log.Printf("RUN phase=lifecycle.managed status=stopping job=%s scope=%s", assignment.JobID, scope)
item, err := executor.managed.Stop(ctx, identity)
if err != nil {
log.Printf("RUN phase=lifecycle.managed status=stop_failed job=%s error=%s", assignment.JobID, err.Error())
return lifecycleFailure("lifecycle_stop_failed", err.Error())
}
log.Printf("RUN phase=lifecycle.managed status=stopped job=%s pid=%d state=%s classification=%s", assignment.JobID, item.PID, item.State, safeOptional(item.ExitClassification))
return processExecutionResult(item, "process stopped")
return executor.stopManaged(ctx, assignment, template, scope, identity)
}
log.Printf("RUN phase=lifecycle.managed status=querying job=%s scope=%s", assignment.JobID, scope)
item := executor.managed.Status(identity)
@@ -623,6 +630,113 @@ func (executor LifecycleExecutor) executeManaged(ctx context.Context, assignment
return processExecutionResult(item, "process status queried")
}
// stopManaged executes the plugin-declared shutdown step for a supervised
// process, then applies the plugin-declared fallback policy. Run keeps the
// generic supervision rules here; the declared command owns all game policy
// (player announcement, save request, shutdown command, exit polling).
func (executor LifecycleExecutor) stopManaged(ctx context.Context, assignment protocol.RunJobAssignment, template LifecycleActionTemplate, scope string, identity ProcessIdentity) LifecycleExecutionResult {
declared := template.GracefulStop
if declared == nil || strings.TrimSpace(declared.ExecutableKey) == "" {
log.Printf("RUN phase=lifecycle.managed status=stopping job=%s scope=%s", assignment.JobID, scope)
return executor.terminateManagedProcess(ctx, assignment, identity)
}
fallback := strings.TrimSpace(declared.Fallback)
if fallback == "" {
fallback = "report"
}
if fallback != "report" && fallback != "terminate" {
log.Printf("RUN phase=lifecycle.managed status=graceful_stop_invalid job=%s fallback=%s", assignment.JobID, fallback)
return lifecycleFailure("unsafe_lifecycle_command", "graceful stop fallback policy is unsupported")
}
command, err := declared.toProcessCommand(template, executor.workspaceRoot, scope, assignment)
if err != nil {
log.Printf("RUN phase=lifecycle.managed status=graceful_stop_invalid job=%s error=%s", assignment.JobID, err.Error())
return lifecycleFailure("unsafe_graceful_stop_command", err.Error())
}
log.Printf("RUN phase=lifecycle.managed status=graceful_stop_requested job=%s scope=%s fallback=%s command=%s timeoutMs=%d", assignment.JobID, scope, fallback, quotedCommandLine(command.Args), command.Timeout.Milliseconds())
command.OutputLine = func(stream string, line string) {
_ = executor.logSink.Append(ctx, assignment, stream, line)
}
result, err := executor.supervisor.Run(ctx, command)
if !result.OutputRelayed {
executor.writeProcessLogs(ctx, assignment, result)
}
if ctx.Err() != nil {
log.Printf("RUN phase=lifecycle.managed status=graceful_stop_cancelled job=%s error=%s", assignment.JobID, ctx.Err().Error())
return lifecycleExecutionFailure("lifecycle_cancelled", "lifecycle action cancelled", false)
}
if err != nil || result.ExitCode != 0 {
log.Printf("RUN phase=lifecycle.managed status=graceful_stop_failed job=%s exitCode=%d error=%s", assignment.JobID, result.ExitCode, errorSummary(err))
return executor.gracefulStopFallback(ctx, assignment, identity, fallback, "graceful_stop_failed")
}
current := executor.managed.Status(identity)
if current.State != "running" {
item, failure := executor.terminateManagedProcessResult(ctx, assignment, identity)
if failure.State != "" {
return failure
}
log.Printf("RUN phase=lifecycle.managed status=graceful_stop_completed job=%s pid=%d state=%s classification=%s", assignment.JobID, item.PID, item.State, safeOptional(item.ExitClassification))
result := processExecutionResult(item, "process stopped gracefully")
result.Progress.Message = "process stopped gracefully"
return result
}
log.Printf("RUN phase=lifecycle.managed status=graceful_stop_alive job=%s pid=%d fallback=%s", assignment.JobID, current.PID, fallback)
return executor.gracefulStopFallback(ctx, assignment, identity, fallback, "graceful_stop_timeout")
}
func (executor LifecycleExecutor) gracefulStopFallback(ctx context.Context, assignment protocol.RunJobAssignment, identity ProcessIdentity, fallback string, failureCode string) LifecycleExecutionResult {
if fallback != "terminate" {
log.Printf("RUN phase=lifecycle.managed status=graceful_stop_unverified job=%s code=%s", assignment.JobID, failureCode)
return lifecycleFailure(failureCode, "declared graceful stop did not stop the process and the plugin does not allow termination")
}
log.Printf("RUN phase=lifecycle.managed status=graceful_stop_escalated job=%s code=%s", assignment.JobID, failureCode)
return executor.terminateManagedProcess(ctx, assignment, identity)
}
func (executor LifecycleExecutor) terminateManagedProcessResult(ctx context.Context, assignment protocol.RunJobAssignment, identity ProcessIdentity) (ProcessIdentity, LifecycleExecutionResult) {
item, err := executor.managed.Stop(ctx, identity)
if err != nil {
log.Printf("RUN phase=lifecycle.managed status=stop_failed job=%s error=%s", assignment.JobID, err.Error())
return ProcessIdentity{}, lifecycleFailure("lifecycle_stop_failed", err.Error())
}
log.Printf("RUN phase=lifecycle.managed status=stopped job=%s pid=%d state=%s classification=%s", assignment.JobID, item.PID, item.State, safeOptional(item.ExitClassification))
return item, LifecycleExecutionResult{}
}
func (executor LifecycleExecutor) terminateManagedProcess(ctx context.Context, assignment protocol.RunJobAssignment, identity ProcessIdentity) LifecycleExecutionResult {
item, failure := executor.terminateManagedProcessResult(ctx, assignment, identity)
if item.State == "" && failure.State != "" {
return failure
}
return processExecutionResult(item, "process stopped")
}
func (declaration GracefulStopActionTemplate) toProcessCommand(template LifecycleActionTemplate, workspaceRoot string, scope string, assignment protocol.RunJobAssignment) (ProcessCommand, error) {
stopTemplate := LifecycleActionTemplate{
Action: template.Action,
ExecutableKey: declaration.ExecutableKey,
Arguments: declaration.Arguments,
Environment: mergeLifecycleEnvironment(template.Environment, declaration.Environment),
OutputMode: "pipes",
TimeoutMS: declaration.TimeoutMS,
}
return stopTemplate.ToExecutableProcessCommand(NewWorkspaceResolver(workspaceRoot), scope, assignment)
}
func mergeLifecycleEnvironment(base map[string]string, override map[string]string) map[string]string {
if len(base) == 0 && len(override) == 0 {
return nil
}
merged := make(map[string]string, len(base)+len(override))
for key, value := range base {
merged[key] = value
}
for key, value := range override {
merged[key] = value
}
return merged
}
func (executor LifecycleExecutor) ResumeManagedProcessLogs(ctx context.Context) {
if executor.managed == nil {
return