feat: add UE4SS DLL runtime extension
This commit is contained in:
@@ -600,7 +600,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
||||
State: job.State,
|
||||
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
|
||||
ResultRef: job.ResultRef,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs)},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...)},
|
||||
LeaseToken: leaseToken,
|
||||
Attempt: job.Attempt,
|
||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||
|
||||
@@ -161,6 +161,19 @@ func (svc *CoreService) dispatchExistingServerLifecycle(command domain.ServerLif
|
||||
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if action == domain.ServerLifecycleActionStart {
|
||||
binding, bindingErr := svc.runtimeBindingForServer(instance.ID)
|
||||
if bindingErr != nil && !errors.Is(bindingErr, repo.ErrNotFound) {
|
||||
return domain.ServerLifecycleResult{}, bindingErr
|
||||
}
|
||||
if bindingErr == nil {
|
||||
if profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey); exists && len(profile.DLLExtensionRefs) > 0 {
|
||||
if _, extensionErr := lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint); extensionErr != nil {
|
||||
return domain.ServerLifecycleResult{}, extensionErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := svc.requireCompleteRuntimeBindings(instance.OwnerUserID, instance.ID, "server.lifecycle."+string(action)+".denied"); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
@@ -203,7 +216,8 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
||||
return domain.Job{}, err
|
||||
}
|
||||
actionRef := binding.ProfileKey
|
||||
if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey); ok {
|
||||
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey)
|
||||
if hasProfile {
|
||||
if ref := runtimeProfileActionRef(profile.ActionRefs, action); ref != "" {
|
||||
actionRef = ref
|
||||
}
|
||||
@@ -213,6 +227,17 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
||||
if strings.TrimSpace(actionRef) == "" {
|
||||
return domain.Job{}, validationError(fmt.Sprintf("plugin %s lifecycle action is required", action))
|
||||
}
|
||||
var dllExtensions []domain.RuntimeDLLExtensionPlan
|
||||
if action == domain.ServerLifecycleActionStart && hasProfile {
|
||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.Job{}, err
|
||||
}
|
||||
dllExtensions, err = lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint)
|
||||
if err != nil {
|
||||
return domain.Job{}, err
|
||||
}
|
||||
}
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: lifecycleJobID(instance.ID, action, idempotencyKey),
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -224,6 +249,7 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
||||
WorkspaceScope: binding.ProfileKey,
|
||||
PluginID: plugin.ID,
|
||||
LifecycleOperation: lifecycleExecutionOperation(action),
|
||||
DLLExtensions: dllExtensions,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -274,6 +300,37 @@ func runtimeLifecycleProfileForKey(profiles domain.GamePluginRuntimeProfiles, ke
|
||||
return domain.RuntimeLifecycleProfile{}, false
|
||||
}
|
||||
|
||||
func lifecycleDLLExtensionPlans(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, endpoint domain.RunEndpoint) ([]domain.RuntimeDLLExtensionPlan, error) {
|
||||
if len(profile.DLLExtensionRefs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
byKey := make(map[string]domain.RuntimeDLLExtensionProfile, len(profiles.DLLExtensions))
|
||||
for _, extension := range profiles.DLLExtensions {
|
||||
byKey[extension.Key] = extension
|
||||
}
|
||||
plans := make([]domain.RuntimeDLLExtensionPlan, 0, len(profile.DLLExtensionRefs))
|
||||
for _, key := range profile.DLLExtensionRefs {
|
||||
extension, exists := byKey[key]
|
||||
if !exists || extension.ReleaseState != "ready" {
|
||||
return nil, validationError("extension_release_unavailable: selected DLL release is not ready")
|
||||
}
|
||||
if !runtimeDLLExtensionSupportsTarget(extension, endpoint.Platform, endpoint.Architecture) {
|
||||
return nil, validationError("unsupported_extension_platform: UE4SS DLL requires windows/amd64")
|
||||
}
|
||||
plans = append(plans, domain.RuntimeDLLExtensionPlan{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort})
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
func runtimeDLLExtensionSupportsTarget(extension domain.RuntimeDLLExtensionProfile, platform string, architecture string) bool {
|
||||
for _, target := range extension.SupportedTargets {
|
||||
if strings.EqualFold(target.OS, platform) && strings.EqualFold(target.Arch, architecture) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (svc *CoreService) validateLifecycleIdempotency(runEndpointID string, idempotencyKey string, serverInstanceID string, capability string) error {
|
||||
existing, err := svc.store.Jobs().GetByIdempotency(runEndpointID, idempotencyKey)
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
|
||||
@@ -85,6 +85,65 @@ func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceFreezesReadyDLLExtensionIntoWindowsStartJob(t *testing.T) {
|
||||
svc, sessionToken := newLifecycleRunService(t)
|
||||
setLifecycleEndpointTarget(t, svc, "windows", "amd64")
|
||||
plugin := createLifecyclePlugin(t, svc)
|
||||
attachReadyLifecycleDLLExtension(t, svc, &plugin)
|
||||
|
||||
created, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ID: "server-dll", PluginID: plugin.ID, RunEndpointID: "run-local", Name: "SCUM DLL", IdempotencyKey: "idem-dll-create", ProfileKey: "local"})
|
||||
if err != nil {
|
||||
t.Fatalf("create DLL lifecycle server: %v", err)
|
||||
}
|
||||
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, created.Instance.ID, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
|
||||
ready, err := svc.GetServerInstance(created.Instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get ready DLL server: %v", err)
|
||||
}
|
||||
|
||||
started, err := svc.StartServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: ready.ID, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "idem-dll-start"})
|
||||
if err != nil {
|
||||
t.Fatalf("start DLL lifecycle server: %v", err)
|
||||
}
|
||||
if len(started.Job.ExecutionInput.DLLExtensions) != 1 {
|
||||
t.Fatalf("expected one frozen DLL plan, got %+v", started.Job.ExecutionInput)
|
||||
}
|
||||
if got := started.Job.ExecutionInput.DLLExtensions[0]; got.Key != "scum-simple-rcon" || got.Checksum != "sha256:"+strings.Repeat("a", 64) || got.ReleaseURL != "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll" {
|
||||
t.Fatalf("unexpected frozen DLL plan: %+v", got)
|
||||
}
|
||||
|
||||
plugin.RuntimeProfiles.DLLExtensions[0].Checksum = "sha256:" + strings.Repeat("c", 64)
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin after start fence: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || len(claim.Job.ExecutionInput.DLLExtensions) != 1 {
|
||||
t.Fatalf("claim frozen start job: claim=%+v err=%v", claim, err)
|
||||
}
|
||||
if got := claim.Job.ExecutionInput.DLLExtensions[0].Checksum; got != "sha256:"+strings.Repeat("a", 64) {
|
||||
t.Fatalf("queued start job was not fenced to the original DLL pin: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsLinuxDLLExtensionStartBeforeDispatch(t *testing.T) {
|
||||
svc, sessionToken := newLifecycleRunService(t)
|
||||
setLifecycleEndpointTarget(t, svc, "windows", "amd64")
|
||||
plugin := createLifecyclePlugin(t, svc)
|
||||
attachReadyLifecycleDLLExtension(t, svc, &plugin)
|
||||
created, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ID: "server-dll-linux", PluginID: plugin.ID, RunEndpointID: "run-local", Name: "SCUM DLL Linux", IdempotencyKey: "idem-dll-linux-create", ProfileKey: "local"})
|
||||
if err != nil {
|
||||
t.Fatalf("create DLL lifecycle server: %v", err)
|
||||
}
|
||||
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, created.Instance.ID, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
|
||||
ready, _ := svc.GetServerInstance(created.Instance.ID)
|
||||
setLifecycleEndpointTarget(t, svc, "linux", "amd64")
|
||||
|
||||
_, err = svc.StartServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: ready.ID, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "idem-dll-linux-start"})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported_extension_platform") {
|
||||
t.Fatalf("expected explicit Linux DLL rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceServerLifecycleRejectsInvalidCommands(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
@@ -272,6 +331,34 @@ func createLifecyclePlugin(t *testing.T, svc *CoreService) domain.GamePlugin {
|
||||
return plugin
|
||||
}
|
||||
|
||||
func attachReadyLifecycleDLLExtension(t *testing.T, svc *CoreService, plugin *domain.GamePlugin) {
|
||||
t.Helper()
|
||||
plugin.RuntimeProfiles.LifecycleProfiles[0].Platforms = []string{"windows"}
|
||||
plugin.RuntimeProfiles.LifecycleProfiles[0].DLLExtensionRefs = []string{"scum-simple-rcon"}
|
||||
plugin.RuntimeProfiles.DLLExtensions = []domain.RuntimeDLLExtensionProfile{{
|
||||
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
|
||||
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024,
|
||||
TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
|
||||
SCUMExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015,
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(*plugin); err != nil {
|
||||
t.Fatalf("attach ready DLL extension: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setLifecycleEndpointTarget(t *testing.T, svc *CoreService, platform string, architecture string) {
|
||||
t.Helper()
|
||||
endpoint, err := svc.store.RunEndpoints().Get("run-local")
|
||||
if err != nil {
|
||||
t.Fatalf("get lifecycle endpoint: %v", err)
|
||||
}
|
||||
endpoint.Platform = platform
|
||||
endpoint.Architecture = architecture
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("set lifecycle endpoint target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func claimAndCompleteLifecycleJob(t *testing.T, svc *CoreService, sessionToken string, capability string, state domain.JobState) {
|
||||
t.Helper()
|
||||
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, "", capability, state)
|
||||
|
||||
Reference in New Issue
Block a user