Freeze SCUM SQLite template run contract

This commit is contained in:
npc0-hue
2026-08-13 13:05:39 +08:00
parent 36ed8822c3
commit 467d5c3a8c
17 changed files with 683 additions and 12 deletions
+23 -3
View File
@@ -346,6 +346,18 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
return err
}
}
case domain.JobCapabilityRemoteRunDBSQLiteQuery:
if result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != scumSQLiteTemplateExecutionKind {
return validationError("SQLite template query result type is invalid")
}
if result.State == domain.JobStateSucceeded {
if result.ExecutionResult.SQLiteTemplate == nil {
return validationError("SQLite template terminal result is required")
}
if err := validateSCUMSQLiteTemplateResultForJob(job, *result.ExecutionResult.SQLiteTemplate); err != nil {
return err
}
}
case domain.JobCapabilityClientManagerDeploy:
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.deployed" {
return validationError("client-manager deploy result type is invalid")
@@ -637,7 +649,7 @@ func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp t
func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignment {
fencingToken := uint64(0)
if isProtectedRequestCapability(job.Capability) || job.Capability == domain.JobCapabilityRemoteRunDBSQLiteProbe {
if isProtectedRequestCapability(job.Capability) || job.Capability == domain.JobCapabilityRemoteRunDBSQLiteProbe || job.Capability == domain.JobCapabilityRemoteRunDBSQLiteQuery {
fencingToken = uint64(job.Attempt)
}
return domain.RunJobAssignment{
@@ -651,7 +663,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
State: job.State,
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Phase: job.Progress.Phase, 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), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan), SQLiteSchemaProbe: domain.CopySCUMSchemaProbeRequestPtr(job.ExecutionInput.SQLiteSchemaProbe)},
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), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan), SQLiteSchemaProbe: domain.CopySCUMSchemaProbeRequestPtr(job.ExecutionInput.SQLiteSchemaProbe), SQLiteTemplate: domain.CopySCUMSQLiteTemplateRequestPtr(job.ExecutionInput.SQLiteTemplate)},
LeaseToken: leaseToken,
Attempt: job.Attempt,
FencingToken: fencingToken,
@@ -729,7 +741,15 @@ func jobRetryBackoff(policy domain.JobRetryPolicy, attempt int) time.Duration {
}
func terminalFingerprint(result domain.RunJobResult) string {
return fmt.Sprintf("%s|%d|%s|%s|%s|%s|%t", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message, result.Retryable)
schemaProbeFingerprint := ""
if result.ExecutionResult.SQLiteSchemaProbe != nil {
schemaProbeFingerprint = result.ExecutionResult.SQLiteSchemaProbe.ResultDigest
}
sqliteTemplateFingerprint := ""
if result.ExecutionResult.SQLiteTemplate != nil {
sqliteTemplateFingerprint = fmt.Sprintf("%s|%s|%s|%d", result.ExecutionResult.SQLiteTemplate.ResultDigest, result.ExecutionResult.SQLiteTemplate.AssetDigest, result.ExecutionResult.SQLiteTemplate.ParameterDigest, result.ExecutionResult.SQLiteTemplate.RowCount)
}
return fmt.Sprintf("%s|%d|%s|%s|%s|%s|%t|%s|%s|%s|%s", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message, result.Retryable, result.ExecutionResult.Kind, result.ExecutionResult.Checksum, schemaProbeFingerprint, sqliteTemplateFingerprint)
}
func terminalMessage(result domain.RunJobResult) string {
+57 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
@@ -212,6 +213,41 @@ func TestCoreServiceRunJobTerminalResultIsIdempotent(t *testing.T) {
}
}
func TestCoreServiceRunJobSQLiteTemplateEnvelopeIsFencedToLease(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
request := domain.SCUMSQLiteTemplateRequest{RequestID: "request-query", JobID: "job-query", Binding: scumTemplateTestBinding(), Capability: domain.SCUMDataCapabilityPlayerRead, TargetKey: "scum-database", TemplateKey: "players.active.v1", AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumTemplateTestHash(), AssetDigest: scumTemplateTestHash(), ParameterDigest: scumTemplateTestHash(), Parameters: map[string]any{"limit": 100.0}, Bounds: domain.DefaultSCUMSQLiteTemplateBounds(), RequestedAt: time.Now()}
createSCUMTemplateServerFixture(t, svc, request.Binding)
job, err := svc.CreateJob(domain.Job{ID: request.JobID, ServerInstanceID: request.Binding.ServerInstanceID, RunEndpointID: request.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, TargetKey: request.TargetKey, InputRef: "input://sqlite-template/request-query", IdempotencyKey: "idem-query", ExecutionInput: domain.JobExecutionInput{SQLiteTemplate: &request}})
if err != nil {
t.Fatalf("create sqlite query job: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: request.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.SQLiteTemplate == nil {
t.Fatalf("claim sqlite query job: claim=%+v err=%v", claim, err)
}
result := scumTemplateTestResult(request)
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: request.Binding.RunEndpointID, SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "query complete"}, Message: "query complete", ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &result, AuditSummary: "redacted sqlite template query"}}); err != nil {
t.Fatalf("complete matching sqlite query: %v", err)
}
badRequest := request
badRequest.RequestID = "request-query-bad"
badRequest.JobID = "job-query-bad"
if _, err := svc.CreateJob(domain.Job{ID: badRequest.JobID, ServerInstanceID: badRequest.Binding.ServerInstanceID, RunEndpointID: badRequest.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, TargetKey: badRequest.TargetKey, InputRef: "input://sqlite-template/request-query-bad", IdempotencyKey: "idem-query-bad", ExecutionInput: domain.JobExecutionInput{SQLiteTemplate: &badRequest}}); err != nil {
t.Fatalf("create bad sqlite query job: %v", err)
}
badClaim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: badRequest.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil {
t.Fatalf("claim bad sqlite query job: %v", err)
}
badResult := scumTemplateTestResult(badRequest)
badResult.AssetDigest = "sha256:" + strings.Repeat("b", 64)
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: badRequest.Binding.RunEndpointID, SessionToken: sessionToken, JobID: badClaim.Job.JobID, LeaseToken: badClaim.Job.LeaseToken, Attempt: badClaim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "query complete"}, Message: "query complete", ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &badResult, AuditSummary: "redacted sqlite template query"}})
if err == nil || !strings.Contains(err.Error(), "digest") {
t.Fatalf("expected digest-fenced query result rejection, got %v", err)
}
}
func TestCoreServiceRunJobReconcile(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
createQueuedRunJob(t, svc, "job-1", "idem-1")
@@ -242,7 +278,7 @@ func newRegisteredRunJobService(t *testing.T) (*CoreService, string) {
t.Helper()
svc := newTestCoreService()
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start")
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start", domain.JobCapabilityRemoteRunDBSQLiteQuery)
helloRequest.CapabilityReport.Fingerprint = "cap-jobs"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
@@ -251,6 +287,26 @@ func newRegisteredRunJobService(t *testing.T) (*CoreService, string) {
return svc, hello.SessionToken
}
func scumTemplateTestHash() string { return "sha256:" + strings.Repeat("a", 64) }
func scumTemplateTestBinding() domain.SCUMBindingIdentity {
return domain.SCUMBindingIdentity{ServerInstanceID: "server-scum", RunBindingID: "binding-scum", RunEndpointID: "run-local", PluginID: "game.scum", PluginVersion: "0.1.6", AdapterVersion: "adapter-1", GameVersion: "scum-1", DatabaseIdentity: "scum-database"}
}
func createSCUMTemplateServerFixture(t *testing.T, svc *CoreService, binding domain.SCUMBindingIdentity) {
t.Helper()
if _, err := svc.CreateGamePlugin(domain.GamePlugin{ID: binding.PluginID, Name: "SCUM", Version: binding.PluginVersion, ServerType: "scum", ManifestRef: "artifact://manifests/game.scum/0.1.6", CreateFormSchemaRef: "artifact://schemas/game.scum/create-form/0.1.6", RequiredRunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, DeclaredPermissions: []string{"server.remote.access"}, Permissions: domain.PluginPermissions{RemoteAccess: true}}); err != nil {
t.Fatalf("create SCUM plugin fixture: %v", err)
}
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: binding.ServerInstanceID, PluginID: binding.PluginID, RunEndpointID: binding.RunEndpointID, Name: "SCUM"}); err != nil {
t.Fatalf("create SCUM server fixture: %v", err)
}
}
func scumTemplateTestResult(request domain.SCUMSQLiteTemplateRequest) domain.SCUMSQLiteTemplateResult {
return domain.SCUMSQLiteTemplateResult{RequestID: request.RequestID, JobID: request.JobID, Binding: request.Binding, Status: domain.SCUMTerminalResultSucceeded, Capability: request.Capability, TargetKey: request.TargetKey, TemplateKey: request.TemplateKey, AdapterVersion: request.AdapterVersion, SchemaFingerprint: request.RequiredSchemaFingerprint, AssetDigest: request.AssetDigest, ParameterDigest: request.ParameterDigest, SourceFingerprint: scumTemplateTestHash(), ObservedAt: time.Now(), ResultDigest: scumTemplateTestHash(), RowCount: 1, Rows: []map[string]any{{"externalPlayerId": "player-redacted", "displayName": "Known Player"}}, Limits: request.Bounds, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}
}
func createQueuedRunJob(t *testing.T, svc *CoreService, id string, idempotencyKey string) domain.Job {
t.Helper()
job, err := svc.CreateJob(domain.Job{
+24 -1
View File
@@ -9,7 +9,10 @@ import (
"browser.local/platform/validator"
)
const scumSchemaProbeExecutionKind = "sqlite.schema-probe"
const (
scumSchemaProbeExecutionKind = "sqlite.schema-probe"
scumSQLiteTemplateExecutionKind = "sqlite.template-query"
)
func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstanceID, idempotencyKey string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error) {
idempotencyKey = strings.TrimSpace(idempotencyKey)
@@ -149,6 +152,26 @@ func validateSCUMSchemaProbeResultForJob(job domain.Job, result domain.SCUMSchem
return nil
}
func validateSCUMSQLiteTemplateResultForJob(job domain.Job, result domain.SCUMSQLiteTemplateResult) error {
if err := validator.ValidateSCUMSQLiteTemplateResult(result); err != nil {
return err
}
expected := job.ExecutionInput.SQLiteTemplate
if expected == nil {
return validationError("SQLite template request is missing from leased job")
}
if result.JobID != job.ID || result.JobID != expected.JobID || result.RequestID != expected.RequestID {
return validationError("SQLite template result does not match leased job identity")
}
if !sameSCUMSchemaProbeBinding(result.Binding, expected.Binding) {
return validationError("SQLite template result does not match leased binding identity")
}
if result.Capability != expected.Capability || result.TargetKey != expected.TargetKey || result.TemplateKey != expected.TemplateKey || result.AdapterVersion != expected.AdapterVersion || result.SchemaFingerprint != expected.RequiredSchemaFingerprint || result.AssetDigest != expected.AssetDigest || result.ParameterDigest != expected.ParameterDigest {
return validationError("SQLite template result does not match leased template, adapter, digest, or parameter identity")
}
return nil
}
func sameSCUMSchemaProbeBinding(a, b domain.SCUMBindingIdentity) bool {
return a.ServerInstanceID == b.ServerInstanceID && a.RunBindingID == b.RunBindingID && a.RunEndpointID == b.RunEndpointID && a.PluginID == b.PluginID && a.PluginVersion == b.PluginVersion && a.AdapterVersion == b.AdapterVersion && a.GameVersion == b.GameVersion && a.DatabaseIdentity == b.DatabaseIdentity
}