1264 lines
52 KiB
Go
1264 lines
52 KiB
Go
package runtime
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"browser.local/run/protocol"
|
|
)
|
|
|
|
const (
|
|
lifecycleResultStateSucceeded = "succeeded"
|
|
lifecycleResultStateFailed = "failed"
|
|
lifecycleResultStateCancelled = "cancelled"
|
|
|
|
defaultLifecycleTimeout = 30 * time.Second
|
|
maxLifecycleTimeout = 2 * time.Hour
|
|
maxLifecycleOutputBytes = 4096
|
|
maxLifecycleLogChunk = 64 * 1024
|
|
)
|
|
|
|
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
|
|
managedProcessStateRoot string
|
|
managedProcessOutputRoot string
|
|
supervisor ProcessSupervisor
|
|
managed ManagedProcessSupervisor
|
|
fileExecutor *FileExecutor
|
|
logSink ProcessLogSink
|
|
artifactHook LifecycleArtifactHook
|
|
dependencyRunner ProcessSupervisor
|
|
dependencyDownloader DependencyDownloader
|
|
selfUpdateActivator SelfUpdateActivator
|
|
logCheckpointStore LogCheckpointStore
|
|
runtimeTargetOS string
|
|
runtimeTargetArch string
|
|
runtimeFileWriter runtimeFileWriter
|
|
localStartupDiagnostics bool
|
|
protectedRequests *ProtectedRequestRegistry
|
|
sqliteSchemaProbe *SQLiteSchemaProbeExecutor
|
|
sqliteQuery *SQLiteQueryExecutor
|
|
metricCollector MetricCollector
|
|
}
|
|
|
|
type runtimeFileWriter func(string, []byte, os.FileMode) error
|
|
|
|
type LifecycleExecutionResult struct {
|
|
State string
|
|
Progress protocol.RunJobProgressReport
|
|
ResultRef string
|
|
Message string
|
|
ErrorCode string
|
|
Retryable bool
|
|
ExecutionResult protocol.RunJobExecutionResult
|
|
ActivationManifest string
|
|
}
|
|
|
|
type LifecycleExecutorOption func(*LifecycleExecutor)
|
|
|
|
func NewLifecycleExecutor(options ...LifecycleExecutorOption) LifecycleExecutor {
|
|
executor := LifecycleExecutor{
|
|
workspaceRoot: filepath.Join(".", ".run-workspace"),
|
|
supervisor: OSProcessSupervisor{},
|
|
logSink: NoopProcessLogSink{},
|
|
artifactHook: StaticLifecycleArtifactHook{},
|
|
dependencyRunner: OSProcessSupervisor{},
|
|
dependencyDownloader: HTTPDependencyDownloader{},
|
|
selfUpdateActivator: ProcessSelfUpdateActivator{},
|
|
logCheckpointStore: NewMemoryLogCheckpointStore(),
|
|
runtimeTargetOS: runtime.GOOS,
|
|
runtimeTargetArch: runtime.GOARCH,
|
|
runtimeFileWriter: writeRuntimeAtomicFile,
|
|
protectedRequests: NewProtectedRequestRegistry(),
|
|
}
|
|
for _, option := range options {
|
|
option(&executor)
|
|
}
|
|
if executor.managedProcessStateRoot == "" {
|
|
executor.managedProcessStateRoot = executor.workspaceRoot
|
|
}
|
|
if executor.managedProcessOutputRoot == "" {
|
|
executor.managedProcessOutputRoot = executor.workspaceRoot
|
|
}
|
|
if executor.managed == nil {
|
|
if managed, err := NewOSManagedProcessSupervisorWithOutputRoot(executor.managedProcessStateRoot, executor.managedProcessOutputRoot); err == nil {
|
|
executor.managed = managed
|
|
}
|
|
}
|
|
if files, err := NewFileExecutor(executor.workspaceRoot); err == nil {
|
|
executor.fileExecutor = files
|
|
}
|
|
executor.sqliteSchemaProbe = NewSQLiteSchemaProbeExecutor(executor.workspaceRoot)
|
|
executor.sqliteQuery = NewSQLiteQueryExecutor(executor.workspaceRoot)
|
|
return executor
|
|
}
|
|
|
|
func (executor LifecycleExecutor) ExecuteLogBackfill(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
|
|
if assignment.ExecutionInput.LogSource == nil {
|
|
return ExecuteLogBackfillJob(ctx, assignment)
|
|
}
|
|
if err := protocol.ValidateRunJobAssignment(assignment); err != nil {
|
|
return lifecycleFailure("unsafe_log_backfill_job", err.Error())
|
|
}
|
|
source := RuntimeLogSource{
|
|
Key: assignment.ExecutionInput.LogSource.Key,
|
|
Kind: assignment.ExecutionInput.LogSource.Kind,
|
|
TargetKey: assignment.ExecutionInput.LogSource.TargetKey,
|
|
StreamKey: assignment.ExecutionInput.LogSource.StreamKey,
|
|
CursorKind: assignment.ExecutionInput.LogSource.CursorKind,
|
|
RetentionDays: assignment.ExecutionInput.LogSource.RetentionDays,
|
|
}
|
|
return TailDeclaredFileLogSource(ctx, executor.workspaceRoot, assignment, source, executor.logSink, executor.logCheckpointStore)
|
|
}
|
|
|
|
func (executor LifecycleExecutor) writeRuntimeFile(path string, body []byte, mode os.FileMode) error {
|
|
writer := executor.runtimeFileWriter
|
|
if writer == nil {
|
|
writer = writeRuntimeAtomicFile
|
|
}
|
|
return writer(path, body, mode)
|
|
}
|
|
|
|
func WithLifecycleWorkspaceRoot(root string) LifecycleExecutorOption {
|
|
return func(executor *LifecycleExecutor) {
|
|
if strings.TrimSpace(root) != "" {
|
|
executor.workspaceRoot = root
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithManagedProcessStateRoot(root string) LifecycleExecutorOption {
|
|
return func(executor *LifecycleExecutor) {
|
|
if strings.TrimSpace(root) != "" {
|
|
executor.managedProcessStateRoot = root
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithManagedProcessOutputRoot(root string) LifecycleExecutorOption {
|
|
return func(executor *LifecycleExecutor) {
|
|
if strings.TrimSpace(root) != "" {
|
|
executor.managedProcessOutputRoot = root
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithLocalStartupDiagnostics(enabled bool) LifecycleExecutorOption {
|
|
return func(executor *LifecycleExecutor) { executor.localStartupDiagnostics = enabled }
|
|
}
|
|
|
|
// WithProtectedRequestRegistry supplies Run-owned handlers for logical
|
|
// transports. It does not expose handler configuration through any protocol.
|
|
func WithProtectedRequestRegistry(registry *ProtectedRequestRegistry) LifecycleExecutorOption {
|
|
return func(executor *LifecycleExecutor) {
|
|
if registry != nil {
|
|
executor.protectedRequests = registry
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithProcessSupervisor(supervisor ProcessSupervisor) LifecycleExecutorOption {
|
|
return func(executor *LifecycleExecutor) {
|
|
if supervisor != nil {
|
|
executor.supervisor = supervisor
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithManagedProcessSupervisor(supervisor ManagedProcessSupervisor) LifecycleExecutorOption {
|
|
return func(executor *LifecycleExecutor) {
|
|
if supervisor != nil {
|
|
executor.managed = 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 WithDependencyCommandRunner(runner ProcessSupervisor) LifecycleExecutorOption {
|
|
return func(executor *LifecycleExecutor) {
|
|
if runner != nil {
|
|
executor.dependencyRunner = runner
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithDependencyDownloader(downloader DependencyDownloader) LifecycleExecutorOption {
|
|
return func(executor *LifecycleExecutor) {
|
|
if downloader != nil {
|
|
executor.dependencyDownloader = downloader
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithSelfUpdateActivator(activator SelfUpdateActivator) LifecycleExecutorOption {
|
|
return func(executor *LifecycleExecutor) {
|
|
if activator != nil {
|
|
executor.selfUpdateActivator = activator
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithDLLExtensionRuntimeTarget is primarily useful for exercising the
|
|
// Windows-only extension path in cross-platform tests. Production workers use
|
|
// the current Go runtime target.
|
|
func WithDLLExtensionRuntimeTarget(targetOS string, targetArch string) LifecycleExecutorOption {
|
|
return func(executor *LifecycleExecutor) {
|
|
if strings.TrimSpace(targetOS) != "" {
|
|
executor.runtimeTargetOS = strings.ToLower(strings.TrimSpace(targetOS))
|
|
}
|
|
if strings.TrimSpace(targetArch) != "" {
|
|
executor.runtimeTargetArch = strings.ToLower(strings.TrimSpace(targetArch))
|
|
}
|
|
}
|
|
}
|
|
|
|
func SupportedLifecycleCapabilities() []string {
|
|
return []string{
|
|
protocol.RunCapabilityProcessInstall,
|
|
protocol.RunCapabilityProcessStart,
|
|
protocol.RunCapabilityProcessStop,
|
|
protocol.RunCapabilityProcessStatus,
|
|
}
|
|
}
|
|
|
|
func SupportedFileCapabilities() []string {
|
|
return []string{protocol.RunCapabilityConfigWrite, protocol.RunCapabilityFilesList, protocol.RunCapabilityFilesRead, protocol.RunCapabilityFilesWrite}
|
|
}
|
|
|
|
func SupportedRunCapabilities() []string {
|
|
return SupportedRunCapabilitiesForComponent("")
|
|
}
|
|
|
|
// SupportedRunCapabilitiesForComponent keeps a generic worker capable of
|
|
// building distributions while ensuring a generated server Run cannot claim
|
|
// a shared build-worker endpoint after it is deployed.
|
|
func SupportedRunCapabilitiesForComponent(componentKind string) []string {
|
|
capabilities := append([]string(nil), SupportedLifecycleCapabilities()...)
|
|
capabilities = append(capabilities, SupportedFileCapabilities()...)
|
|
capabilities = append(capabilities, protocol.RunCapabilityLogsRead)
|
|
capabilities = append(capabilities, protocol.RunCapabilityDeploymentPlan)
|
|
for _, capability := range SupportedDistributionCapabilities() {
|
|
if componentKind == "run" && capability == protocol.RunCapabilityDistributionBuild {
|
|
continue
|
|
}
|
|
capabilities = append(capabilities, capability)
|
|
}
|
|
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.RunCapabilityRemoteRunDBSQLiteProbe,
|
|
protocol.RunCapabilityRemoteRunDBSQLiteQuery,
|
|
protocol.RunCapabilityRemoteRunLogsTransfer,
|
|
protocol.RunCapabilityRemoteRunRCONCommand,
|
|
protocol.RunCapabilityRemoteRunProtectedSQL,
|
|
protocol.RunCapabilityRemoteRunProtectedRCON,
|
|
protocol.RunCapabilityRemoteRunProgram,
|
|
}
|
|
}
|
|
|
|
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 {
|
|
log.Printf("RUN phase=lifecycle status=starting job=%s capability=%s target=%s server=%s workspaceScope=%s", assignment.JobID, assignment.Capability, safeOptional(assignment.TargetKey), assignment.ServerInstanceID, safeOptional(assignment.ExecutionInput.WorkspaceScope))
|
|
if (assignment.Capability == protocol.RunCapabilityConfigWrite || assignment.Capability == protocol.RunCapabilityFilesList || assignment.Capability == protocol.RunCapabilityFilesRead || assignment.Capability == protocol.RunCapabilityFilesWrite) && assignment.ExecutionInput.WorkspaceScope != "" {
|
|
if executor.fileExecutor == nil {
|
|
log.Printf("RUN phase=lifecycle status=file_executor_unavailable job=%s", assignment.JobID)
|
|
return lifecycleFailure("file_executor_unavailable", "file executor is unavailable")
|
|
}
|
|
log.Printf("RUN phase=lifecycle status=file_executor_start job=%s capability=%s", assignment.JobID, assignment.Capability)
|
|
return executor.fileExecutor.Execute(ctx, assignment)
|
|
}
|
|
if !isSupportedLifecycleCapability(assignment.Capability) {
|
|
log.Printf("RUN phase=lifecycle status=unsupported_capability job=%s capability=%s", assignment.JobID, assignment.Capability)
|
|
return lifecycleFailure("unsupported_lifecycle_capability", "unsupported lifecycle capability")
|
|
}
|
|
if len(assignment.ExecutionInput.DLLExtensions) > 0 {
|
|
log.Printf("RUN phase=lifecycle.dll status=validating job=%s extensions=%d", assignment.JobID, len(assignment.ExecutionInput.DLLExtensions))
|
|
if err := protocol.ValidateRunJobAssignment(assignment); err != nil {
|
|
log.Printf("RUN phase=lifecycle.dll status=invalid job=%s error=%s", assignment.JobID, err.Error())
|
|
return lifecycleFailure("unsafe_dll_extension_plan", "DLL extension plan is invalid")
|
|
}
|
|
}
|
|
if assignment.ExecutionInput.ServerDeploymentPlan != nil {
|
|
log.Printf("RUN phase=lifecycle status=legacy_deployment_rejected job=%s", assignment.JobID)
|
|
return lifecycleFailure("unsupported_legacy_deployment_plan", "game-specific deployment plans must be implemented by plugin lifecycle actions")
|
|
}
|
|
if assignment.ExecutionInput.Deployment != nil && assignment.ExecutionInput.Deployment.Mode == "custom-command" {
|
|
log.Printf("RUN phase=lifecycle status=custom_deployment job=%s", assignment.JobID)
|
|
return executor.executeDeployment(ctx, assignment)
|
|
}
|
|
log.Printf("RUN phase=lifecycle.template status=loading job=%s target=%s", assignment.JobID, safeOptional(assignment.TargetKey))
|
|
template, scope, err := executor.loadLifecycleTemplate(assignment)
|
|
if err != nil {
|
|
log.Printf("RUN phase=lifecycle.template status=failed job=%s error=%s", assignment.JobID, err.Error())
|
|
return lifecycleFailure("unsafe_lifecycle_command", err.Error())
|
|
}
|
|
log.Printf("RUN phase=lifecycle.template status=loaded job=%s action=%s mode=%s scope=%s commandArgs=%d envKeys=%s", assignment.JobID, safeOptional(template.Action), safeOptional(template.Mode), scope, len(template.Command)+len(template.Arguments), envKeysSummary(template.Env, template.Environment))
|
|
if template.Action != "" {
|
|
expectedAction := map[string]string{protocol.RunCapabilityProcessInstall: "install", protocol.RunCapabilityProcessStart: "start", protocol.RunCapabilityProcessStop: "stop", protocol.RunCapabilityProcessStatus: "status"}[assignment.Capability]
|
|
if template.Action != expectedAction {
|
|
log.Printf("RUN phase=lifecycle.template status=action_mismatch job=%s expected=%s actual=%s", assignment.JobID, expectedAction, template.Action)
|
|
return lifecycleFailure("unsafe_lifecycle_command", "typed lifecycle action does not match capability")
|
|
}
|
|
if assignment.Capability == protocol.RunCapabilityProcessStart && template.Mode != "supervised" {
|
|
log.Printf("RUN phase=lifecycle.template status=mode_mismatch job=%s expected=supervised actual=%s", assignment.JobID, template.Mode)
|
|
return lifecycleFailure("unsafe_lifecycle_command", "typed start action must be supervised")
|
|
}
|
|
if (assignment.Capability == protocol.RunCapabilityProcessStop || assignment.Capability == protocol.RunCapabilityProcessStatus) && template.Mode != "control" {
|
|
log.Printf("RUN phase=lifecycle.template status=mode_mismatch job=%s expected=control actual=%s", assignment.JobID, template.Mode)
|
|
return lifecycleFailure("unsafe_lifecycle_command", "typed control action must use control mode")
|
|
}
|
|
}
|
|
if assignment.Capability == protocol.RunCapabilityProcessStart && len(assignment.ExecutionInput.DLLExtensions) > 0 {
|
|
log.Printf("RUN phase=lifecycle.dll status=synchronizing job=%s extensions=%d", assignment.JobID, len(assignment.ExecutionInput.DLLExtensions))
|
|
if err := executor.synchronizeUE4SSDLLExtensions(ctx, assignment, template, scope); err != nil {
|
|
log.Printf("RUN phase=lifecycle.dll status=failed job=%s error=%s", assignment.JobID, err.Error())
|
|
return dllExtensionLifecycleFailure(err)
|
|
}
|
|
log.Printf("RUN phase=lifecycle.dll status=complete job=%s", assignment.JobID)
|
|
}
|
|
if executor.managed != nil && template.Action != "" && (assignment.Capability == protocol.RunCapabilityProcessStart || assignment.Capability == protocol.RunCapabilityProcessStop || assignment.Capability == protocol.RunCapabilityProcessStatus) {
|
|
log.Printf("RUN phase=lifecycle.managed status=dispatch job=%s action=%s mode=%s", assignment.JobID, template.Action, template.Mode)
|
|
return executor.executeManaged(ctx, assignment, template, scope)
|
|
}
|
|
log.Printf("RUN phase=lifecycle.command status=building job=%s", assignment.JobID)
|
|
command, err := template.ToProcessCommand(scope, NewWorkspaceResolver(executor.workspaceRoot), assignment)
|
|
if err != nil {
|
|
log.Printf("RUN phase=lifecycle.command status=build_failed job=%s error=%s", assignment.JobID, err.Error())
|
|
return lifecycleFailure("unsafe_lifecycle_command", err.Error())
|
|
}
|
|
log.Printf("RUN phase=lifecycle.command status=starting job=%s workdir=%s command=%s timeoutMs=%d envKeys=%s", assignment.JobID, safeOptional(command.WorkDir), quotedCommandLine(command.Args), command.Timeout.Milliseconds(), envKeysSummary(command.Env, nil))
|
|
command.OutputLine = func(stream string, line string) {
|
|
_ = executor.logSink.Append(ctx, assignment, stream, line)
|
|
}
|
|
result, err := executor.supervisor.Run(ctx, command)
|
|
if err != nil && ctx.Err() != nil {
|
|
log.Printf("RUN phase=lifecycle.command status=cancelled job=%s error=%s", assignment.JobID, ctx.Err().Error())
|
|
return LifecycleExecutionResult{
|
|
State: lifecycleResultStateCancelled,
|
|
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "lifecycle action cancelled"},
|
|
Message: "lifecycle action cancelled",
|
|
ErrorCode: "lifecycle_cancelled",
|
|
}
|
|
}
|
|
if !result.OutputRelayed {
|
|
executor.writeProcessLogs(ctx, assignment, result)
|
|
}
|
|
if err != nil {
|
|
log.Printf("RUN phase=lifecycle.command status=failed job=%s exitCode=%d error=%s", assignment.JobID, result.ExitCode, err.Error())
|
|
return lifecycleFailure("lifecycle_process_failed", err.Error())
|
|
}
|
|
if result.ExitCode != 0 {
|
|
log.Printf("RUN phase=lifecycle.command status=failed job=%s exitCode=%d", assignment.JobID, result.ExitCode)
|
|
return lifecycleFailure("lifecycle_process_failed", fmt.Sprintf("lifecycle command exited with code %d", result.ExitCode))
|
|
}
|
|
log.Printf("RUN phase=lifecycle.command status=exited job=%s exitCode=%d stdoutBytes=%d stderrBytes=%d", assignment.JobID, result.ExitCode, len(result.Stdout), len(result.Stderr))
|
|
artifactRef, err := executor.artifactHook.QueueLifecycleResult(ctx, assignment, result)
|
|
if err != nil {
|
|
log.Printf("RUN phase=lifecycle.artifact status=failed job=%s error=%s", assignment.JobID, err.Error())
|
|
return lifecycleFailure("lifecycle_artifact_hook_failed", err.Error())
|
|
}
|
|
log.Printf("RUN phase=lifecycle status=succeeded job=%s resultRef=%s", assignment.JobID, safeOptional(artifactRef))
|
|
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) executeDeployment(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
|
|
definition := assignment.ExecutionInput.Deployment
|
|
log.Printf("RUN phase=deployment status=starting job=%s mode=%s revision=%d", assignment.JobID, definition.Mode, definition.Revision)
|
|
if definition.SchemaVersion != "1" || definition.Revision < 1 || definition.Mode == "" {
|
|
log.Printf("RUN phase=deployment status=invalid job=%s", assignment.JobID)
|
|
return lifecycleFailure("invalid_deployment_definition", "deployment definition is invalid")
|
|
}
|
|
action := map[string]string{protocol.RunCapabilityProcessInstall: "install", protocol.RunCapabilityProcessStart: "start", protocol.RunCapabilityProcessStop: "stop", protocol.RunCapabilityProcessStatus: "status"}[assignment.Capability]
|
|
if action == "" {
|
|
log.Printf("RUN phase=deployment status=invalid_action job=%s capability=%s", assignment.JobID, assignment.Capability)
|
|
return lifecycleFailure("invalid_deployment_action", "deployment action is invalid")
|
|
}
|
|
commandText := map[string]string{"start": definition.StartCommand, "stop": definition.StopCommand, "status": definition.StatusCommand}[action]
|
|
if action == "install" {
|
|
commandText = definition.StartCommand
|
|
}
|
|
if commandText == "" {
|
|
log.Printf("RUN phase=deployment status=missing_command job=%s action=%s", assignment.JobID, action)
|
|
return lifecycleFailure("deployment_command_missing", "deployment command is not configured")
|
|
}
|
|
workdir := definition.WorkingDirectory
|
|
if workdir == "" {
|
|
workdir = definition.ServerRoot
|
|
}
|
|
if workdir == "" {
|
|
log.Printf("RUN phase=deployment status=missing_workdir job=%s action=%s", assignment.JobID, action)
|
|
return lifecycleFailure("deployment_workdir_missing", "deployment working directory is not configured")
|
|
}
|
|
args := strings.Fields(commandText)
|
|
if len(args) == 0 {
|
|
log.Printf("RUN phase=deployment status=invalid_command job=%s", assignment.JobID)
|
|
return lifecycleFailure("deployment_command_invalid", "deployment command is invalid")
|
|
}
|
|
if definition.Shell != "" {
|
|
log.Printf("RUN phase=deployment status=unsupported_shell job=%s shell=%s", assignment.JobID, definition.Shell)
|
|
return lifecycleFailure("deployment_shell_unsupported", "deployment shell is not supported by this Run")
|
|
}
|
|
command := ProcessCommand{Args: args, WorkDir: workdir, JobID: assignment.JobID, Capability: assignment.Capability, Action: action}
|
|
log.Printf("RUN phase=deployment.command status=starting job=%s action=%s revision=%d root=%s workdir=%s command=%s", assignment.JobID, action, definition.Revision, safeOptional(definition.ServerRoot), safeOptional(workdir), quotedCommandLine(command.Args))
|
|
result, err := executor.supervisor.Run(ctx, command)
|
|
if err != nil {
|
|
log.Printf("RUN phase=deployment.command status=failed job=%s exitCode=%d error=%s", assignment.JobID, result.ExitCode, err.Error())
|
|
return lifecycleFailure("lifecycle_process_failed", err.Error())
|
|
}
|
|
executor.writeProcessLogs(ctx, assignment, result)
|
|
if result.ExitCode != 0 {
|
|
log.Printf("RUN phase=deployment.command status=failed job=%s exitCode=%d", assignment.JobID, result.ExitCode)
|
|
return lifecycleFailure("lifecycle_process_failed", fmt.Sprintf("lifecycle command exited with code %d", result.ExitCode))
|
|
}
|
|
log.Printf("RUN phase=deployment.command status=exited job=%s exitCode=%d stdoutBytes=%d stderrBytes=%d", assignment.JobID, result.ExitCode, len(result.Stdout), len(result.Stderr))
|
|
receipt := &protocol.ServerDeploymentExecutionReceipt{SchemaVersion: "1", Revision: definition.Revision, Action: action, Mode: definition.Mode, Shell: definition.Shell, UsedServerRoot: definition.ServerRoot != ""}
|
|
log.Printf("RUN phase=deployment status=succeeded job=%s action=%s revision=%d", assignment.JobID, action, definition.Revision)
|
|
return LifecycleExecutionResult{State: lifecycleResultStateSucceeded, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "deployment lifecycle action completed"}, Message: fmt.Sprintf("%s completed", assignment.Capability), ExecutionResult: protocol.RunJobExecutionResult{Kind: "deployment.lifecycle", Summary: "deployment revision confirmed", DeploymentReceipt: receipt}}
|
|
}
|
|
|
|
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, NewWorkspaceResolver(executor.workspaceRoot), assignment)
|
|
}
|
|
|
|
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 splitRawLogLines(item.body) {
|
|
_ = executor.logSink.Append(ctx, assignment, item.stream, line)
|
|
}
|
|
}
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
func (executor LifecycleExecutor) loadLifecycleTemplate(assignment protocol.RunJobAssignment) (LifecycleActionTemplate, string, error) {
|
|
scope, err := executor.lifecycleScope(assignment)
|
|
if err != nil {
|
|
return LifecycleActionTemplate{}, "", err
|
|
}
|
|
template := LifecycleActionTemplate{Command: []string{"true"}, TimeoutMS: int(defaultLifecycleTimeout / time.Millisecond)}
|
|
if assignment.TargetKey == "" {
|
|
if err := os.MkdirAll(scope, 0o755); err != nil {
|
|
return LifecycleActionTemplate{}, "", fmt.Errorf("create lifecycle workspace: %w", err)
|
|
}
|
|
return template, scope, nil
|
|
}
|
|
path, err := NewWorkspaceResolver(executor.workspaceRoot).ExistingTarget(scope, assignment.TargetKey)
|
|
if err != nil {
|
|
return LifecycleActionTemplate{}, "", fmt.Errorf("open lifecycle action template: %w", err)
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return LifecycleActionTemplate{}, "", fmt.Errorf("open lifecycle action template: %w", err)
|
|
}
|
|
defer file.Close()
|
|
decoder := json.NewDecoder(io.LimitReader(file, 16*1024))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&template); err != nil {
|
|
return LifecycleActionTemplate{}, "", fmt.Errorf("decode lifecycle action template: %w", err)
|
|
}
|
|
return template, scope, nil
|
|
}
|
|
|
|
func (executor LifecycleExecutor) lifecycleScope(assignment protocol.RunJobAssignment) (string, error) {
|
|
if assignment.ExecutionInput.WorkspaceScope != "" {
|
|
return NewWorkspaceResolver(executor.workspaceRoot).Scope(assignment.ServerInstanceID, assignment.ExecutionInput.WorkspaceScope)
|
|
}
|
|
return scopedServerWorkspace(executor.workspaceRoot, assignment.ServerInstanceID)
|
|
}
|
|
|
|
func (executor LifecycleExecutor) executeManaged(ctx context.Context, assignment protocol.RunJobAssignment, template LifecycleActionTemplate, scope string) LifecycleExecutionResult {
|
|
resolver := NewWorkspaceResolver(executor.workspaceRoot)
|
|
identity := ProcessIdentity{Scope: scope, ServerInstanceID: assignment.ServerInstanceID, RunEndpointID: assignment.RunEndpointID, JobID: assignment.JobID, Capability: assignment.Capability, ProfileKey: assignment.ExecutionInput.WorkspaceScope, Attempt: assignment.Attempt, StdoutStreamKey: processLogStreamKey(assignment, "process.stdout", "stdout"), StderrStreamKey: processLogStreamKey(assignment, "process.stderr", "stderr")}
|
|
if assignment.Capability == protocol.RunCapabilityProcessStart {
|
|
log.Printf("RUN phase=lifecycle.managed status=building_start_command job=%s scope=%s", assignment.JobID, scope)
|
|
command, err := template.ToManagedProcessCommand(resolver, scope, assignment)
|
|
if err != nil {
|
|
log.Printf("RUN phase=lifecycle.managed status=build_failed job=%s error=%s", assignment.JobID, err.Error())
|
|
return lifecycleFailure("unsafe_lifecycle_command", err.Error())
|
|
}
|
|
log.Printf("RUN phase=lifecycle.managed status=starting job=%s workdir=%s command=%s timeoutMs=%d envKeys=%s", assignment.JobID, safeOptional(command.WorkDir), quotedCommandLine(command.Args), command.Timeout.Milliseconds(), envKeysSummary(command.Env, nil))
|
|
item, err := executor.managed.Start(ctx, command, identity, executor.managedProcessOutput(ctx, assignment))
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
log.Printf("RUN phase=lifecycle.managed status=cancelled job=%s error=%s", assignment.JobID, ctx.Err().Error())
|
|
return lifecycleExecutionFailure("lifecycle_cancelled", "lifecycle action cancelled", false)
|
|
}
|
|
log.Printf("RUN phase=lifecycle.managed status=failed job=%s error=%s", assignment.JobID, err.Error())
|
|
return lifecycleFailure("lifecycle_process_failed", err.Error())
|
|
}
|
|
log.Printf("RUN phase=lifecycle.managed status=started job=%s pid=%d state=%s stdoutRef=%s stderrRef=%s", assignment.JobID, item.PID, item.State, safeOptional(item.StdoutLogRef), safeOptional(item.StderrLogRef))
|
|
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")
|
|
}
|
|
log.Printf("RUN phase=lifecycle.managed status=querying job=%s scope=%s", assignment.JobID, scope)
|
|
item := executor.managed.Status(identity)
|
|
log.Printf("RUN phase=lifecycle.managed status=queried job=%s pid=%d state=%s classification=%s", assignment.JobID, item.PID, item.State, safeOptional(item.ExitClassification))
|
|
return processExecutionResult(item, "process status queried")
|
|
}
|
|
|
|
func (executor LifecycleExecutor) ResumeManagedProcessLogs(ctx context.Context) {
|
|
if executor.managed == nil {
|
|
return
|
|
}
|
|
executor.managed.ResumeOutput(executor.managedProcessOutput(ctx, protocol.RunJobAssignment{}))
|
|
}
|
|
|
|
func (executor LifecycleExecutor) managedProcessOutput(ctx context.Context, assignment protocol.RunJobAssignment) ManagedProcessOutput {
|
|
return ManagedProcessOutput{
|
|
Stdout: func(identity ProcessIdentity, line ManagedProcessLine) error {
|
|
return executor.appendManagedProcessLog(ctx, assignment, identity, "stdout", line)
|
|
},
|
|
Stderr: func(identity ProcessIdentity, line ManagedProcessLine) error {
|
|
return executor.appendManagedProcessLog(ctx, assignment, identity, "stderr", line)
|
|
},
|
|
}
|
|
}
|
|
|
|
func (executor LifecycleExecutor) appendManagedProcessLog(ctx context.Context, assignment protocol.RunJobAssignment, identity ProcessIdentity, stream string, line ManagedProcessLine) error {
|
|
if executor.logSink == nil {
|
|
return nil
|
|
}
|
|
if assignment.JobID == "" {
|
|
assignment = assignmentFromProcessIdentity(identity)
|
|
}
|
|
assignment.LogSessionID = identity.LogSessionID
|
|
assignment.SessionStartedAt = identity.StartedAt
|
|
if assignment.JobID == "" || assignment.ServerInstanceID == "" {
|
|
return nil
|
|
}
|
|
if sink, ok := executor.logSink.(ProcessLogCursorSink); ok {
|
|
return sink.AppendWithCursor(ctx, assignment, stream, line.Text, ProcessLogCursor{StartOffset: line.StartOffset, EndOffset: line.EndOffset})
|
|
}
|
|
return executor.logSink.Append(ctx, assignment, stream, line.Text)
|
|
}
|
|
|
|
func assignmentFromProcessIdentity(identity ProcessIdentity) protocol.RunJobAssignment {
|
|
assignment := protocol.RunJobAssignment{
|
|
JobID: identity.JobID,
|
|
ServerInstanceID: identity.ServerInstanceID,
|
|
RunEndpointID: identity.RunEndpointID,
|
|
Capability: identity.Capability,
|
|
Attempt: identity.Attempt,
|
|
LogSessionID: identity.LogSessionID,
|
|
SessionStartedAt: identity.StartedAt,
|
|
}
|
|
if identity.StdoutStreamKey != "" {
|
|
assignment.ExecutionInput.LogSources = append(assignment.ExecutionInput.LogSources, protocol.RuntimeLogSourcePlan{Key: "process-stdout", Kind: "process.stdout", StreamKey: identity.StdoutStreamKey, CursorKind: "sequence"})
|
|
}
|
|
if identity.StderrStreamKey != "" {
|
|
assignment.ExecutionInput.LogSources = append(assignment.ExecutionInput.LogSources, protocol.RuntimeLogSourcePlan{Key: "process-stderr", Kind: "process.stderr", StreamKey: identity.StderrStreamKey, CursorKind: "sequence"})
|
|
}
|
|
return assignment
|
|
}
|
|
|
|
func processLogStreamKey(assignment protocol.RunJobAssignment, kind string, fallback string) string {
|
|
for _, source := range assignment.ExecutionInput.LogSources {
|
|
if source.Kind == kind && strings.TrimSpace(source.StreamKey) != "" {
|
|
return source.StreamKey
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func processExecutionResult(item ProcessIdentity, message string) LifecycleExecutionResult {
|
|
state := item.State
|
|
if state == "" {
|
|
state = "stopped"
|
|
}
|
|
return LifecycleExecutionResult{State: lifecycleResultStateSucceeded, Progress: protocol.RunJobProgressReport{Percent: 100, Message: message}, Message: message, ExecutionResult: protocol.RunJobExecutionResult{Kind: "process", ProcessState: state, ExitClassification: item.ExitClassification, ExitCode: item.ExitCode, Summary: "private supervised process identity"}}
|
|
}
|
|
|
|
func lifecycleExecutionFailure(code string, message string, retryable bool) LifecycleExecutionResult {
|
|
return LifecycleExecutionResult{State: lifecycleResultStateFailed, Progress: protocol.RunJobProgressReport{Percent: 100, Message: message}, Message: message, ErrorCode: code, Retryable: retryable, ExecutionResult: protocol.RunJobExecutionResult{Kind: "file", Summary: code}}
|
|
}
|
|
|
|
func (template LifecycleActionTemplate) ToProcessCommand(workdir string, resolver WorkspaceResolver, assignment protocol.RunJobAssignment) (ProcessCommand, error) {
|
|
if template.ExecutableKey != "" {
|
|
return template.ToExecutableProcessCommand(resolver, workdir, assignment)
|
|
}
|
|
args := append([]string(nil), template.Command...)
|
|
if len(args) == 0 {
|
|
return ProcessCommand{}, fmt.Errorf("command is required")
|
|
}
|
|
for i, part := range args {
|
|
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, err := template.executionEnvironment(assignment)
|
|
if err != nil {
|
|
return ProcessCommand{}, err
|
|
}
|
|
timeout := defaultLifecycleTimeout
|
|
if template.TimeoutMS > 0 {
|
|
timeout = time.Duration(template.TimeoutMS) * time.Millisecond
|
|
}
|
|
if timeout > maxLifecycleTimeout {
|
|
return ProcessCommand{}, fmt.Errorf("timeout is too large")
|
|
}
|
|
return ProcessCommand{WorkDir: workdir, Args: args, Env: env, Timeout: timeout, JobID: assignment.JobID, Capability: assignment.Capability, Action: template.Action}, nil
|
|
}
|
|
|
|
func (template LifecycleActionTemplate) ToExecutableProcessCommand(resolver WorkspaceResolver, scope string, assignment protocol.RunJobAssignment) (ProcessCommand, error) {
|
|
if template.ExecutableKey == "" {
|
|
return ProcessCommand{}, fmt.Errorf("typed executableKey is required")
|
|
}
|
|
executable, err := resolver.ExistingTarget(scope, template.ExecutableKey)
|
|
if err != nil {
|
|
return ProcessCommand{}, err
|
|
}
|
|
info, err := os.Stat(executable)
|
|
if err != nil || !info.Mode().IsRegular() || runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
|
|
return ProcessCommand{}, fmt.Errorf("typed executable is not executable")
|
|
}
|
|
args := append([]string{executable}, template.Arguments...)
|
|
for _, part := range args[1:] {
|
|
if strings.TrimSpace(part) == "" || containsUnsafeRuntimeText(part) || strings.ContainsAny(part, "|;&`$<>") {
|
|
return ProcessCommand{}, fmt.Errorf("typed argument is unsafe")
|
|
}
|
|
}
|
|
if template.OutputMode != "" && template.OutputMode != "pipes" && template.OutputMode != "console" {
|
|
return ProcessCommand{}, fmt.Errorf("typed output mode is unsupported")
|
|
}
|
|
env, err := template.executionEnvironment(assignment)
|
|
if err != nil {
|
|
return ProcessCommand{}, err
|
|
}
|
|
timeout := defaultLifecycleTimeout
|
|
if template.TimeoutMS > 0 {
|
|
timeout = time.Duration(template.TimeoutMS) * time.Millisecond
|
|
}
|
|
if timeout > maxLifecycleTimeout {
|
|
return ProcessCommand{}, fmt.Errorf("timeout is too large")
|
|
}
|
|
args = managedExecutableArgs(runtime.GOOS, args)
|
|
return ProcessCommand{WorkDir: scope, Args: args, Env: env, OutputMode: template.OutputMode, Timeout: timeout, JobID: assignment.JobID, Capability: assignment.Capability, Action: template.Action}, nil
|
|
}
|
|
|
|
func managedExecutableArgs(targetOS string, args []string) []string {
|
|
if targetOS != "windows" || len(args) == 0 {
|
|
return args
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(args[0]))
|
|
if ext != ".cmd" && ext != ".bat" {
|
|
return args
|
|
}
|
|
// Keep the batch path as a normal /c argument. Passing a pre-quoted
|
|
// command string here makes ComposeCommandLine escape the inner quotes
|
|
// for CreateProcess; cmd.exe does not treat those backslashes as quote
|
|
// escapes and consequently fails before it can run the script.
|
|
return append([]string{"cmd.exe", "/d", "/c", "call"}, args...)
|
|
}
|
|
|
|
func quoteWindowsCommandArg(value string) string {
|
|
if value != "" && !strings.ContainsAny(value, " \t\"") {
|
|
return value
|
|
}
|
|
var builder strings.Builder
|
|
builder.WriteByte('"')
|
|
backslashes := 0
|
|
for _, char := range value {
|
|
if char == '\\' {
|
|
backslashes++
|
|
continue
|
|
}
|
|
if char == '"' {
|
|
builder.WriteString(strings.Repeat("\\", backslashes*2+1))
|
|
builder.WriteRune(char)
|
|
backslashes = 0
|
|
continue
|
|
}
|
|
if backslashes > 0 {
|
|
builder.WriteString(strings.Repeat("\\", backslashes))
|
|
backslashes = 0
|
|
}
|
|
builder.WriteRune(char)
|
|
}
|
|
if backslashes > 0 {
|
|
builder.WriteString(strings.Repeat("\\", backslashes*2))
|
|
}
|
|
builder.WriteByte('"')
|
|
return builder.String()
|
|
}
|
|
|
|
func (template LifecycleActionTemplate) ToManagedProcessCommand(resolver WorkspaceResolver, scope string, assignment protocol.RunJobAssignment) (ProcessCommand, error) {
|
|
command, err := template.ToExecutableProcessCommand(resolver, scope, assignment)
|
|
if err != nil {
|
|
return ProcessCommand{}, err
|
|
}
|
|
return command, nil
|
|
}
|
|
|
|
func (template LifecycleActionTemplate) executionEnvironment(assignment protocol.RunJobAssignment) (map[string]string, error) {
|
|
env := template.Environment
|
|
if env == nil {
|
|
env = template.Env
|
|
}
|
|
validated := make(map[string]string, len(env)+16)
|
|
for key, value := range env {
|
|
if !envNamePattern.MatchString(key) || (!strings.HasPrefix(key, "GAME_") && !strings.HasPrefix(key, "SERVER_") && !strings.HasPrefix(key, "RUN_")) || containsUnsafeRuntimeText(value) {
|
|
return nil, fmt.Errorf("typed environment is unsafe")
|
|
}
|
|
validated[key] = value
|
|
}
|
|
add := func(key string, value string) error {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil
|
|
}
|
|
if !envNamePattern.MatchString(key) || (!strings.HasPrefix(key, "GAME_") && !strings.HasPrefix(key, "SERVER_") && !strings.HasPrefix(key, "RUN_")) || containsUnsafeRuntimeText(value) {
|
|
return fmt.Errorf("typed environment is unsafe")
|
|
}
|
|
validated[key] = value
|
|
return nil
|
|
}
|
|
if err := add("SERVER_PLUGIN_ID", assignment.ExecutionInput.PluginID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := add("SERVER_LIFECYCLE_OPERATION", assignment.ExecutionInput.LifecycleOperation); err != nil {
|
|
return nil, err
|
|
}
|
|
if deployment := assignment.ExecutionInput.Deployment; deployment != nil {
|
|
if err := add("SERVER_DEPLOYMENT_MODE", deployment.Mode); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := add("SERVER_PROFILE_KEY", deployment.ProfileKey); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := add("SERVER_ROOT", deployment.ServerRoot); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := add("SERVER_WORKING_DIRECTORY", deployment.WorkingDirectory); err != nil {
|
|
return nil, err
|
|
}
|
|
if deployment.Revision > 0 {
|
|
if err := add("SERVER_REVISION", fmt.Sprint(deployment.Revision)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for key, value := range deployment.CreateInputs {
|
|
suffix := envKeySuffix(key)
|
|
if suffix == "" {
|
|
return nil, fmt.Errorf("typed environment is unsafe")
|
|
}
|
|
if err := add("SERVER_CREATE_"+suffix, value); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
for key, value := range assignment.ExecutionInput.Inputs {
|
|
suffix := envKeySuffix(key)
|
|
if suffix == "" {
|
|
return nil, fmt.Errorf("typed environment is unsafe")
|
|
}
|
|
if err := add("SERVER_INPUT_"+suffix, value); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return validated, nil
|
|
}
|
|
|
|
func envKeySuffix(key string) string {
|
|
var builder strings.Builder
|
|
for _, char := range key {
|
|
switch {
|
|
case char >= 'a' && char <= 'z':
|
|
builder.WriteRune(char - 'a' + 'A')
|
|
case char >= 'A' && char <= 'Z':
|
|
builder.WriteRune(char)
|
|
case char >= '0' && char <= '9':
|
|
builder.WriteRune(char)
|
|
case char == '_' || char == '-' || char == '.' || char == '/':
|
|
builder.WriteByte('_')
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
return strings.Trim(builder.String(), "_")
|
|
}
|
|
|
|
type ProcessCommand struct {
|
|
WorkDir string
|
|
Args []string
|
|
Env map[string]string
|
|
OutputMode string
|
|
Timeout time.Duration
|
|
JobID string
|
|
Capability string
|
|
Action string
|
|
OutputLine func(string, string) `json:"-"`
|
|
}
|
|
|
|
type ProcessResult struct {
|
|
ExitCode int
|
|
Stdout string
|
|
Stderr string
|
|
OutputRelayed bool
|
|
}
|
|
|
|
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 {
|
|
log.Printf("RUN phase=process.command status=missing_executable job=%s capability=%s action=%s", safeOptional(command.JobID), safeOptional(command.Capability), safeOptional(command.Action))
|
|
return ProcessResult{ExitCode: -1}, fmt.Errorf("command is required")
|
|
}
|
|
startedAt := time.Now()
|
|
log.Printf("RUN phase=process.command status=starting job=%s capability=%s action=%s workdir=%s command=%s timeoutMs=%d envKeys=%s", safeOptional(command.JobID), safeOptional(command.Capability), safeOptional(command.Action), safeOptional(command.WorkDir), quotedCommandLine(command.Args), command.Timeout.Milliseconds(), envKeysSummary(command.Env, nil))
|
|
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)
|
|
}
|
|
stdoutLimit := maxLifecycleOutputBytes
|
|
stderrLimit := maxLifecycleOutputBytes
|
|
if command.OutputLine != nil {
|
|
stdoutLimit = 0
|
|
stderrLimit = 0
|
|
}
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
stdoutWriter := newLifecycleOutputWriter(&stdout, stdoutLimit, command, "stdout")
|
|
stderrWriter := newLifecycleOutputWriter(&stderr, stderrLimit, command, "stderr")
|
|
cmd.Stdout = stdoutWriter
|
|
cmd.Stderr = stderrWriter
|
|
if err := cmd.Start(); err != nil {
|
|
log.Printf("RUN phase=process.command status=start_failed job=%s capability=%s action=%s command=%s durationMs=%d error=%s", safeOptional(command.JobID), safeOptional(command.Capability), safeOptional(command.Action), quotedCommandLine(command.Args), time.Since(startedAt).Milliseconds(), err.Error())
|
|
return ProcessResult{ExitCode: -1}, err
|
|
}
|
|
pid := 0
|
|
if cmd.Process != nil {
|
|
pid = cmd.Process.Pid
|
|
}
|
|
log.Printf("RUN phase=process.command status=started job=%s capability=%s action=%s pid=%d command=%s", safeOptional(command.JobID), safeOptional(command.Capability), safeOptional(command.Action), pid, quotedCommandLine(command.Args))
|
|
err := cmd.Wait()
|
|
stdoutWriter.Flush()
|
|
stderrWriter.Flush()
|
|
result := ProcessResult{Stdout: stdout.String(), Stderr: stderr.String(), OutputRelayed: command.OutputLine != nil}
|
|
if cmd.ProcessState != nil {
|
|
result.ExitCode = cmd.ProcessState.ExitCode()
|
|
}
|
|
if err != nil {
|
|
log.Printf("RUN phase=process.command status=failed job=%s capability=%s action=%s pid=%d command=%s exitCode=%d durationMs=%d stdoutBytes=%d stderrBytes=%d error=%s", safeOptional(command.JobID), safeOptional(command.Capability), safeOptional(command.Action), pid, quotedCommandLine(command.Args), result.ExitCode, time.Since(startedAt).Milliseconds(), len(result.Stdout), len(result.Stderr), err.Error())
|
|
return result, err
|
|
}
|
|
log.Printf("RUN phase=process.command status=exited job=%s capability=%s action=%s pid=%d command=%s exitCode=%d durationMs=%d stdoutBytes=%d stderrBytes=%d", safeOptional(command.JobID), safeOptional(command.Capability), safeOptional(command.Action), pid, quotedCommandLine(command.Args), result.ExitCode, time.Since(startedAt).Milliseconds(), len(result.Stdout), len(result.Stderr))
|
|
return result, nil
|
|
}
|
|
|
|
type lifecycleOutputWriter struct {
|
|
mu sync.Mutex
|
|
buffer *bytes.Buffer
|
|
limit int
|
|
command ProcessCommand
|
|
stream string
|
|
pending string
|
|
}
|
|
|
|
func newLifecycleOutputWriter(buffer *bytes.Buffer, limit int, command ProcessCommand, stream string) *lifecycleOutputWriter {
|
|
return &lifecycleOutputWriter{buffer: buffer, limit: limit, command: command, stream: stream}
|
|
}
|
|
|
|
func (writer *lifecycleOutputWriter) Write(p []byte) (int, error) {
|
|
writer.mu.Lock()
|
|
defer writer.mu.Unlock()
|
|
remaining := writer.limit - writer.buffer.Len()
|
|
if remaining > 0 {
|
|
if len(p) > remaining {
|
|
_, _ = writer.buffer.Write(p[:remaining])
|
|
} else {
|
|
_, _ = writer.buffer.Write(p)
|
|
}
|
|
}
|
|
if writer.command.OutputLine == nil {
|
|
return len(p), nil
|
|
}
|
|
writer.pending += string(p)
|
|
for {
|
|
index := strings.IndexByte(writer.pending, '\n')
|
|
if index < 0 {
|
|
break
|
|
}
|
|
line := writer.pending[:index]
|
|
writer.pending = writer.pending[index+1:]
|
|
writer.logLine(line)
|
|
}
|
|
for len(writer.pending) >= maxLifecycleLogChunk {
|
|
writer.logLine(writer.pending[:maxLifecycleLogChunk])
|
|
writer.pending = writer.pending[maxLifecycleLogChunk:]
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (writer *lifecycleOutputWriter) Flush() {
|
|
writer.mu.Lock()
|
|
defer writer.mu.Unlock()
|
|
if writer.pending == "" {
|
|
return
|
|
}
|
|
writer.logLine(writer.pending)
|
|
writer.pending = ""
|
|
}
|
|
|
|
func (writer *lifecycleOutputWriter) logLine(line string) {
|
|
if writer.command.OutputLine != nil {
|
|
writer.command.OutputLine(writer.stream, line)
|
|
}
|
|
}
|
|
|
|
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 ProcessLogCursor struct {
|
|
StartOffset int64
|
|
EndOffset int64
|
|
}
|
|
|
|
type ProcessLogCursorSink interface {
|
|
AppendWithCursor(context.Context, protocol.RunJobAssignment, string, string, ProcessLogCursor) 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,
|
|
Retryable: result.Retryable,
|
|
ExecutionResult: protocol.RunJobExecutionResult{Kind: result.ExecutionResult.Kind, ProcessState: result.ExecutionResult.ProcessState, ExitClassification: result.ExecutionResult.ExitClassification, ExitCode: result.ExecutionResult.ExitCode, Version: result.ExecutionResult.Version, Checksum: result.ExecutionResult.Checksum, SizeBytes: result.ExecutionResult.SizeBytes, Summary: result.ExecutionResult.Summary, Content: result.ExecutionResult.Content, SQLiteSchemaProbe: result.ExecutionResult.SQLiteSchemaProbe, DeploymentReceipt: result.ExecutionResult.DeploymentReceipt, ServerDeploymentEvidence: result.ExecutionResult.ServerDeploymentEvidence},
|
|
}
|
|
}
|
|
|
|
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: message},
|
|
Message: 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 safeOptional(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "-"
|
|
}
|
|
return value
|
|
}
|
|
|
|
func errorSummary(err error) string {
|
|
if err == nil {
|
|
return "-"
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
func quotedCommandLine(args []string) string {
|
|
if len(args) == 0 {
|
|
return "-"
|
|
}
|
|
parts := make([]string, len(args))
|
|
for i, arg := range args {
|
|
parts[i] = strconv.Quote(arg)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func envKeysSummary(first map[string]string, second map[string]string) string {
|
|
seen := map[string]struct{}{}
|
|
for key := range first {
|
|
seen[key] = struct{}{}
|
|
}
|
|
for key := range second {
|
|
seen[key] = struct{}{}
|
|
}
|
|
if len(seen) == 0 {
|
|
return "-"
|
|
}
|
|
keys := make([]string, 0, len(seen))
|
|
for key := range seen {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
return strings.Join(keys, ",")
|
|
}
|
|
|
|
// splitRawLogLines only removes the newline framing used by LogEntry. It
|
|
// deliberately preserves every other byte, including blank lines and spaces.
|
|
func splitRawLogLines(value string) []string {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
lines := make([]string, 0, strings.Count(value, "\n")+1)
|
|
for start := 0; start < len(value); {
|
|
end := strings.IndexByte(value[start:], '\n')
|
|
if end < 0 {
|
|
lines = append(lines, value[start:])
|
|
break
|
|
}
|
|
end += start
|
|
lines = append(lines, value[start:end])
|
|
start = end + 1
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func checksumForText(value string) string {
|
|
sum := sha256.Sum256([]byte(value))
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|