feat: add UE4SS DLL runtime extension

This commit is contained in:
npc0-hue
2026-07-23 09:49:14 +08:00
parent 8e34c8762a
commit 1caf40565c
30 changed files with 1179 additions and 70 deletions
+1 -1
View File
@@ -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,
+58 -1
View File
@@ -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) {
+87
View File
@@ -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)