first commit
This commit is contained in:
@@ -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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user