Files
browser/run/runtime/lifecycle_test.go
T

412 lines
18 KiB
Go

package runtime
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"browser.local/run/config"
"browser.local/run/protocol"
)
func TestLifecycleExecutorHandlesSupportedJobs(t *testing.T) {
executor := NewLifecycleExecutor()
for _, capability := range SupportedLifecycleCapabilities() {
assignment := lifecycleAssignment(capability)
result := executor.Execute(assignment)
if result.State != "succeeded" || result.Progress.Percent != 100 || result.ResultRef == "" {
t.Fatalf("expected successful bounded result for %s, got %+v", capability, result)
}
for _, forbidden := range []string{"host path", "/Users/", "run socket", "api_key", "sk-"} {
if strings.Contains(result.Message, forbidden) || strings.Contains(result.ResultRef, forbidden) {
t.Fatalf("lifecycle result exposed forbidden content %q: %+v", forbidden, result)
}
}
}
}
func TestLifecycleExecutorRejectsUnsupportedJobs(t *testing.T) {
result := NewLifecycleExecutor().Execute(lifecycleAssignment("files.write"))
if result.State != "failed" || result.ErrorCode != "unsupported_lifecycle_capability" || result.ResultRef != "" {
t.Fatalf("expected unsupported lifecycle failure, got %+v", result)
}
}
func TestLifecycleResultRequestUsesAssignmentLease(t *testing.T) {
assignment := lifecycleAssignment(protocol.RunCapabilityProcessStart)
execution := NewLifecycleExecutor().Execute(assignment)
request := LifecycleResultRequest(assignment, "session-token", execution)
if request.RunEndpointID != assignment.RunEndpointID || request.JobID != assignment.JobID || request.LeaseToken != assignment.LeaseToken || request.Attempt != assignment.Attempt {
t.Fatalf("expected result request to use assignment lease, got %+v", request)
}
if request.SessionToken != "session-token" || request.State != "succeeded" {
t.Fatalf("unexpected result request: %+v", request)
}
}
func TestLifecycleExecutorRunsScopedCommandTemplateAndHooks(t *testing.T) {
root := t.TempDir()
assignment := lifecycleAssignment(protocol.RunCapabilityProcessStart)
serverRoot := filepath.Join(root, assignment.ServerInstanceID)
if err := os.MkdirAll(filepath.Join(serverRoot, "actions"), 0o755); err != nil {
t.Fatalf("create action dir: %v", err)
}
template := LifecycleActionTemplate{
Command: []string{"echo", "server-ready"},
Env: map[string]string{"GAME_MODE": "test"},
}
body, err := json.Marshal(template)
if err != nil {
t.Fatalf("marshal template: %v", err)
}
if err := os.WriteFile(filepath.Join(serverRoot, "actions", "start.json"), body, 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
assignment.TargetKey = "actions/start.json"
logSink := &recordingLogSink{}
artifactHook := &recordingArtifactHook{}
result := NewLifecycleExecutor(
WithLifecycleWorkspaceRoot(root),
WithProcessLogSink(logSink),
WithLifecycleArtifactHook(artifactHook),
).Execute(assignment)
if result.State != "succeeded" || result.ResultRef != "artifact://jobs/job-1/lifecycle-result" {
t.Fatalf("expected scoped lifecycle success, got %+v", result)
}
if len(logSink.lines) != 1 || logSink.lines[0] != "stdout:server-ready" {
t.Fatalf("expected process stdout to be logged, got %+v", logSink.lines)
}
if !artifactHook.called {
t.Fatal("expected artifact hook to be called")
}
}
func TestLifecycleExecutorRejectsUnsafeTemplates(t *testing.T) {
root := t.TempDir()
assignment := lifecycleAssignment(protocol.RunCapabilityProcessStart)
serverRoot := filepath.Join(root, assignment.ServerInstanceID)
if err := os.MkdirAll(filepath.Join(serverRoot, "actions"), 0o755); err != nil {
t.Fatalf("create action dir: %v", err)
}
for name, template := range map[string]LifecycleActionTemplate{
"absolute": {Command: []string{"/bin/echo", "nope"}},
"shell": {Command: []string{"sh", "-c", "echo nope"}},
"secret": {Command: []string{"echo", "sk-secret"}},
"env": {Command: []string{"echo", "ok"}, Env: map[string]string{"AWS_SECRET_ACCESS_KEY": "secret"}},
} {
body, err := json.Marshal(template)
if err != nil {
t.Fatalf("marshal %s: %v", name, err)
}
actionPath := filepath.Join(serverRoot, "actions", name+".json")
if err := os.WriteFile(actionPath, body, 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
unsafeAssignment := assignment
unsafeAssignment.TargetKey = "actions/" + name + ".json"
result := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(root)).Execute(unsafeAssignment)
if result.State != "failed" || result.ErrorCode != "unsafe_lifecycle_command" {
t.Fatalf("expected unsafe command rejection for %s, got %+v", name, result)
}
}
}
func TestLifecycleExecutorRejectsWorkspaceEscapes(t *testing.T) {
root := t.TempDir()
assignment := lifecycleAssignment(protocol.RunCapabilityProcessStart)
assignment.TargetKey = "../outside.json"
result := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(root)).Execute(assignment)
if result.State != "failed" || result.ErrorCode != "unsafe_lifecycle_command" {
t.Fatalf("expected workspace escape rejection, got %+v", result)
}
}
func TestLifecycleExecutorKeepsSiblingInstanceWorkspacesIsolated(t *testing.T) {
root := t.TempDir()
first := lifecycleAssignment(protocol.RunCapabilityProcessStart)
first.ServerInstanceID = "server-alpha"
second := lifecycleAssignment(protocol.RunCapabilityProcessStop)
second.JobID = "job-2"
second.ServerInstanceID = "server-beta"
for _, assignment := range []protocol.RunJobAssignment{first, second} {
serverRoot := filepath.Join(root, assignment.ServerInstanceID)
if err := os.MkdirAll(filepath.Join(serverRoot, "actions"), 0o755); err != nil {
t.Fatalf("create action dir for %s: %v", assignment.ServerInstanceID, err)
}
body, err := json.Marshal(LifecycleActionTemplate{Command: []string{"echo", assignment.ServerInstanceID}})
if err != nil {
t.Fatalf("marshal template: %v", err)
}
if err := os.WriteFile(filepath.Join(serverRoot, "actions", "lifecycle.json"), body, 0o644); err != nil {
t.Fatalf("write template for %s: %v", assignment.ServerInstanceID, err)
}
}
first.TargetKey = "actions/lifecycle.json"
second.TargetKey = "actions/lifecycle.json"
logSink := &recordingLogSink{}
executor := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(root), WithProcessLogSink(logSink))
firstResult := executor.Execute(first)
secondResult := executor.Execute(second)
if firstResult.State != "succeeded" || secondResult.State != "succeeded" {
t.Fatalf("expected both lifecycle jobs to succeed, got first=%+v second=%+v", firstResult, secondResult)
}
joined := strings.Join(logSink.lines, "\n")
if !strings.Contains(joined, "stdout:server-alpha") || !strings.Contains(joined, "stdout:server-beta") {
t.Fatalf("expected instance-specific output, got %q", joined)
}
if _, err := os.Stat(filepath.Join(root, "server-alpha", "actions", "lifecycle.json")); err != nil {
t.Fatalf("expected alpha template to remain scoped: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "server-beta", "actions", "lifecycle.json")); err != nil {
t.Fatalf("expected beta template to remain scoped: %v", err)
}
}
func TestLifecycleExecutorCancelsRunningCommand(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
result := NewLifecycleExecutor(WithProcessSupervisor(blockingSupervisor{})).ExecuteContext(ctx, lifecycleAssignment(protocol.RunCapabilityProcessStart))
if result.State != "cancelled" || result.ErrorCode != "lifecycle_cancelled" {
t.Fatalf("expected cancelled lifecycle result, got %+v", result)
}
}
func TestSmokeSummaryReportsLifecycleCapabilities(t *testing.T) {
summary := SmokeSummary(config.Config{Mode: "smoke", PlatformURL: "http://platform.test"})
for _, capability := range SupportedLifecycleCapabilities() {
if !containsCapability(summary.Capabilities, capability) {
t.Fatalf("expected smoke capabilities to include %s, got %+v", capability, summary.Capabilities)
}
}
}
func TestSmokeSummaryReportsLogReadCapability(t *testing.T) {
summary := SmokeSummary(config.Config{Mode: "smoke", PlatformURL: "http://platform.test"})
if !containsCapability(summary.Capabilities, protocol.RunCapabilityLogsRead) {
t.Fatalf("expected smoke capabilities to include %s, got %+v", protocol.RunCapabilityLogsRead, summary.Capabilities)
}
}
func TestRemoteAccessExecutorCompletesBoundedJobs(t *testing.T) {
assignment := lifecycleAssignment(protocol.RunCapabilityRemoteRunDBSQLiteQuery)
assignment.TargetKey = "db/scum/query"
assignment.InputRef = "input://server-1/db/sqlite/query/1"
result := ExecuteRemoteAccessJob(context.Background(), assignment)
if result.State != "succeeded" || result.ResultRef != "artifact://jobs/job-1/remote-access-result" {
t.Fatalf("expected bounded remote result, got %+v", result)
}
for _, forbidden := range []string{"/Users/", "tcp://", "password=", "sk-"} {
if strings.Contains(result.Message, forbidden) || strings.Contains(result.ResultRef, forbidden) {
t.Fatalf("remote result exposed forbidden fragment %q: %+v", forbidden, result)
}
}
}
func TestSmokeSummaryReportsRemoteCapabilities(t *testing.T) {
summary := SmokeSummary(config.Config{Mode: "smoke", PlatformURL: "http://platform.test"})
for _, capability := range []string{protocol.RunCapabilityRemoteRunRCONCommand, protocol.RunCapabilityRemoteRunDBMySQLQuery, protocol.RunCapabilityRemoteRunDBSQLiteQuery, protocol.RunCapabilityRemoteRunLogsTransfer} {
if !containsCapability(summary.Capabilities, capability) {
t.Fatalf("expected smoke capabilities to include %s, got %+v", capability, summary.Capabilities)
}
}
}
func TestSmokeSummaryReportsDistributionCapabilities(t *testing.T) {
summary := SmokeSummary(config.Config{Mode: "smoke", PlatformURL: "http://platform.test"})
for _, capability := range []string{protocol.RunCapabilityRunSelfUpdate, protocol.RunCapabilityDependenciesCheck, protocol.RunCapabilityDependenciesInstall, protocol.RunCapabilityLogsBackfill} {
if !containsCapability(summary.Capabilities, capability) {
t.Fatalf("expected smoke capabilities to include %s, got %+v", capability, summary.Capabilities)
}
}
}
func TestDistributionExecutorsReturnBoundedRefsAndRedactResults(t *testing.T) {
assignments := []protocol.RunJobAssignment{
func() protocol.RunJobAssignment {
assignment := lifecycleAssignment(protocol.RunCapabilityRunSelfUpdate)
assignment.TargetKey = "run/update"
assignment.InputRef = "artifact://artifact-run-latest"
return assignment
}(),
func() protocol.RunJobAssignment {
assignment := lifecycleAssignment(protocol.RunCapabilityDependenciesInstall)
assignment.TargetKey = "dependencies/install/install-java-linux"
return assignment
}(),
func() protocol.RunJobAssignment {
assignment := lifecycleAssignment(protocol.RunCapabilityLogsBackfill)
assignment.TargetKey = "logs/latest-log"
assignment.InputRef = "artifact://logs/checkpoint/1"
return assignment
}(),
}
for _, assignment := range assignments {
result := ExecuteDistributionJob(context.Background(), assignment)
if result.State != "succeeded" || result.Progress.Percent != 100 || !strings.HasPrefix(result.ResultRef, "artifact://jobs/") {
t.Fatalf("expected bounded success for %s, got %+v", assignment.Capability, result)
}
for _, forbidden := range []string{"/Users/", "tcp://", "unix://", "password=", "sk-", "mysql://", "sqlite://"} {
if strings.Contains(result.Message, forbidden) || strings.Contains(result.ResultRef, forbidden) {
t.Fatalf("distribution result leaked forbidden fragment %q: %+v", forbidden, result)
}
}
}
}
func TestDistributionExecutorsRejectUnsafeJobs(t *testing.T) {
assignment := lifecycleAssignment(protocol.RunCapabilityDependenciesInstall)
assignment.TargetKey = "dependencies/java-21"
result := ExecuteDistributionJob(context.Background(), assignment)
if result.State != "failed" || result.ErrorCode != "unsafe_dependency_install_plan" {
t.Fatalf("expected unsafe dependency install rejection, got %+v", result)
}
}
func TestResolveRuntimeProfilesSupportsDeclaredModesAndSafeMissingKeys(t *testing.T) {
profiles := RuntimeProfiles{
Discovery: []RuntimeDiscoveryProbe{{Key: "steamcmd", Kind: "command.version", TargetKey: "steamcmd", Required: true}},
LifecycleProfiles: []RuntimeLifecycleProfile{
{Key: "run-local", Mode: RuntimeModeLocalProcess, Capabilities: []string{protocol.RunCapabilityProcessStart}, ActionRefs: map[string]string{"start": "actions/start.json"}, TransportKeys: []string{"server-files"}, Platforms: []string{"linux"}},
{Key: "hosted-ftp", Mode: RuntimeModeHostedFTPRCON, Capabilities: []string{protocol.RunCapabilityRemoteFTPRead, protocol.RunCapabilityRemoteRunRCONCommand}, TransportKeys: []string{"ftp", "rcon"}},
{Key: "ftp-only", Mode: RuntimeModeFTPOnly, Capabilities: []string{protocol.RunCapabilityRemoteFTPRead}, TransportKeys: []string{"ftp"}},
{Key: "custom-client", Mode: RuntimeModeCustomClient, Capabilities: []string{protocol.RunCapabilityRemoteRunRCONCommand}, TransportKeys: []string{"rcon"}, ClientManagerRef: "scum-client-manager"},
},
LogSources: []RuntimeLogSource{{Key: "latest-log", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log"}},
TransportProfiles: []RuntimeTransportProfile{
{Key: "server-files", Kind: "file", TargetKey: "server-root", Capabilities: []string{protocol.RunCapabilityRemoteRunFilesRead}},
{Key: "ftp", Kind: "ftp", TargetKey: "ftp-root", Capabilities: []string{protocol.RunCapabilityRemoteFTPRead}},
{Key: "rcon", Kind: "rcon", TargetKey: "rcon", Capabilities: []string{protocol.RunCapabilityRemoteRunRCONCommand}},
},
}
resolution, err := ResolveRuntimeProfile(profiles, "custom-client", "windows", RuntimeBindingSet{
ProfileKey: "custom-client",
Mode: RuntimeModeCustomClient,
Bindings: map[string]string{
"rcon": "binding://rcon/current",
"logs/latest": "binding://logs/latest",
"steamcmd": "binding://probe/steamcmd",
"scum-client-manager": "binding://client/current",
},
})
if err != nil {
t.Fatalf("resolve custom client profile: %v", err)
}
if !resolution.Available || resolution.Mode != RuntimeModeCustomClient || resolution.ClientManagerRef != "scum-client-manager" {
t.Fatalf("unexpected custom client resolution: %+v", resolution)
}
missing, err := ResolveRuntimeProfile(profiles, "hosted-ftp", "linux", RuntimeBindingSet{ProfileKey: "hosted-ftp", Mode: RuntimeModeHostedFTPRCON, Bindings: map[string]string{"ftp-root": "binding://ftp/current"}})
if err != nil {
t.Fatalf("resolve hosted profile: %v", err)
}
if missing.Available || strings.Join(missing.MissingKeys, ",") != "logs/latest,rcon,steamcmd" {
t.Fatalf("expected safe missing keys without raw binding values, got %+v", missing)
}
}
func TestTailDeclaredFileLogSourceUsesCheckpointAndRedaction(t *testing.T) {
root := t.TempDir()
assignment := lifecycleAssignment(protocol.RunCapabilityLogsRead)
serverRoot := filepath.Join(root, assignment.ServerInstanceID, "logs")
if err := os.MkdirAll(serverRoot, 0o755); err != nil {
t.Fatalf("create logs dir: %v", err)
}
logPath := filepath.Join(serverRoot, "latest.log")
if err := os.WriteFile(logPath, []byte("first line\npassword=hidden\n"), 0o644); err != nil {
t.Fatalf("write log file: %v", err)
}
store := NewMemoryLogCheckpointStore()
sink := &recordingLogSink{}
source := RuntimeLogSource{Key: "latest-log", Kind: "file.tail", TargetKey: "logs/latest.log", StreamKey: "latest-log", CursorKind: "offset"}
result := TailDeclaredFileLogSource(context.Background(), root, assignment, source, sink, store)
if result.State != "succeeded" || !strings.Contains(result.ResultRef, "live-log-checkpoint") {
t.Fatalf("expected file tail success, got %+v", result)
}
if len(sink.lines) != 2 || strings.Contains(strings.Join(sink.lines, "\n"), "password=hidden") {
t.Fatalf("expected redacted tailed lines, got %+v", sink.lines)
}
checkpoint := store.GetLogCheckpoint("latest-log")
if checkpoint.Offset == 0 || checkpoint.Sequence != 2 || strings.Contains(RedactedLogCheckpointSummary(checkpoint), "/Users/") {
t.Fatalf("expected durable safe checkpoint, got %+v", checkpoint)
}
if err := os.WriteFile(logPath, []byte("first line\npassword=hidden\nsecond line\n"), 0o644); err != nil {
t.Fatalf("append log file: %v", err)
}
sink.lines = nil
result = TailDeclaredFileLogSource(context.Background(), root, assignment, source, sink, store)
if result.State != "succeeded" || len(sink.lines) != 1 || !strings.Contains(sink.lines[0], "second line") {
t.Fatalf("expected checkpointed incremental tail, result=%+v lines=%+v", result, sink.lines)
}
}
type recordingLogSink struct {
lines []string
}
func (sink *recordingLogSink) Append(_ context.Context, _ protocol.RunJobAssignment, stream string, line string) error {
sink.lines = append(sink.lines, stream+":"+line)
return nil
}
type recordingArtifactHook struct {
called bool
}
func (hook *recordingArtifactHook) QueueLifecycleResult(_ context.Context, assignment protocol.RunJobAssignment, _ ProcessResult) (string, error) {
hook.called = true
return fmt.Sprintf("artifact://jobs/%s/lifecycle-result", assignment.JobID), nil
}
type blockingSupervisor struct{}
func (blockingSupervisor) Run(ctx context.Context, _ ProcessCommand) (ProcessResult, error) {
<-ctx.Done()
return ProcessResult{ExitCode: -1}, ctx.Err()
}
func lifecycleAssignment(capability string) protocol.RunJobAssignment {
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
return protocol.RunJobAssignment{
JobID: "job-1",
ServerInstanceID: "server-1",
RunEndpointID: "run-local",
Capability: capability,
IdempotencyKey: "idem-1",
State: "accepted",
LeaseToken: "lease-1",
Attempt: 1,
CreatedAt: now,
UpdatedAt: now,
}
}
func containsCapability(capabilities []string, capability string) bool {
for _, item := range capabilities {
if item == capability {
return true
}
}
return false
}