first commit

This commit is contained in:
npc0-hue
2026-07-11 14:56:10 +08:00
commit 7e05d0a4e7
660 changed files with 78119 additions and 0 deletions
+454
View File
@@ -0,0 +1,454 @@
package runtime
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"browser.local/run/protocol"
)
const (
lifecycleResultStateSucceeded = "succeeded"
lifecycleResultStateFailed = "failed"
lifecycleResultStateCancelled = "cancelled"
defaultLifecycleTimeout = 30 * time.Second
maxLifecycleOutputBytes = 4096
)
var (
commandNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
envNamePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`)
disallowedExecutables = map[string]struct{}{
"bash": {},
"cmd": {},
"fish": {},
"powershell": {},
"pwsh": {},
"sh": {},
"zsh": {},
}
)
type LifecycleExecutor struct {
workspaceRoot string
supervisor ProcessSupervisor
logSink ProcessLogSink
artifactHook LifecycleArtifactHook
}
type LifecycleExecutionResult struct {
State string
Progress protocol.RunJobProgressReport
ResultRef string
Message string
ErrorCode string
}
type LifecycleExecutorOption func(*LifecycleExecutor)
func NewLifecycleExecutor(options ...LifecycleExecutorOption) LifecycleExecutor {
executor := LifecycleExecutor{
workspaceRoot: filepath.Join(".", ".run-workspace"),
supervisor: OSProcessSupervisor{},
logSink: NoopProcessLogSink{},
artifactHook: StaticLifecycleArtifactHook{},
}
for _, option := range options {
option(&executor)
}
return executor
}
func WithLifecycleWorkspaceRoot(root string) LifecycleExecutorOption {
return func(executor *LifecycleExecutor) {
if strings.TrimSpace(root) != "" {
executor.workspaceRoot = root
}
}
}
func WithProcessSupervisor(supervisor ProcessSupervisor) LifecycleExecutorOption {
return func(executor *LifecycleExecutor) {
if supervisor != nil {
executor.supervisor = supervisor
}
}
}
func WithProcessLogSink(sink ProcessLogSink) LifecycleExecutorOption {
return func(executor *LifecycleExecutor) {
if sink != nil {
executor.logSink = sink
}
}
}
func WithLifecycleArtifactHook(hook LifecycleArtifactHook) LifecycleExecutorOption {
return func(executor *LifecycleExecutor) {
if hook != nil {
executor.artifactHook = hook
}
}
}
func SupportedLifecycleCapabilities() []string {
return []string{
protocol.RunCapabilityProcessInstall,
protocol.RunCapabilityProcessStart,
protocol.RunCapabilityProcessStop,
}
}
func SupportedRunCapabilities() []string {
capabilities := append([]string(nil), SupportedLifecycleCapabilities()...)
capabilities = append(capabilities, protocol.RunCapabilityLogsRead)
return capabilities
}
func (executor LifecycleExecutor) SupportedCapabilities() []string {
return SupportedLifecycleCapabilities()
}
func (executor LifecycleExecutor) Execute(assignment protocol.RunJobAssignment) LifecycleExecutionResult {
return executor.ExecuteContext(context.Background(), assignment)
}
func (executor LifecycleExecutor) ExecuteContext(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
if !isSupportedLifecycleCapability(assignment.Capability) {
return lifecycleFailure("unsupported_lifecycle_capability", "unsupported lifecycle capability")
}
command, err := executor.ResolveCommand(assignment)
if err != nil {
return lifecycleFailure("unsafe_lifecycle_command", err.Error())
}
result, err := executor.supervisor.Run(ctx, command)
if err != nil && ctx.Err() != nil {
return LifecycleExecutionResult{
State: lifecycleResultStateCancelled,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "lifecycle action cancelled"},
Message: "lifecycle action cancelled",
ErrorCode: "lifecycle_cancelled",
}
}
executor.writeProcessLogs(ctx, assignment, result)
if err != nil {
return lifecycleFailure("lifecycle_process_failed", RedactText(err.Error()))
}
if result.ExitCode != 0 {
return lifecycleFailure("lifecycle_process_failed", fmt.Sprintf("lifecycle command exited with code %d", result.ExitCode))
}
artifactRef, err := executor.artifactHook.QueueLifecycleResult(ctx, assignment, result)
if err != nil {
return lifecycleFailure("lifecycle_artifact_hook_failed", err.Error())
}
return LifecycleExecutionResult{
State: lifecycleResultStateSucceeded,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "lifecycle action completed"},
ResultRef: artifactRef,
Message: fmt.Sprintf("%s completed", assignment.Capability),
}
}
func (executor LifecycleExecutor) ResolveCommand(assignment protocol.RunJobAssignment) (ProcessCommand, error) {
workdir, err := scopedServerWorkspace(executor.workspaceRoot, assignment.ServerInstanceID)
if err != nil {
return ProcessCommand{}, err
}
if err := os.MkdirAll(workdir, 0o755); err != nil {
return ProcessCommand{}, fmt.Errorf("create scoped workspace: %w", err)
}
template := LifecycleActionTemplate{
Command: []string{"true"},
TimeoutMS: int(defaultLifecycleTimeout / time.Millisecond),
}
if assignment.TargetKey != "" {
path, err := scopedPath(workdir, assignment.TargetKey)
if err != nil {
return ProcessCommand{}, err
}
file, err := os.Open(path)
if err != nil {
return ProcessCommand{}, fmt.Errorf("open lifecycle action template: %w", err)
}
decodeErr := json.NewDecoder(file).Decode(&template)
closeErr := file.Close()
if decodeErr != nil {
return ProcessCommand{}, fmt.Errorf("decode lifecycle action template: %w", decodeErr)
}
if closeErr != nil {
return ProcessCommand{}, fmt.Errorf("close lifecycle action template: %w", closeErr)
}
}
return template.ToProcessCommand(workdir)
}
func (executor LifecycleExecutor) writeProcessLogs(ctx context.Context, assignment protocol.RunJobAssignment, result ProcessResult) {
for _, item := range []struct {
stream string
body string
}{
{stream: "stdout", body: result.Stdout},
{stream: "stderr", body: result.Stderr},
} {
for _, line := range splitBoundedLines(item.body) {
_ = executor.logSink.Append(ctx, assignment, item.stream, line)
}
}
}
type LifecycleActionTemplate struct {
Command []string `json:"command"`
Env map[string]string `json:"env,omitempty"`
TimeoutMS int `json:"timeoutMs,omitempty"`
}
func (template LifecycleActionTemplate) ToProcessCommand(workdir string) (ProcessCommand, error) {
if len(template.Command) == 0 {
return ProcessCommand{}, fmt.Errorf("command is required")
}
for i, part := range template.Command {
if strings.TrimSpace(part) == "" {
return ProcessCommand{}, fmt.Errorf("command part is required")
}
if containsUnsafeRuntimeText(part) {
return ProcessCommand{}, fmt.Errorf("command contains unsafe content")
}
if i == 0 {
if !commandNamePattern.MatchString(part) || strings.Contains(part, "/") || filepath.IsAbs(part) {
return ProcessCommand{}, fmt.Errorf("command executable must be an allowlisted name")
}
if _, disallowed := disallowedExecutables[strings.ToLower(part)]; disallowed {
return ProcessCommand{}, fmt.Errorf("command executable must not be a shell")
}
continue
}
if strings.ContainsAny(part, "|;&`$<>") {
return ProcessCommand{}, fmt.Errorf("command arguments must not contain shell metacharacters")
}
}
env := make(map[string]string, len(template.Env))
for key, value := range template.Env {
if !envNamePattern.MatchString(key) || !strings.HasPrefix(key, "GAME_") && !strings.HasPrefix(key, "SERVER_") && !strings.HasPrefix(key, "RUN_") {
return ProcessCommand{}, fmt.Errorf("env key is not allowlisted")
}
if containsUnsafeRuntimeText(value) {
return ProcessCommand{}, fmt.Errorf("env value contains unsafe content")
}
env[key] = value
}
timeout := defaultLifecycleTimeout
if template.TimeoutMS > 0 {
timeout = time.Duration(template.TimeoutMS) * time.Millisecond
}
if timeout > 5*time.Minute {
return ProcessCommand{}, fmt.Errorf("timeout is too large")
}
return ProcessCommand{WorkDir: workdir, Args: append([]string(nil), template.Command...), Env: env, Timeout: timeout}, nil
}
type ProcessCommand struct {
WorkDir string
Args []string
Env map[string]string
Timeout time.Duration
}
type ProcessResult struct {
ExitCode int
Stdout string
Stderr string
}
type ProcessSupervisor interface {
Run(context.Context, ProcessCommand) (ProcessResult, error)
}
type OSProcessSupervisor struct{}
func (supervisor OSProcessSupervisor) Run(ctx context.Context, command ProcessCommand) (ProcessResult, error) {
if len(command.Args) == 0 {
return ProcessResult{ExitCode: -1}, fmt.Errorf("command is required")
}
if command.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, command.Timeout)
defer cancel()
}
cmd := exec.CommandContext(ctx, command.Args[0], command.Args[1:]...)
cmd.Dir = command.WorkDir
cmd.Env = os.Environ()
for key, value := range command.Env {
cmd.Env = append(cmd.Env, key+"="+value)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = ioLimitWriter{Writer: &stdout, Limit: maxLifecycleOutputBytes}
cmd.Stderr = ioLimitWriter{Writer: &stderr, Limit: maxLifecycleOutputBytes}
err := cmd.Run()
result := ProcessResult{Stdout: RedactText(stdout.String()), Stderr: RedactText(stderr.String())}
if cmd.ProcessState != nil {
result.ExitCode = cmd.ProcessState.ExitCode()
}
if err != nil {
return result, err
}
return result, nil
}
type ioLimitWriter struct {
Writer *bytes.Buffer
Limit int
}
func (writer ioLimitWriter) Write(p []byte) (int, error) {
remaining := writer.Limit - writer.Writer.Len()
if remaining > 0 {
if len(p) > remaining {
_, _ = writer.Writer.Write(p[:remaining])
} else {
_, _ = writer.Writer.Write(p)
}
}
return len(p), nil
}
type ProcessLogSink interface {
Append(context.Context, protocol.RunJobAssignment, string, string) error
}
type NoopProcessLogSink struct{}
func (NoopProcessLogSink) Append(context.Context, protocol.RunJobAssignment, string, string) error {
return nil
}
type LifecycleArtifactHook interface {
QueueLifecycleResult(context.Context, protocol.RunJobAssignment, ProcessResult) (string, error)
}
type StaticLifecycleArtifactHook struct{}
func (StaticLifecycleArtifactHook) QueueLifecycleResult(_ context.Context, assignment protocol.RunJobAssignment, _ ProcessResult) (string, error) {
return fmt.Sprintf("artifact://jobs/%s/lifecycle-result", url.PathEscape(assignment.JobID)), nil
}
func LifecycleResultRequest(assignment protocol.RunJobAssignment, sessionToken string, result LifecycleExecutionResult) protocol.RunJobResultRequest {
return protocol.RunJobResultRequest{
RunEndpointID: assignment.RunEndpointID,
SessionToken: sessionToken,
JobID: assignment.JobID,
LeaseToken: assignment.LeaseToken,
Attempt: assignment.Attempt,
State: result.State,
Progress: result.Progress,
ResultRef: result.ResultRef,
Message: result.Message,
ErrorCode: result.ErrorCode,
}
}
func isSupportedLifecycleCapability(capability string) bool {
for _, supported := range SupportedLifecycleCapabilities() {
if capability == supported {
return true
}
}
return false
}
func lifecycleFailure(code string, message string) LifecycleExecutionResult {
return LifecycleExecutionResult{
State: lifecycleResultStateFailed,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: RedactText(message)},
Message: RedactText(message),
ErrorCode: code,
}
}
func scopedServerWorkspace(root string, serverInstanceID string) (string, error) {
if strings.TrimSpace(serverInstanceID) == "" {
return "", fmt.Errorf("server instance id is required")
}
if containsUnsafeRuntimeText(serverInstanceID) || strings.ContainsAny(serverInstanceID, `/\`) || serverInstanceID == "." || serverInstanceID == ".." {
return "", fmt.Errorf("server instance id is unsafe")
}
return scopedPath(root, serverInstanceID)
}
func scopedPath(root string, key string) (string, error) {
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("workspace root is required")
}
if strings.TrimSpace(key) == "" {
return "", fmt.Errorf("logical key is required")
}
if filepath.IsAbs(key) || strings.Contains(key, "..") || strings.Contains(key, `\`) || containsUnsafeRuntimeText(key) {
return "", fmt.Errorf("logical key is unsafe")
}
cleanRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
candidate := filepath.Clean(filepath.Join(cleanRoot, filepath.FromSlash(key)))
rel, err := filepath.Rel(cleanRoot, candidate)
if err != nil {
return "", err
}
if rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return "", fmt.Errorf("logical key escapes workspace")
}
return candidate, nil
}
func containsUnsafeRuntimeText(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{"/users/", "/.ssh/", "password=", "apikey", "api_key", "secret=", "bearer ", "sk-", "unix://", "tcp://", "://"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func RedactText(value string) string {
redacted := value
replacements := []string{"/Users/", "[host]/", "Bearer ", "Bearer [redacted] ", "sk-", "sk-[redacted]", "password=", "password=[redacted]", "api_key=", "api_key=[redacted]", "secret=", "secret=[redacted]", "unix://", "socket://"}
for i := 0; i+1 < len(replacements); i += 2 {
redacted = strings.ReplaceAll(redacted, replacements[i], replacements[i+1])
}
if len(redacted) > maxLifecycleOutputBytes {
return redacted[:maxLifecycleOutputBytes]
}
return redacted
}
func splitBoundedLines(value string) []string {
value = RedactText(value)
lines := strings.Split(value, "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimRight(line, "\r")
if strings.TrimSpace(line) == "" {
continue
}
out = append(out, line)
}
return out
}
func checksumForText(value string) string {
sum := sha256.Sum256([]byte(value))
return "sha256:" + hex.EncodeToString(sum[:])
}