653 lines
29 KiB
Go
653 lines
29 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"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 TestGeneratedRunOmitsBuildOnlyCapabilities(t *testing.T) {
|
|
generated := SupportedRunCapabilitiesForComponent("run")
|
|
if containsCapability(generated, protocol.RunCapabilityDistributionBuild) {
|
|
t.Fatalf("generated Run must not advertise %s: %v", protocol.RunCapabilityDistributionBuild, generated)
|
|
}
|
|
if !containsCapability(generated, protocol.RunCapabilityRunSelfUpdate) {
|
|
t.Fatalf("generated Run must advertise %s: %v", protocol.RunCapabilityRunSelfUpdate, generated)
|
|
}
|
|
for _, capability := range []string{"deployment.scum.v1"} {
|
|
if containsCapability(generated, capability) {
|
|
t.Fatalf("generated Run must not advertise %s: %v", capability, generated)
|
|
}
|
|
}
|
|
for _, capability := range []string{protocol.RunCapabilityProcessStart, protocol.RunCapabilityDependenciesInstall} {
|
|
if !containsCapability(generated, capability) {
|
|
t.Fatalf("generated Run must retain %s: %v", capability, generated)
|
|
}
|
|
}
|
|
if !containsCapability(SupportedRunCapabilitiesForComponent(""), protocol.RunCapabilityDistributionBuild) {
|
|
t.Fatal("generic build worker must retain distribution.build")
|
|
}
|
|
if containsCapability(SupportedRunCapabilitiesForComponent(""), "deployment.scum.v1") {
|
|
t.Fatal("generic run must not advertise game-specific SCUM deployment capability")
|
|
}
|
|
}
|
|
|
|
func TestLifecycleExecutorRejectsLegacyGameSpecificDeploymentPlan(t *testing.T) {
|
|
assignment := lifecycleAssignment(protocol.RunCapabilityProcessInstall)
|
|
assignment.ExecutionInput.Deployment = &protocol.ServerDeploymentExecution{SchemaVersion: "1", Mode: "guided-install", ServerRoot: "C:/scumserver", Revision: 1}
|
|
assignment.ExecutionInput.ServerDeploymentPlan = &protocol.ServerDeploymentPlan{SchemaVersion: "1", PluginID: "game.scum", TemplateKey: "scum-steamcmd-windows"}
|
|
|
|
result := NewLifecycleExecutor().Execute(assignment)
|
|
|
|
if result.State != "failed" || result.ErrorCode != "unsupported_legacy_deployment_plan" {
|
|
t.Fatalf("expected legacy deployment plan rejection, got %+v", 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 TestLifecycleExecutorRunsTypedInstallActionWithDeploymentInputs(t *testing.T) {
|
|
root := t.TempDir()
|
|
assignment := lifecycleAssignment(protocol.RunCapabilityProcessInstall)
|
|
assignment.TargetKey = "actions/install.json"
|
|
assignment.ExecutionInput.WorkspaceScope = "run-local"
|
|
assignment.ExecutionInput.PluginID = "game.example"
|
|
assignment.ExecutionInput.LifecycleOperation = "install"
|
|
assignment.ExecutionInput.Deployment = &protocol.ServerDeploymentExecution{
|
|
SchemaVersion: "1",
|
|
Mode: "guided-install",
|
|
ProfileKey: "run-local",
|
|
ServerRoot: "D:/game-server",
|
|
CreateInputs: map[string]string{"gamePort": "27000", "maxPlayers": "128"},
|
|
Revision: 3,
|
|
}
|
|
scope, err := NewWorkspaceResolver(root).Scope(assignment.ServerInstanceID, assignment.ExecutionInput.WorkspaceScope)
|
|
if err != nil {
|
|
t.Fatalf("resolve scope: %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(scope, "actions"), 0o755); err != nil {
|
|
t.Fatalf("create action dir: %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(scope, "bin"), 0o755); err != nil {
|
|
t.Fatalf("create bin dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(scope, "bin", "install-server"), []byte("plugin-owned helper"), 0o700); err != nil {
|
|
t.Fatalf("write helper: %v", err)
|
|
}
|
|
body, err := json.Marshal(LifecycleActionTemplate{Version: 1, Action: "install", Mode: "oneshot", ExecutableKey: "bin/install-server", Environment: map[string]string{"GAME_ID": "example"}, OutputMode: "console", TimeoutMS: int((90 * time.Minute) / time.Millisecond)})
|
|
if err != nil {
|
|
t.Fatalf("marshal action: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(scope, "actions", "install.json"), body, 0o600); err != nil {
|
|
t.Fatalf("write action: %v", err)
|
|
}
|
|
supervisor := &recordingSupervisor{}
|
|
|
|
result := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(root), WithProcessSupervisor(supervisor)).Execute(assignment)
|
|
|
|
if result.State != "succeeded" || !strings.HasSuffix(supervisor.command.Args[0], filepath.Join("bin", "install-server")) {
|
|
t.Fatalf("expected typed install helper execution, result=%+v command=%+v", result, supervisor.command)
|
|
}
|
|
if supervisor.command.Env["SERVER_ROOT"] != "D:/game-server" || supervisor.command.Env["SERVER_CREATE_GAMEPORT"] != "27000" || supervisor.command.Env["SERVER_CREATE_MAXPLAYERS"] != "128" || supervisor.command.Env["SERVER_REVISION"] != "3" {
|
|
t.Fatalf("expected deployment inputs in typed action environment, got %+v", supervisor.command.Env)
|
|
}
|
|
if supervisor.command.Timeout != 90*time.Minute {
|
|
t.Fatalf("expected plugin-declared long lifecycle timeout, got %s", supervisor.command.Timeout)
|
|
}
|
|
if supervisor.command.OutputMode != "console" {
|
|
t.Fatalf("expected plugin-declared output mode, got %q", supervisor.command.OutputMode)
|
|
}
|
|
}
|
|
|
|
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 TestMaterializeWorkspaceSeedWritesPluginAssetsToProfileScope(t *testing.T) {
|
|
root := t.TempDir()
|
|
seed, err := json.Marshal([]workspaceSeedFile{
|
|
{Path: "actions/install.json", Content: `{"version":1,"action":"install","mode":"oneshot"}`, Mode: 0o600},
|
|
{Path: "bin/install-server", Content: "echo install\n", Mode: 0o700},
|
|
{Path: "assets/map.bin", Content: base64.StdEncoding.EncodeToString([]byte{0xff, 0x00, 0x7f}), Encoding: "base64", Mode: 0o600},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal seed: %v", err)
|
|
}
|
|
|
|
err = MaterializeWorkspaceSeed(config.Config{
|
|
WorkspaceRoot: root,
|
|
ServerInstanceID: "server-seeded",
|
|
ComponentKey: "run-local",
|
|
WorkspaceSeed: base64.StdEncoding.EncodeToString(seed),
|
|
ComponentKind: "run",
|
|
RegistrationToken: "unused",
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("materialize workspace seed: %v", err)
|
|
}
|
|
scope, err := NewWorkspaceResolver(root).Scope("server-seeded", "run-local")
|
|
if err != nil {
|
|
t.Fatalf("resolve seeded scope: %v", err)
|
|
}
|
|
if body, err := os.ReadFile(filepath.Join(scope, "actions", "install.json")); err != nil || !strings.Contains(string(body), `"action":"install"`) {
|
|
t.Fatalf("expected seeded action file, body=%q err=%v", body, err)
|
|
}
|
|
info, err := os.Stat(filepath.Join(scope, "bin", "install-server"))
|
|
if err != nil || info.Mode().Perm()&0o111 == 0 {
|
|
t.Fatalf("expected executable seeded helper, info=%+v err=%v", info, err)
|
|
}
|
|
if body, err := os.ReadFile(filepath.Join(scope, "assets", "map.bin")); err != nil || string(body) != string([]byte{0xff, 0x00, 0x7f}) {
|
|
t.Fatalf("expected base64 seed file to materialize as binary bytes, body=%v err=%v", body, err)
|
|
}
|
|
}
|
|
|
|
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 TestSmokeSummaryReportsSQLiteSchemaProbeCapability(t *testing.T) {
|
|
summary := SmokeSummary(config.Config{Mode: "smoke", PlatformURL: "http://platform.test"})
|
|
if !containsCapability(summary.Capabilities, protocol.RunCapabilityRemoteRunDBSQLiteProbe) {
|
|
t.Fatalf("expected schema probe capability, got %+v", summary.Capabilities)
|
|
}
|
|
}
|
|
|
|
func TestLifecycleResultRequestPreservesSQLiteSchemaProbeEnvelope(t *testing.T) {
|
|
assignment := sqliteSchemaProbeAssignment()
|
|
probe := &protocol.SQLiteSchemaProbeResult{RequestID: "probe-1", JobID: assignment.JobID, Binding: assignment.ExecutionInput.SQLiteSchemaProbe.Binding, Status: "succeeded", ResultDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Limits: assignment.ExecutionInput.SQLiteSchemaProbe.Limits}
|
|
request := LifecycleResultRequest(assignment, "session-token", LifecycleExecutionResult{State: lifecycleResultStateSucceeded, ExecutionResult: protocol.RunJobExecutionResult{Kind: "sqlite.schema-probe", SQLiteSchemaProbe: probe}})
|
|
if request.ExecutionResult.SQLiteSchemaProbe == nil || request.ExecutionResult.SQLiteSchemaProbe.ResultDigest != probe.ResultDigest || request.ExecutionResult.SQLiteSchemaProbe.JobID != assignment.JobID {
|
|
t.Fatalf("expected SQLite probe terminal envelope to survive job result conversion: %+v", request.ExecutionResult)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
assignment := lifecycleAssignment(protocol.RunCapabilityLogsBackfill)
|
|
assignment.TargetKey = "logs/latest-log"
|
|
assignment.InputRef = "artifact://logs/checkpoint/1"
|
|
result := ExecuteDistributionJob(context.Background(), assignment)
|
|
if result.State != "succeeded" || result.Progress.Percent != 100 || !strings.HasPrefix(result.ResultRef, "artifact://jobs/") {
|
|
t.Fatalf("expected bounded log backfill success, got %+v", 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 != "dependency_execution_requires_worker" {
|
|
t.Fatalf("expected dependency execution to require authenticated worker, 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"}},
|
|
},
|
|
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}},
|
|
},
|
|
}
|
|
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, ",") != "rcon,steamcmd" {
|
|
t.Fatalf("expected safe missing keys without raw binding values, got %+v", missing)
|
|
}
|
|
}
|
|
|
|
func TestTailDeclaredFileLogSourceUsesCheckpointAndVerbatimOutput(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.Join(sink.lines, "\n") != "latest-log:first line\nlatest-log:password=hidden" {
|
|
t.Fatalf("expected verbatim tailed lines, got %+v", sink.lines)
|
|
}
|
|
checkpoint := store.GetLogCheckpoint("latest-log")
|
|
if checkpoint.Offset == 0 || checkpoint.Sequence != 2 || strings.Contains(LogCheckpointSummary(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)
|
|
}
|
|
}
|
|
|
|
func TestTailDeclaredFileLogSourceDoesNotLimitOrRewriteOutput(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)
|
|
}
|
|
longLine := "password=visible\r" + strings.Repeat("x", maxLifecycleOutputBytes+1)
|
|
logPath := filepath.Join(serverRoot, "current.log")
|
|
if err := os.WriteFile(logPath, []byte(longLine+"\n\nfinal"), 0o644); err != nil {
|
|
t.Fatalf("write log file: %v", err)
|
|
}
|
|
store := NewMemoryLogCheckpointStore()
|
|
sink := &recordingLogSink{}
|
|
source := RuntimeLogSource{Key: "current-log", Kind: "file.tail", TargetKey: "logs/current.log", StreamKey: "current-log", CursorKind: "offset"}
|
|
|
|
result := TailDeclaredFileLogSource(context.Background(), root, assignment, source, sink, store)
|
|
|
|
if result.State != lifecycleResultStateSucceeded || len(sink.lines) < 3 {
|
|
t.Fatalf("expected all unbounded log entries, result=%+v lines=%d", result, len(sink.lines))
|
|
}
|
|
payloads := make([]string, len(sink.lines))
|
|
for index, line := range sink.lines {
|
|
payloads[index] = strings.TrimPrefix(line, "current-log:")
|
|
}
|
|
if strings.Join(payloads[:len(payloads)-2], "") != longLine || payloads[len(payloads)-2] != "" || payloads[len(payloads)-1] != "final" {
|
|
t.Fatalf("expected byte-for-byte log payloads after chunk reassembly, got %d entries", len(payloads))
|
|
}
|
|
checkpoint := store.GetLogCheckpoint(source.Key)
|
|
if checkpoint.Offset != int64(len(longLine+"\n\nfinal")) || checkpoint.Sequence != uint64(len(payloads)) {
|
|
t.Fatalf("expected complete checkpoint after unbounded tail, got %+v", checkpoint)
|
|
}
|
|
}
|
|
|
|
func TestLifecycleExecutorExecutesDeclaredLogBackfillTail(t *testing.T) {
|
|
root := t.TempDir()
|
|
assignment := lifecycleAssignment(protocol.RunCapabilityLogsBackfill)
|
|
assignment.TargetKey = "logs/latest-log"
|
|
assignment.ExecutionInput.LogSource = &protocol.RuntimeLogSourcePlan{Key: "latest-log", Kind: "file.tail", TargetKey: "logs/latest.log", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}
|
|
serverRoot := filepath.Join(root, assignment.ServerInstanceID, "logs")
|
|
if err := os.MkdirAll(serverRoot, 0o755); err != nil {
|
|
t.Fatalf("create logs dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(serverRoot, "latest.log"), []byte("scum latest line\n"), 0o644); err != nil {
|
|
t.Fatalf("write log file: %v", err)
|
|
}
|
|
sink := &recordingLogSink{}
|
|
|
|
result := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(root), WithProcessLogSink(sink)).ExecuteLogBackfill(context.Background(), assignment)
|
|
|
|
if result.State != "succeeded" || !strings.Contains(result.Message, "tailed") {
|
|
t.Fatalf("expected tailed log backfill success, got %+v", result)
|
|
}
|
|
if len(sink.lines) != 1 || sink.lines[0] != "latest-log:scum latest line" {
|
|
t.Fatalf("expected tailed log line in sink, got %+v", sink.lines)
|
|
}
|
|
}
|
|
|
|
type recordingSupervisor struct {
|
|
command ProcessCommand
|
|
}
|
|
|
|
func (supervisor *recordingSupervisor) Run(_ context.Context, command ProcessCommand) (ProcessResult, error) {
|
|
supervisor.command = command
|
|
return ProcessResult{ExitCode: 0, Stdout: "recorded\n"}, nil
|
|
}
|
|
|
|
func TestOSProcessSupervisorRelaysRawCarriageReturn(t *testing.T) {
|
|
var output []string
|
|
result, err := (OSProcessSupervisor{}).Run(context.Background(), ProcessCommand{
|
|
Args: []string{"sh", "-c", "printf 'raw\\r\\n'"},
|
|
OutputLine: func(stream string, line string) {
|
|
output = append(output, stream+":"+line)
|
|
},
|
|
})
|
|
if err != nil || result.ExitCode != 0 {
|
|
t.Fatalf("run process: result=%+v err=%v", result, err)
|
|
}
|
|
if len(output) != 1 || output[0] != "stdout:raw\r" {
|
|
t.Fatalf("expected raw CR to be relayed, got %+v", output)
|
|
}
|
|
if result.Stdout != "" || result.Stderr != "" {
|
|
t.Fatalf("relayed process output must not be duplicated into result buffers, got %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestOSProcessSupervisorChunksLongRelayedLinesWithoutRewriting(t *testing.T) {
|
|
var output []string
|
|
result, err := (OSProcessSupervisor{}).Run(context.Background(), ProcessCommand{
|
|
Args: []string{"sh", "-c", "printf %70000s | tr ' ' x"},
|
|
OutputLine: func(stream string, line string) {
|
|
if stream == "stdout" {
|
|
output = append(output, line)
|
|
}
|
|
},
|
|
})
|
|
if err != nil || result.ExitCode != 0 {
|
|
t.Fatalf("run process: result=%+v err=%v", result, err)
|
|
}
|
|
if len(output) < 2 || strings.Join(output, "") != strings.Repeat("x", 70000) {
|
|
t.Fatalf("expected long relayed line to reassemble exactly, chunks=%d", len(output))
|
|
}
|
|
}
|
|
|
|
func TestProcessCommandJSONOmitsOutputCallback(t *testing.T) {
|
|
body, err := json.Marshal(ProcessCommand{
|
|
WorkDir: "/workspace",
|
|
Args: []string{"cmd.exe", "/d", "/c", "call", "start.cmd"},
|
|
OutputLine: func(stream string, line string) {
|
|
panic(stream + line)
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal process command with output callback: %v", err)
|
|
}
|
|
if strings.Contains(string(body), "OutputLine") || strings.Contains(string(body), "func") {
|
|
t.Fatalf("output callback leaked into helper JSON: %s", body)
|
|
}
|
|
}
|
|
|
|
type recordingLogSink struct {
|
|
mu sync.Mutex
|
|
lines []string
|
|
}
|
|
|
|
func (sink *recordingLogSink) Append(_ context.Context, _ protocol.RunJobAssignment, stream string, line string) error {
|
|
sink.mu.Lock()
|
|
defer sink.mu.Unlock()
|
|
sink.lines = append(sink.lines, stream+":"+line)
|
|
return nil
|
|
}
|
|
|
|
func (sink *recordingLogSink) snapshot() []string {
|
|
sink.mu.Lock()
|
|
defer sink.mu.Unlock()
|
|
return append([]string(nil), sink.lines...)
|
|
}
|
|
|
|
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
|
|
}
|