- LifecycleActionTemplate 新增 gracefulStop:stop 动作可声明插件自有的优雅关闭命令、 参数、环境、超时与 fallback;超时且 fallback=report 时任务失败,避免默认杀进程。 - 依赖探针新增 steam.update:通过 steamcmd +app_info_print 读取公开分支 buildid, 与本地 appmanifest_<appid>.acf 比较,输出 installed/latest/update=yes|no|unknown。 - 依赖执行输入新增 ServerRoot,供探针定位插件声明的服务器安装目录。
689 lines
25 KiB
Go
689 lines
25 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"browser.local/run/protocol"
|
|
)
|
|
|
|
const (
|
|
maxDependencyDownloadBytes = int64(512 * 1024 * 1024)
|
|
dependencyCommandTimeout = 10 * time.Minute
|
|
)
|
|
|
|
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}$`)
|
|
)
|
|
|
|
type DependencyDownloader interface {
|
|
Download(context.Context, string, string, int64) (int64, string, error)
|
|
}
|
|
|
|
type HTTPDependencyDownloader struct {
|
|
Client *http.Client
|
|
}
|
|
|
|
func (downloader HTTPDependencyDownloader) Download(ctx context.Context, sourceURL, destination string, maxBytes int64) (int64, string, error) {
|
|
parsed, err := validateDependencyDownloadURL(sourceURL)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
client := downloader.Client
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 10 * time.Minute, CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
|
if len(via) >= 3 {
|
|
return fmt.Errorf("dependency download redirect limit exceeded")
|
|
}
|
|
_, err := validateDependencyDownloadURL(request.URL.String())
|
|
return err
|
|
}}
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return 0, "", fmt.Errorf("dependency download returned status %d", response.StatusCode)
|
|
}
|
|
if response.ContentLength > maxBytes {
|
|
return 0, "", fmt.Errorf("dependency download exceeds size limit")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
|
|
return 0, "", err
|
|
}
|
|
temporary := destination + ".partial"
|
|
file, err := os.OpenFile(temporary, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
remove := true
|
|
defer func() {
|
|
_ = file.Close()
|
|
if remove {
|
|
_ = os.Remove(temporary)
|
|
}
|
|
}()
|
|
hash := sha256.New()
|
|
written, err := io.Copy(io.MultiWriter(file, hash), io.LimitReader(response.Body, maxBytes+1))
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
if written > maxBytes {
|
|
return 0, "", fmt.Errorf("dependency download exceeds size limit")
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
return 0, "", err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return 0, "", err
|
|
}
|
|
if err := os.Rename(temporary, destination); err != nil {
|
|
return 0, "", err
|
|
}
|
|
remove = false
|
|
return written, "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
|
|
}
|
|
|
|
type dependencyJournal struct {
|
|
Version int `json:"version"`
|
|
JobID string `json:"jobId"`
|
|
Attempt int `json:"attempt"`
|
|
PlanDigest string `json:"planDigest"`
|
|
CompletedSteps []int `json:"completedSteps,omitempty"`
|
|
State string `json:"state"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
func (worker *Worker) executeDependencyJob(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
|
|
if err := protocol.ValidateRunJobAssignment(assignment); err != nil {
|
|
return lifecycleFailure("unsafe_dependency_job", err.Error())
|
|
}
|
|
runState, err := worker.registeredState()
|
|
if err != nil {
|
|
return lifecycleFailure("dependency_unregistered", "Run worker is not registered")
|
|
}
|
|
input, err := worker.client.GetDependencyExecutionInput(ctx, protocol.DependencyExecutionInputRequest{RunEndpointID: runState.RunEndpointID, SessionToken: runState.SessionToken, JobID: assignment.JobID, LeaseToken: assignment.LeaseToken, Attempt: assignment.Attempt})
|
|
if err != nil {
|
|
return lifecycleFailure("dependency_input_failed", "could not load fenced dependency input")
|
|
}
|
|
if err := validateDependencyInput(assignment, input); err != nil {
|
|
return lifecycleFailure("unsafe_dependency_input", err.Error())
|
|
}
|
|
journalPath := filepath.Join(worker.cfg.WorkspaceRoot, "dependency-journals", safeWorkspaceName(assignment.JobID)+".json")
|
|
journal, err := loadDependencyJournal(journalPath, assignment, input.PlanDigest)
|
|
if err != nil {
|
|
return lifecycleFailure("dependency_journal_failed", err.Error())
|
|
}
|
|
|
|
if assignment.Capability == protocol.RunCapabilityDependenciesCheck {
|
|
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"}
|
|
}
|
|
return lifecycleFailure("dependency_probe_failed", probeErr.Error())
|
|
}
|
|
journal.State = state
|
|
journal.UpdatedAt = time.Now().UTC()
|
|
if err := persistDependencyJournal(journalPath, journal); err != nil {
|
|
return lifecycleFailure("dependency_journal_failed", err.Error())
|
|
}
|
|
return dependencySuccess(assignment, input, state, evidence, 0)
|
|
}
|
|
|
|
completed := map[int]bool{}
|
|
for _, index := range journal.CompletedSteps {
|
|
completed[index] = true
|
|
}
|
|
for index, step := range input.Plan.Steps {
|
|
if completed[index] {
|
|
continue
|
|
}
|
|
if cancelled, ok := checkContextCancelled(ctx, "dependency installation cancelled", "dependency_install_cancelled"); ok {
|
|
return cancelled
|
|
}
|
|
if err := worker.executor.runDependencyInstallStep(ctx, assignment, input, step, index); err != nil {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return LifecycleExecutionResult{State: lifecycleResultStateCancelled, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "dependency installation cancelled"}, Message: "dependency installation cancelled", ErrorCode: "dependency_install_cancelled"}
|
|
}
|
|
return lifecycleFailure("dependency_install_failed", err.Error())
|
|
}
|
|
journal.CompletedSteps = append(journal.CompletedSteps, index)
|
|
journal.State = "installing"
|
|
journal.UpdatedAt = time.Now().UTC()
|
|
if err := persistDependencyJournal(journalPath, journal); err != nil {
|
|
return lifecycleFailure("dependency_journal_failed", err.Error())
|
|
}
|
|
}
|
|
state, evidence, probeErr := worker.executor.runDependencyProbe(ctx, input.Probe, input.Bindings, input.ServerRoot)
|
|
if probeErr != nil {
|
|
return lifecycleFailure("dependency_verify_failed", probeErr.Error())
|
|
}
|
|
if state != "present" {
|
|
return lifecycleFailure("dependency_verify_missing", "dependency remains missing after install plan")
|
|
}
|
|
journal.State = "present"
|
|
journal.UpdatedAt = time.Now().UTC()
|
|
if err := persistDependencyJournal(journalPath, journal); err != nil {
|
|
return lifecycleFailure("dependency_journal_failed", err.Error())
|
|
}
|
|
return dependencySuccess(assignment, input, "present", evidence, len(journal.CompletedSteps))
|
|
}
|
|
|
|
func validateDependencyInput(assignment protocol.RunJobAssignment, input protocol.DependencyExecutionInputResponse) error {
|
|
if input.JobID != assignment.JobID || input.ServerInstanceID != assignment.ServerInstanceID || input.RunEndpointID != assignment.RunEndpointID {
|
|
return fmt.Errorf("dependency input scope does not match job")
|
|
}
|
|
if input.TargetOS != runtime.GOOS || input.TargetArch != runtime.GOARCH {
|
|
return fmt.Errorf("dependency input target does not match Run")
|
|
}
|
|
if !validSHA256(input.PlanDigest) || !protocol.ValidLogicalFileKey(input.ProfileKey) {
|
|
return fmt.Errorf("dependency input digest or profile is unsafe")
|
|
}
|
|
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")
|
|
}
|
|
return nil
|
|
}
|
|
if assignment.Capability != protocol.RunCapabilityDependenciesInstall || assignment.TargetKey != "dependencies/install/"+input.Plan.Key || !protocol.ValidLogicalFileKey(input.Plan.Key) || len(input.Plan.Steps) == 0 || len(input.Plan.Steps) > 64 {
|
|
return fmt.Errorf("dependency install declaration does not match job")
|
|
}
|
|
for _, step := range input.Plan.Steps {
|
|
if !validDependencyInstallStepType(step.Type) {
|
|
return fmt.Errorf("dependency install step type is unsupported")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validDependencyInstallStepType(value string) bool {
|
|
switch value {
|
|
case "package", "verified-download", "manual":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
switch probe.Kind {
|
|
case "file.exists", "steam.app":
|
|
info, err := os.Lstat(target)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "missing", "declared target is not present", nil
|
|
}
|
|
return "", "", err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return "", "", fmt.Errorf("dependency target cannot be a symlink")
|
|
}
|
|
return "present", "declared target is present", nil
|
|
case "package.installed":
|
|
if err := validateDependencyExecutable(target); err != nil {
|
|
return "", "", err
|
|
}
|
|
if filepath.IsAbs(target) {
|
|
if _, err := os.Stat(target); err != nil {
|
|
return "missing", "declared package executable is not present", nil
|
|
}
|
|
} else if _, err := exec.LookPath(target); err != nil {
|
|
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
|
|
}
|
|
args := []string{"--version"}
|
|
if probe.Kind == "java.version" {
|
|
args = []string{"-version"}
|
|
}
|
|
result, err := executor.dependencyRunner.Run(ctx, ProcessCommand{Args: append([]string{target}, args...), Timeout: 30 * time.Second, Capability: "dependency.probe", Action: probe.Kind})
|
|
if err != nil || result.ExitCode != 0 {
|
|
if ctx.Err() != nil {
|
|
return "", "", ctx.Err()
|
|
}
|
|
return "missing", "declared executable is not available", nil
|
|
}
|
|
version := dependencyVersionEvidence(processOutputEvidence(result.Stdout, result.Stderr))
|
|
if probe.MinimumVersion != "" && !dependencyVersionAtLeast(version, probe.MinimumVersion) {
|
|
return "missing", "declared executable version is below minimum", nil
|
|
}
|
|
return "present", version, nil
|
|
case "service.exists":
|
|
if !dependencyTokenPattern.MatchString(target) {
|
|
return "", "", fmt.Errorf("dependency service target is unsafe")
|
|
}
|
|
name, args := serviceProbeCommand(runtime.GOOS, target)
|
|
if name == "" {
|
|
return "", "", fmt.Errorf("service probe is unsupported on this platform")
|
|
}
|
|
result, err := executor.dependencyRunner.Run(ctx, ProcessCommand{Args: append([]string{name}, args...), Timeout: 30 * time.Second, Capability: "dependency.probe", Action: probe.Kind})
|
|
if err != nil || result.ExitCode != 0 {
|
|
if ctx.Err() != nil {
|
|
return "", "", ctx.Err()
|
|
}
|
|
return "missing", "declared service is not present", nil
|
|
}
|
|
return "present", "declared service is present", nil
|
|
default:
|
|
return "", "", fmt.Errorf("dependency probe kind is unsupported")
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
switch step.Type {
|
|
case "package":
|
|
name, args, err := packageInstallCommand(step.PackageManager, step.PackageName, step.Version)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result, runErr := executor.dependencyRunner.Run(ctx, ProcessCommand{Args: append([]string{name}, args...), Timeout: dependencyCommandTimeout, JobID: assignment.JobID, Capability: assignment.Capability, Action: "dependency.package"})
|
|
if runErr != nil || result.ExitCode != 0 {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return fmt.Errorf("typed package adapter failed")
|
|
}
|
|
return nil
|
|
case "verified-download":
|
|
if !validSHA256(step.Checksum) {
|
|
return fmt.Errorf("verified download checksum is required")
|
|
}
|
|
if _, err := validateDependencyDownloadURL(step.DownloadRef); err != nil {
|
|
return err
|
|
}
|
|
destination := filepath.Join(executor.workspaceRoot, "dependency-files", safeWorkspaceName(assignment.ServerInstanceID), safeWorkspaceName(step.TargetKey))
|
|
size, checksum, err := executor.dependencyDownloader.Download(ctx, step.DownloadRef, destination, maxDependencyDownloadBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if size <= 0 || checksum != strings.ToLower(step.Checksum) {
|
|
_ = os.Remove(destination)
|
|
return fmt.Errorf("verified dependency download checksum mismatch")
|
|
}
|
|
return os.Chmod(destination, 0o700)
|
|
case "manual":
|
|
return fmt.Errorf("manual dependency step requires operator action")
|
|
default:
|
|
return fmt.Errorf("dependency install step type is unsupported")
|
|
}
|
|
}
|
|
|
|
func packageInstallCommand(manager, packageName, version string) (string, []string, error) {
|
|
if !dependencyTokenPattern.MatchString(packageName) || version != "" && !dependencyVersionPattern.MatchString(version) {
|
|
return "", nil, fmt.Errorf("package name or version is unsafe")
|
|
}
|
|
spec := packageName
|
|
switch manager {
|
|
case "apt":
|
|
if version != "" {
|
|
spec += "=" + version
|
|
}
|
|
return "apt-get", []string{"install", "-y", "--no-install-recommends", spec}, nil
|
|
case "yum", "dnf":
|
|
if version != "" {
|
|
spec += "-" + version
|
|
}
|
|
return manager, []string{"install", "-y", spec}, nil
|
|
case "pacman":
|
|
return "pacman", []string{"-S", "--noconfirm", spec}, nil
|
|
case "zypper":
|
|
return "zypper", []string{"--non-interactive", "install", spec}, nil
|
|
case "brew":
|
|
if version != "" {
|
|
spec += "@" + version
|
|
}
|
|
return "brew", []string{"install", spec}, nil
|
|
case "winget":
|
|
args := []string{"install", "--id", packageName, "--exact", "--silent", "--accept-package-agreements", "--accept-source-agreements"}
|
|
if version != "" {
|
|
args = append(args, "--version", version)
|
|
}
|
|
return "winget", args, nil
|
|
case "choco":
|
|
args := []string{"install", packageName, "-y", "--no-progress"}
|
|
if version != "" {
|
|
args = append(args, "--version", version)
|
|
}
|
|
return "choco", args, nil
|
|
case "scoop":
|
|
if version != "" {
|
|
spec += "@" + version
|
|
}
|
|
return "scoop", []string{"install", spec}, nil
|
|
default:
|
|
return "", nil, fmt.Errorf("package manager is unsupported")
|
|
}
|
|
}
|
|
|
|
func validateDependencyExecutable(target string) error {
|
|
if strings.TrimSpace(target) != target || target == "" || strings.ContainsAny(target, "\r\n\x00") || containsUnsafeRuntimeText(target) {
|
|
return fmt.Errorf("dependency executable target is unsafe")
|
|
}
|
|
if filepath.IsAbs(target) {
|
|
info, err := os.Lstat(target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
|
|
return fmt.Errorf("dependency executable target is not a regular executable")
|
|
}
|
|
return nil
|
|
}
|
|
if !commandNamePattern.MatchString(target) {
|
|
return fmt.Errorf("dependency executable name is unsafe")
|
|
}
|
|
if _, forbidden := disallowedExecutables[strings.ToLower(target)]; forbidden {
|
|
return fmt.Errorf("dependency executable cannot be a shell")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateDependencyDownloadURL(raw string) (*url.URL, error) {
|
|
parsed, err := url.ParseRequestURI(strings.TrimSpace(raw))
|
|
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" {
|
|
return nil, fmt.Errorf("dependency download URL is not approved")
|
|
}
|
|
host := strings.ToLower(parsed.Hostname())
|
|
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
|
return nil, fmt.Errorf("dependency download host is not approved")
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()) {
|
|
return nil, fmt.Errorf("dependency download host is not approved")
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func serviceProbeCommand(targetOS, service string) (string, []string) {
|
|
switch targetOS {
|
|
case "linux":
|
|
return "systemctl", []string{"status", service, "--no-pager"}
|
|
case "windows":
|
|
return "sc", []string{"query", service}
|
|
case "darwin":
|
|
return "launchctl", []string{"print", "system/" + service}
|
|
default:
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
func dependencySuccess(assignment protocol.RunJobAssignment, input protocol.DependencyExecutionInputResponse, state, evidence string, completed int) LifecycleExecutionResult {
|
|
payload, _ := json.Marshal(protocol.DependencyExecutionEvidence{ProbeKey: input.Probe.Key, PlanKey: input.Plan.Key, PlanDigest: input.PlanDigest, State: state, Evidence: evidence, CompletedSteps: completed})
|
|
kind := "dependency.check"
|
|
message := "dependency probe completed"
|
|
if assignment.Capability == protocol.RunCapabilityDependenciesInstall {
|
|
kind = "dependency.install"
|
|
message = "dependency install plan completed and verified"
|
|
}
|
|
return LifecycleExecutionResult{State: lifecycleResultStateSucceeded, Progress: protocol.RunJobProgressReport{Percent: 100, Message: message}, ResultRef: fmt.Sprintf("artifact://jobs/%s/dependencies-result", url.PathEscape(assignment.JobID)), Message: message, ExecutionResult: protocol.RunJobExecutionResult{Kind: kind, Checksum: input.PlanDigest, Summary: message, Content: string(payload)}}
|
|
}
|
|
|
|
func processOutputEvidence(stdout string, stderr string) string {
|
|
if stdout == "" {
|
|
return stderr
|
|
}
|
|
if stderr == "" {
|
|
return stdout
|
|
}
|
|
return stdout + "\n" + stderr
|
|
}
|
|
|
|
func loadDependencyJournal(path string, assignment protocol.RunJobAssignment, digest string) (dependencyJournal, error) {
|
|
journal := dependencyJournal{Version: 1, JobID: assignment.JobID, Attempt: assignment.Attempt, PlanDigest: digest, State: "pending", UpdatedAt: time.Now().UTC()}
|
|
body, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return journal, nil
|
|
}
|
|
if err != nil {
|
|
return dependencyJournal{}, err
|
|
}
|
|
if err := json.Unmarshal(body, &journal); err != nil {
|
|
return dependencyJournal{}, fmt.Errorf("decode dependency journal: %w", err)
|
|
}
|
|
if journal.Version != 1 || journal.JobID != assignment.JobID || journal.PlanDigest != digest {
|
|
return dependencyJournal{}, fmt.Errorf("dependency journal does not match immutable plan")
|
|
}
|
|
if journal.Attempt > assignment.Attempt {
|
|
return dependencyJournal{}, fmt.Errorf("dependency journal attempt is newer than assignment")
|
|
}
|
|
journal.Attempt = assignment.Attempt
|
|
return journal, nil
|
|
}
|
|
|
|
func persistDependencyJournal(path string, journal dependencyJournal) error {
|
|
body, err := json.MarshalIndent(journal, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeRuntimeAtomicFile(path, body, 0o600)
|
|
}
|
|
|
|
func writeRuntimeAtomicFile(path string, body []byte, mode os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
temporary := path + ".tmp"
|
|
file, err := os.OpenFile(temporary, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := file.Write(body); err != nil {
|
|
_ = file.Close()
|
|
_ = os.Remove(temporary)
|
|
return err
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
_ = file.Close()
|
|
_ = os.Remove(temporary)
|
|
return err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
_ = os.Remove(temporary)
|
|
return err
|
|
}
|
|
if err := os.Rename(temporary, path); err != nil {
|
|
_ = os.Remove(temporary)
|
|
return err
|
|
}
|
|
return os.Chmod(path, mode)
|
|
}
|
|
|
|
func validSHA256(value string) bool {
|
|
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") {
|
|
return false
|
|
}
|
|
_, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:"))
|
|
return err == nil
|
|
}
|
|
|
|
func dependencyVersionEvidence(output string) string {
|
|
if output == "" {
|
|
return "version available"
|
|
}
|
|
return output
|
|
}
|
|
|
|
func dependencyVersionAtLeast(actual, minimum string) bool {
|
|
numbers := func(value string) []int {
|
|
parts := regexp.MustCompile(`[0-9]+`).FindAllString(value, -1)
|
|
out := make([]int, len(parts))
|
|
for i, part := range parts {
|
|
out[i], _ = strconv.Atoi(part)
|
|
}
|
|
return out
|
|
}
|
|
a, b := numbers(actual), numbers(minimum)
|
|
for i := 0; i < len(a) || i < len(b); i++ {
|
|
av, bv := 0, 0
|
|
if i < len(a) {
|
|
av = a[i]
|
|
}
|
|
if i < len(b) {
|
|
bv = b[i]
|
|
}
|
|
if av != bv {
|
|
return av > bv
|
|
}
|
|
}
|
|
return len(a) > 0
|
|
}
|