483 lines
15 KiB
Go
483 lines
15 KiB
Go
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)
|
|
capabilities = append(capabilities, SupportedDistributionCapabilities()...)
|
|
capabilities = append(capabilities, SupportedRemoteCapabilities()...)
|
|
return capabilities
|
|
}
|
|
|
|
func SupportedRemoteCapabilities() []string {
|
|
return []string{
|
|
protocol.RunCapabilityRemoteFTPRead,
|
|
protocol.RunCapabilityRemoteFTPWrite,
|
|
protocol.RunCapabilityRemoteRsyncRead,
|
|
protocol.RunCapabilityRemoteRsyncWrite,
|
|
protocol.RunCapabilityRemoteRunFilesRead,
|
|
protocol.RunCapabilityRemoteRunFilesWrite,
|
|
protocol.RunCapabilityRemoteRunProcessStart,
|
|
protocol.RunCapabilityRemoteRunProcessStop,
|
|
protocol.RunCapabilityRemoteRunDBMySQLQuery,
|
|
protocol.RunCapabilityRemoteRunDBSQLiteQuery,
|
|
protocol.RunCapabilityRemoteRunLogsTransfer,
|
|
protocol.RunCapabilityRemoteRunRCONCommand,
|
|
}
|
|
}
|
|
|
|
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 isSupportedRemoteCapability(capability string) bool {
|
|
for _, supported := range SupportedRemoteCapabilities() {
|
|
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[:])
|
|
}
|