init
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/run/protocol"
|
||||
)
|
||||
|
||||
func TestWorkerRunsAutonomousBootstrapFromSeededPlan(t *testing.T) {
|
||||
client := newFakeWorkerClient()
|
||||
cfg := workerTestConfig(t)
|
||||
cfg.ServerInstanceID = "server-worker"
|
||||
cfg.PluginID = "server.scum"
|
||||
cfg.ComponentKind = "run"
|
||||
cfg.ComponentKey = "run-local"
|
||||
writeAutonomousPlanFixture(t, cfg.WorkspaceRoot, cfg.ServerInstanceID, cfg.ComponentKey, protocol.RunAutonomousLifecyclePlan{
|
||||
SchemaVersion: "1",
|
||||
ServerInstanceID: cfg.ServerInstanceID,
|
||||
PluginID: cfg.PluginID,
|
||||
PluginVersion: "1.0.0",
|
||||
RunEndpointID: cfg.RunEndpointID,
|
||||
ProfileKey: cfg.ComponentKey,
|
||||
TargetOS: runtime.GOOS,
|
||||
TargetArch: runtime.GOARCH,
|
||||
TargetRelease: "run-dist-test",
|
||||
Bootstrap: &protocol.RunAutonomousLifecycleAction{Action: "start", Operation: "start", Capability: protocol.RunCapabilityProcessStart, TargetKey: "actions/start.json"},
|
||||
LogSources: []protocol.RuntimeLogSourcePlan{{Key: "console-stdout", Kind: "process.stdout", StreamKey: "game.console.stdout", CursorKind: "sequence"}},
|
||||
Deployment: &protocol.RunAutonomousDeployment{SchemaVersion: "1", Mode: "guided", ProfileKey: cfg.ComponentKey, ServerRoot: "D:/game-server", CreateInputs: map[string]string{"gamePort": "7779", "maxPlayers": "128"}, Revision: 2},
|
||||
})
|
||||
managed := &recordingManagedSupervisor{}
|
||||
worker, err := NewWorker(cfg, client, WithManagedProcessSupervisor(managed))
|
||||
if err != nil {
|
||||
t.Fatalf("new worker: %v", err)
|
||||
}
|
||||
if err := worker.Register(context.Background()); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
if err := worker.RunAutonomousLifecycleOnce(context.Background()); err != nil {
|
||||
t.Fatalf("run autonomous lifecycle: %v", err)
|
||||
}
|
||||
|
||||
if len(client.claimRequests) != 0 || len(client.ackRequests) != 0 || len(client.resultRequests) != 0 || len(client.lifecycleReports) != 1 {
|
||||
t.Fatalf("autonomous bootstrap must report without platform job assignment: claims=%d acks=%d results=%d reports=%d", len(client.claimRequests), len(client.ackRequests), len(client.resultRequests), len(client.lifecycleReports))
|
||||
}
|
||||
if client.lifecycleReports[0].Capability != protocol.RunCapabilityProcessStart || client.lifecycleReports[0].ExecutionResult.ProcessState != "running" {
|
||||
t.Fatalf("expected autonomous process lifecycle report, got %+v", client.lifecycleReports[0])
|
||||
}
|
||||
if !managed.started || managed.identity.ServerInstanceID != cfg.ServerInstanceID || managed.identity.RunEndpointID != cfg.RunEndpointID {
|
||||
t.Fatalf("expected managed process start from autonomous plan, managed=%+v", managed)
|
||||
}
|
||||
if managed.identity.StdoutStreamKey != "game.console.stdout" {
|
||||
t.Fatalf("expected declared process stdout stream, identity=%+v", managed.identity)
|
||||
}
|
||||
if managed.command.Env["SERVER_ROOT"] != "D:/game-server" || managed.command.Env["SERVER_CREATE_GAMEPORT"] != "7779" || managed.command.Env["SERVER_CREATE_MAXPLAYERS"] != "128" {
|
||||
t.Fatalf("expected deployment env from autonomous plan, env=%+v", managed.command.Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerContinuesAutonomousBootstrapWhenLifecycleReportFails(t *testing.T) {
|
||||
client := newFakeWorkerClient()
|
||||
client.lifecycleReportErr = errors.New("platform request failed: status=401 path=/api/v1/run/lifecycle/report code=unauthorized")
|
||||
cfg := workerTestConfig(t)
|
||||
cfg.ServerInstanceID = "server-worker"
|
||||
cfg.PluginID = "game.example"
|
||||
cfg.ComponentKind = "run"
|
||||
cfg.ComponentKey = "run-local"
|
||||
writeAutonomousPlanFixture(t, cfg.WorkspaceRoot, cfg.ServerInstanceID, cfg.ComponentKey, protocol.RunAutonomousLifecyclePlan{
|
||||
SchemaVersion: "1",
|
||||
ServerInstanceID: cfg.ServerInstanceID,
|
||||
PluginID: cfg.PluginID,
|
||||
PluginVersion: "1.0.0",
|
||||
RunEndpointID: cfg.RunEndpointID,
|
||||
ProfileKey: cfg.ComponentKey,
|
||||
TargetOS: runtime.GOOS,
|
||||
TargetArch: runtime.GOARCH,
|
||||
TargetRelease: "run-dist-test",
|
||||
Bootstrap: &protocol.RunAutonomousLifecycleAction{Action: "start", Operation: "start", Capability: protocol.RunCapabilityProcessStart, TargetKey: "actions/start.json"},
|
||||
})
|
||||
managed := &recordingManagedSupervisor{}
|
||||
worker, err := NewWorker(cfg, client, WithManagedProcessSupervisor(managed))
|
||||
if err != nil {
|
||||
t.Fatalf("new worker: %v", err)
|
||||
}
|
||||
if err := worker.Register(context.Background()); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
if err := worker.RunAutonomousLifecycleOnce(context.Background()); err != nil {
|
||||
t.Fatalf("run autonomous lifecycle with report failure: %v", err)
|
||||
}
|
||||
|
||||
if !managed.started || len(client.lifecycleReports) != 1 {
|
||||
t.Fatalf("expected bootstrap to continue after lifecycle report failure, managed=%+v reports=%d", managed, len(client.lifecycleReports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerDefersUnmatchedRequiredDependencyToBootstrap(t *testing.T) {
|
||||
client := newFakeWorkerClient()
|
||||
cfg := workerTestConfig(t)
|
||||
cfg.ServerInstanceID = "server-worker"
|
||||
cfg.PluginID = "game.example"
|
||||
cfg.ComponentKind = "run"
|
||||
cfg.ComponentKey = "run-local"
|
||||
writeAutonomousPlanFixture(t, cfg.WorkspaceRoot, cfg.ServerInstanceID, cfg.ComponentKey, protocol.RunAutonomousLifecyclePlan{
|
||||
SchemaVersion: "1",
|
||||
ServerInstanceID: cfg.ServerInstanceID,
|
||||
PluginID: cfg.PluginID,
|
||||
PluginVersion: "1.0.0",
|
||||
RunEndpointID: cfg.RunEndpointID,
|
||||
ProfileKey: cfg.ComponentKey,
|
||||
TargetOS: runtime.GOOS,
|
||||
TargetArch: runtime.GOARCH,
|
||||
TargetRelease: "run-dist-test",
|
||||
Bootstrap: &protocol.RunAutonomousLifecycleAction{Action: "start", Operation: "start", Capability: protocol.RunCapabilityProcessStart, TargetKey: "actions/start.json"},
|
||||
DependencyProbes: []protocol.DependencyProbe{{Key: "steamcmd", Kind: "command.version", TargetKey: "steamcmd", Required: true, Platforms: []string{runtime.GOOS}}},
|
||||
InstallPlans: []protocol.DependencyInstallPlan{{Key: "install-game-server", Title: "Install game server", Platforms: []string{runtime.GOOS}, Steps: []protocol.DependencyInstallStep{{Type: "steamcmd-app", TargetKey: "server/install-root", PackageManager: "steamcmd", PackageName: "3792580"}}}},
|
||||
})
|
||||
managed := &recordingManagedSupervisor{}
|
||||
worker, err := NewWorker(cfg, client, WithManagedProcessSupervisor(managed), WithDependencyCommandRunner(missingCommandRunner{}))
|
||||
if err != nil {
|
||||
t.Fatalf("new worker: %v", err)
|
||||
}
|
||||
if err := worker.Register(context.Background()); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
if err := worker.RunAutonomousLifecycleOnce(context.Background()); err != nil {
|
||||
t.Fatalf("run autonomous lifecycle: %v", err)
|
||||
}
|
||||
|
||||
if !managed.started || len(client.lifecycleReports) != 1 {
|
||||
t.Fatalf("expected bootstrap to run despite unmatched dependency install plan, managed=%+v reports=%d", managed, len(client.lifecycleReports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAutonomousLifecyclePlanRejectsMismatchedIdentity(t *testing.T) {
|
||||
cfg := workerTestConfig(t)
|
||||
cfg.ServerInstanceID = "server-worker"
|
||||
cfg.PluginID = "game.example"
|
||||
cfg.ComponentKind = "run"
|
||||
cfg.ComponentKey = "run-local"
|
||||
writeAutonomousPlanFixture(t, cfg.WorkspaceRoot, cfg.ServerInstanceID, cfg.ComponentKey, protocol.RunAutonomousLifecyclePlan{
|
||||
SchemaVersion: "1",
|
||||
ServerInstanceID: cfg.ServerInstanceID,
|
||||
PluginID: "game.other",
|
||||
PluginVersion: "1.0.0",
|
||||
RunEndpointID: cfg.RunEndpointID,
|
||||
ProfileKey: cfg.ComponentKey,
|
||||
TargetOS: runtime.GOOS,
|
||||
TargetArch: runtime.GOARCH,
|
||||
TargetRelease: "run-dist-test",
|
||||
Bootstrap: &protocol.RunAutonomousLifecycleAction{Action: "start", Operation: "start", Capability: protocol.RunCapabilityProcessStart, TargetKey: "actions/start.json"},
|
||||
})
|
||||
worker, err := NewWorker(cfg, newFakeWorkerClient())
|
||||
if err != nil {
|
||||
t.Fatalf("new worker: %v", err)
|
||||
}
|
||||
worker.state.SessionToken = "session-token"
|
||||
|
||||
if err := worker.RunAutonomousLifecycleOnce(context.Background()); err == nil {
|
||||
t.Fatal("expected mismatched autonomous plan identity to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericWorkerReportsRunningAndExitObservationsForAllEndpointServers(t *testing.T) {
|
||||
client := newFakeWorkerClient()
|
||||
cfg := workerTestConfig(t)
|
||||
cfg.ServerInstanceID = ""
|
||||
cfg.ComponentKind = ""
|
||||
startedAt := time.Now().UTC().Add(-time.Minute)
|
||||
managed := &observationManagedSupervisor{items: []ProcessIdentity{
|
||||
{Scope: "scope-a", ServerInstanceID: "server-a", RunEndpointID: cfg.RunEndpointID, PID: 101, StartedAt: startedAt, CommandFingerprint: "sha256:a", State: "running", ObservationSeq: 1, UpdatedAt: startedAt},
|
||||
{Scope: "scope-b", ServerInstanceID: "server-b", RunEndpointID: cfg.RunEndpointID, PID: 202, StartedAt: startedAt, CommandFingerprint: "sha256:b", State: "running", ObservationSeq: 4, UpdatedAt: startedAt},
|
||||
{Scope: "scope-other", ServerInstanceID: "server-other", RunEndpointID: "run-other", PID: 303, StartedAt: startedAt, CommandFingerprint: "sha256:other", State: "running", ObservationSeq: 1, UpdatedAt: startedAt},
|
||||
}}
|
||||
worker, err := NewWorker(cfg, client, WithManagedProcessSupervisor(managed))
|
||||
if err != nil {
|
||||
t.Fatalf("new generic worker: %v", err)
|
||||
}
|
||||
if err := worker.Register(context.Background()); err != nil {
|
||||
t.Fatalf("register generic worker: %v", err)
|
||||
}
|
||||
if err := worker.reportAutonomousProcessObservations(context.Background()); err != nil {
|
||||
t.Fatalf("report running observations: %v", err)
|
||||
}
|
||||
if len(client.lifecycleReports) != 2 || client.lifecycleReports[0].ExecutionResult.ProcessState != "running" || client.lifecycleReports[1].ExecutionResult.ProcessState != "running" {
|
||||
t.Fatalf("generic worker did not report both matching running observations: %+v", client.lifecycleReports)
|
||||
}
|
||||
if err := worker.reportAutonomousProcessObservations(context.Background()); err != nil || len(client.lifecycleReports) != 2 {
|
||||
t.Fatalf("unchanged observations were not deduplicated: reports=%+v err=%v", client.lifecycleReports, err)
|
||||
}
|
||||
managed.setItems([]ProcessIdentity{
|
||||
{Scope: "scope-a", ServerInstanceID: "server-a", RunEndpointID: cfg.RunEndpointID, PID: 101, StartedAt: startedAt, CommandFingerprint: "sha256:a", State: "exited", ExitCode: 1, ExitClassification: "unexpected-exit", ObservationSeq: 2, UpdatedAt: time.Now().UTC()},
|
||||
{Scope: "scope-b", ServerInstanceID: "server-b", RunEndpointID: cfg.RunEndpointID, PID: 202, StartedAt: startedAt, CommandFingerprint: "sha256:b", State: "exited", ExitCode: 0, ExitClassification: "clean-exit", ObservationSeq: 5, UpdatedAt: time.Now().UTC()},
|
||||
})
|
||||
if err := worker.reportAutonomousProcessObservations(context.Background()); err != nil {
|
||||
t.Fatalf("report exit observations: %v", err)
|
||||
}
|
||||
if len(client.lifecycleReports) != 4 || client.lifecycleReports[2].ExecutionResult.ProcessState != "exited" || client.lifecycleReports[3].ExecutionResult.ProcessState != "exited" {
|
||||
t.Fatalf("generic worker did not report matching exit observations: %+v", client.lifecycleReports)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAutonomousPlanFixture(t *testing.T, root string, serverInstanceID string, profileKey string, plan protocol.RunAutonomousLifecyclePlan) {
|
||||
t.Helper()
|
||||
scope, err := NewWorkspaceResolver(root).Scope(serverInstanceID, profileKey)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve scope: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(scope, "actions"), 0o700); err != nil {
|
||||
t.Fatalf("create actions: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(scope, "bin"), 0o700); err != nil {
|
||||
t.Fatalf("create bin: %v", err)
|
||||
}
|
||||
writeJSONFixture(t, filepath.Join(scope, "actions", "start.json"), map[string]any{"version": 1, "action": "start", "mode": "supervised", "executableKey": "bin/game-server"})
|
||||
if err := os.WriteFile(filepath.Join(scope, "bin", "game-server"), []byte("plugin-owned executable"), 0o700); err != nil {
|
||||
t.Fatalf("write executable: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(scope, ".platform"), 0o700); err != nil {
|
||||
t.Fatalf("create platform dir: %v", err)
|
||||
}
|
||||
body, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal plan: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(scope, autonomousLifecyclePlanKey), body, 0o600); err != nil {
|
||||
t.Fatalf("write plan: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingManagedSupervisor struct {
|
||||
started bool
|
||||
command ProcessCommand
|
||||
identity ProcessIdentity
|
||||
}
|
||||
|
||||
type observationManagedSupervisor struct {
|
||||
mu sync.Mutex
|
||||
items []ProcessIdentity
|
||||
}
|
||||
|
||||
func (supervisor *observationManagedSupervisor) Start(_ context.Context, _ ProcessCommand, identity ProcessIdentity, _ ManagedProcessOutput) (ProcessIdentity, error) {
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (supervisor *observationManagedSupervisor) Stop(_ context.Context, identity ProcessIdentity) (ProcessIdentity, error) {
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (supervisor *observationManagedSupervisor) Status(identity ProcessIdentity) ProcessIdentity {
|
||||
return identity
|
||||
}
|
||||
|
||||
func (supervisor *observationManagedSupervisor) ResumeOutput(ManagedProcessOutput) {}
|
||||
|
||||
func (supervisor *observationManagedSupervisor) ManagedProcessObservations() []ProcessIdentity {
|
||||
supervisor.mu.Lock()
|
||||
defer supervisor.mu.Unlock()
|
||||
return append([]ProcessIdentity(nil), supervisor.items...)
|
||||
}
|
||||
|
||||
func (supervisor *observationManagedSupervisor) setItems(items []ProcessIdentity) {
|
||||
supervisor.mu.Lock()
|
||||
defer supervisor.mu.Unlock()
|
||||
supervisor.items = append([]ProcessIdentity(nil), items...)
|
||||
}
|
||||
|
||||
func (supervisor *recordingManagedSupervisor) Start(_ context.Context, command ProcessCommand, identity ProcessIdentity, _ ManagedProcessOutput) (ProcessIdentity, error) {
|
||||
supervisor.started = true
|
||||
supervisor.command = command
|
||||
identity.PID = 42
|
||||
identity.State = "running"
|
||||
supervisor.identity = identity
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (supervisor *recordingManagedSupervisor) Stop(_ context.Context, identity ProcessIdentity) (ProcessIdentity, error) {
|
||||
identity.State = "stopped"
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (supervisor *recordingManagedSupervisor) Status(identity ProcessIdentity) ProcessIdentity {
|
||||
return identity
|
||||
}
|
||||
|
||||
func (supervisor *recordingManagedSupervisor) ResumeOutput(ManagedProcessOutput) {}
|
||||
|
||||
type missingCommandRunner struct{}
|
||||
|
||||
func (missingCommandRunner) Run(context.Context, ProcessCommand) (ProcessResult, error) {
|
||||
return ProcessResult{ExitCode: 1}, os.ErrNotExist
|
||||
}
|
||||
Reference in New Issue
Block a user