The metadata snapshot deleted every SCUM sqlite query job while persisting, so the recurring database poll lost its job records as soon as anything wrote the snapshot and left its stdout/stderr streams behind. Those polls are the only producer for the platform scum_user and scum_vehicle tables, and the service already retires older terminal polls with their streams, so the snapshot no longer drops them. Startup now removes job log streams whose job no longer exists, keeping the autonomous lifecycle streams the Run posts without a platform job. That clears the streams left by the removed poll producers instead of carrying them in every snapshot.
472 lines
26 KiB
Go
472 lines
26 KiB
Go
package repo
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
)
|
|
|
|
func TestMemoryRepositoryRejectsDuplicateAndCopiesValues(t *testing.T) {
|
|
store := NewMemoryStore()
|
|
user := domain.User{
|
|
ID: "user-1",
|
|
DisplayName: "Mary",
|
|
Status: domain.UserStatusActive,
|
|
Roles: []string{"admin"},
|
|
}
|
|
|
|
if err := store.Users().Create(user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
if err := store.Users().Create(user); !errors.Is(err, ErrDuplicate) {
|
|
t.Fatalf("expected duplicate error, got %v", err)
|
|
}
|
|
|
|
got, err := store.Users().Get(user.ID)
|
|
if err != nil {
|
|
t.Fatalf("get user: %v", err)
|
|
}
|
|
got.Roles[0] = "mutated"
|
|
|
|
again, err := store.Users().Get(user.ID)
|
|
if err != nil {
|
|
t.Fatalf("get user again: %v", err)
|
|
}
|
|
if again.Roles[0] != "admin" {
|
|
t.Fatalf("expected stored roles to be isolated, got %+v", again.Roles)
|
|
}
|
|
}
|
|
|
|
func TestMemoryRepositoryListFiltersAndSorts(t *testing.T) {
|
|
store := NewMemoryStore()
|
|
users := []domain.User{
|
|
{ID: "user-c", DisplayName: "C", Status: domain.UserStatusDisabled},
|
|
{ID: "user-b", DisplayName: "B", Status: domain.UserStatusActive},
|
|
{ID: "user-a", DisplayName: "A", Status: domain.UserStatusActive},
|
|
}
|
|
for _, user := range users {
|
|
if err := store.Users().Create(user); err != nil {
|
|
t.Fatalf("create user %s: %v", user.ID, err)
|
|
}
|
|
}
|
|
|
|
active, err := store.Users().List(domain.UserFilter{Status: domain.UserStatusActive})
|
|
if err != nil {
|
|
t.Fatalf("list users: %v", err)
|
|
}
|
|
if len(active) != 2 || active[0].ID != "user-a" || active[1].ID != "user-b" {
|
|
t.Fatalf("expected sorted active users, got %+v", active)
|
|
}
|
|
}
|
|
|
|
func TestMemoryJobRepositoryFindsIdempotencyKey(t *testing.T) {
|
|
store := NewMemoryStore()
|
|
job := domain.Job{
|
|
ID: "job-1",
|
|
RunEndpointID: "run-local",
|
|
Capability: "process.start",
|
|
IdempotencyKey: "idem-1",
|
|
State: domain.JobStateQueued,
|
|
}
|
|
|
|
if err := store.Jobs().Create(job); err != nil {
|
|
t.Fatalf("create job: %v", err)
|
|
}
|
|
got, err := store.Jobs().GetByIdempotency("run-local", "idem-1")
|
|
if err != nil {
|
|
t.Fatalf("get by idempotency: %v", err)
|
|
}
|
|
if got.ID != job.ID {
|
|
t.Fatalf("expected job %q, got %q", job.ID, got.ID)
|
|
}
|
|
|
|
_, err = store.Jobs().GetByIdempotency("run-local", "missing")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected not found for missing idempotency key, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileStorePersistsAndReloadsResources(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "metadata.json")
|
|
store, err := NewFileStore(path)
|
|
if err != nil {
|
|
t.Fatalf("create file store: %v", err)
|
|
}
|
|
user := domain.User{
|
|
ID: "user-1",
|
|
DisplayName: "Durable User",
|
|
Status: domain.UserStatusActive,
|
|
Roles: []string{"platform-admin"},
|
|
}
|
|
if err := store.Users().Create(user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
stamp := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC)
|
|
job := domain.Job{
|
|
ID: "job-1",
|
|
RunEndpointID: "run-local",
|
|
ServerInstanceID: "server-1",
|
|
Capability: "process.start",
|
|
IdempotencyKey: "idem-1",
|
|
State: domain.JobStateQueued,
|
|
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 4, InitialBackoffSeconds: 3, MaxBackoffSeconds: 30},
|
|
Attempt: 2,
|
|
QueueEligibleAt: stamp,
|
|
NextAttemptAt: stamp.Add(3 * time.Second),
|
|
LeaseTokenHash: strings.Repeat("d", 64),
|
|
LeaseSessionGen: 2,
|
|
AckDeadlineAt: stamp.Add(15 * time.Second),
|
|
LeaseExpiresAt: stamp.Add(time.Minute),
|
|
LastProgressSeq: 7,
|
|
CancelReason: "operator requested",
|
|
CancelRequestedAt: stamp,
|
|
LastReconciledAt: stamp,
|
|
ReconcileCount: 2,
|
|
ReconcileOutcome: "confirmed active attempt",
|
|
ExecutionInput: domain.JobExecutionInput{
|
|
WorkspaceScope: "local",
|
|
Content: "name=approved\n",
|
|
ExpectedVersion: 1,
|
|
ExpectedChecksum: "sha256:" + strings.Repeat("1", 64),
|
|
MaxReadBytes: 64 * 1024,
|
|
Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-123"},
|
|
Deployment: &domain.ServerDeploymentDefinition{
|
|
Mode: domain.ServerDeploymentModeGuided,
|
|
ProfileKey: "local",
|
|
CreateInputs: map[string]string{"gamePort": "27000", "maxPlayers": "128"},
|
|
ServerRoot: "D:\\scumserver",
|
|
Revision: 2,
|
|
},
|
|
ServerDeploymentPlan: &domain.ServerDeploymentPlan{SchemaVersion: "1", Operation: "install", PluginID: "game.runtime", Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "tool"}}},
|
|
},
|
|
ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, Summary: "bounded read", Content: "private-read"},
|
|
}
|
|
if err := store.Jobs().Create(job); err != nil {
|
|
t.Fatalf("create job: %v", err)
|
|
}
|
|
plugin := domain.GamePlugin{ID: "game.runtime", Name: "Runtime", Version: "1.0.0", RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}}}}}
|
|
if err := store.GamePlugins().Create(plugin); err != nil {
|
|
t.Fatalf("create plugin: %v", err)
|
|
}
|
|
binding := domain.RuntimeBinding{ID: "runtime-binding-server-1", ServerInstanceID: "server-1", PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: "local", Mode: "local-process", Bindings: map[string]string{"rcon.password": "secret://server-1/rcon"}, Status: domain.RuntimeBindingStatusComplete, CreatedAt: stamp, UpdatedAt: stamp}
|
|
if err := store.RuntimeBindings().Create(binding); err != nil {
|
|
t.Fatalf("create runtime binding: %v", err)
|
|
}
|
|
runEndpoint := domain.RunEndpoint{ID: "run-target", DisplayName: "Target Run", Version: "release-1", Platform: "linux", Architecture: "amd64", Status: domain.RunEndpointStatusOnline, Capabilities: []string{domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate}, Capacity: domain.RunCapacity{MaxJobs: 2}, LastHeartbeatAt: stamp}
|
|
if err := store.RunEndpoints().Create(runEndpoint); err != nil {
|
|
t.Fatalf("create target Run endpoint: %v", err)
|
|
}
|
|
planDigest := "sha256:" + strings.Repeat("e", 64)
|
|
dependencyStatus := domain.DependencyStatus{ID: "dependency-server-1-java", ServerInstanceID: "server-1", PluginID: plugin.ID, ProbeKey: "java", TargetOS: "linux", TargetArch: "amd64", State: domain.DependencyStatePresent, Required: true, InstallPlanKey: "java-install", PlanDigest: planDigest, JobID: "job-dependency", Evidence: "OpenJDK 21", CompletedSteps: 1, Message: "dependency execution completed", CheckedAt: stamp, UpdatedAt: stamp}
|
|
if err := store.DependencyStatuses().Create(dependencyStatus); err != nil {
|
|
t.Fatalf("create dependency status: %v", err)
|
|
}
|
|
runUpdate := domain.RunUpdateJob{ID: "run-update-1", ServerInstanceID: "server-1", RunEndpointID: runEndpoint.ID, ArtifactID: "artifact-run-2", Checksum: planDigest, TargetOS: "linux", TargetArch: "amd64", TargetRelease: "release-2", PreviousVersion: "release-1", JobID: "job-update", IdempotencyKey: "run-update-idempotent", Status: domain.DistributionJobStatusFailed, Phase: domain.RunUpdatePhaseRolledBack, Message: "previous executable restored", Rollback: true, CreatedAt: stamp, UpdatedAt: stamp}
|
|
if err := store.RunUpdateJobs().Create(runUpdate); err != nil {
|
|
t.Fatalf("create Run update status: %v", err)
|
|
}
|
|
authSession := domain.AuthSessionRecord{ID: "auth-session-1", UserID: user.ID, TokenHash: strings.Repeat("a", 64), Status: domain.AuthSessionStatusActive, Generation: 1, IssuedAt: stamp, ExpiresAt: stamp.Add(time.Hour), LastSeenAt: stamp}
|
|
if err := store.AuthSessions().Create(authSession); err != nil {
|
|
t.Fatalf("create auth session: %v", err)
|
|
}
|
|
runSession := domain.RunControlSession{RunEndpointID: "run-local", SessionToken: "raw-run-token", SessionTokenHash: strings.Repeat("b", 64), Status: domain.AuthSessionStatusActive, Generation: 2, CapabilityFingerprint: "cap-v2", HeartbeatIntervalSeconds: 15, CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true, UsedNonces: []string{"nonce-1"}}
|
|
if err := store.RunControlSessions().Create(runSession); err != nil {
|
|
t.Fatalf("create run session: %v", err)
|
|
}
|
|
componentKey := domain.EncryptedComponentKey{ID: "component-key-1", ServerInstanceID: "server-1", ComponentKind: domain.DistributionComponentRun, EncryptedKey: "ciphertext-only", KeyHash: strings.Repeat("c", 64), Fingerprint: "fingerprint", SecretRef: "secret://components/server-1/run", Generation: 1, Status: domain.ComponentKeyStatusActive, CreatedAt: stamp, UpdatedAt: stamp}
|
|
if err := store.EncryptedComponentKeys().Create(componentKey); err != nil {
|
|
t.Fatalf("create component key: %v", err)
|
|
}
|
|
payload, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read snapshot: %v", err)
|
|
}
|
|
if strings.Contains(string(payload), "raw-run-token") {
|
|
t.Fatalf("snapshot exposed raw Run token: %s", payload)
|
|
}
|
|
if strings.Contains(string(payload), "raw-job-lease") {
|
|
t.Fatalf("snapshot exposed raw job lease: %s", payload)
|
|
}
|
|
|
|
reloaded, err := NewFileStore(path)
|
|
if err != nil {
|
|
t.Fatalf("reload file store: %v", err)
|
|
}
|
|
got, err := reloaded.Users().Get("user-1")
|
|
if err != nil {
|
|
t.Fatalf("get reloaded user: %v", err)
|
|
}
|
|
if got.DisplayName != user.DisplayName || got.Roles[0] != "platform-admin" {
|
|
t.Fatalf("unexpected reloaded user: %+v", got)
|
|
}
|
|
gotJob, err := reloaded.Jobs().GetByIdempotency("run-local", "idem-1")
|
|
if err != nil {
|
|
t.Fatalf("get reloaded job by idempotency: %v", err)
|
|
}
|
|
if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" || gotJob.Attempt != 2 || gotJob.RetryPolicy.MaxAttempts != 4 || gotJob.LeaseTokenHash != strings.Repeat("d", 64) || gotJob.ReconcileCount != 2 || gotJob.ExecutionInput.Content != "name=approved\n" || gotJob.ExecutionInput.Inputs["templateKey"] != "players.by-id" || gotJob.ExecutionInput.Inputs["playerId"] != "steam-123" || gotJob.ExecutionInput.Deployment == nil || gotJob.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || gotJob.ExecutionInput.ServerDeploymentPlan == nil || gotJob.ExecutionInput.ServerDeploymentPlan.Prerequisites[0].Key != "steamcmd" || gotJob.ExecutionResult.Content != "private-read" {
|
|
t.Fatalf("unexpected reloaded job: %+v", gotJob)
|
|
}
|
|
gotJob.ExecutionInput.Inputs["playerId"] = "mutated"
|
|
againJob, err := reloaded.Jobs().GetByIdempotency("run-local", "idem-1")
|
|
if err != nil {
|
|
t.Fatalf("get reloaded job again: %v", err)
|
|
}
|
|
if againJob.ExecutionInput.Inputs["playerId"] != "steam-123" {
|
|
t.Fatalf("expected reloaded job inputs to be isolated, got %+v", againJob.ExecutionInput.Inputs)
|
|
}
|
|
gotPlugin, err := reloaded.GamePlugins().Get(plugin.ID)
|
|
if err != nil || len(gotPlugin.RuntimeProfiles.LifecycleProfiles) != 1 || gotPlugin.RuntimeProfiles.LifecycleProfiles[0].Key != "local" {
|
|
t.Fatalf("unexpected reloaded runtime profiles: plugin=%+v err=%v", gotPlugin, err)
|
|
}
|
|
gotBindings, err := reloaded.RuntimeBindings().List(domain.RuntimeBindingFilter{ServerInstanceID: "server-1"})
|
|
if err != nil || len(gotBindings) != 1 || gotBindings[0].Bindings["rcon.password"] != "secret://server-1/rcon" {
|
|
t.Fatalf("unexpected reloaded runtime binding: bindings=%+v err=%v", gotBindings, err)
|
|
}
|
|
gotEndpoint, err := reloaded.RunEndpoints().Get(runEndpoint.ID)
|
|
if err != nil || gotEndpoint.Platform != "linux" || gotEndpoint.Architecture != "amd64" || gotEndpoint.Version != "release-1" {
|
|
t.Fatalf("unexpected reloaded Run target: endpoint=%+v err=%v", gotEndpoint, err)
|
|
}
|
|
gotDependencies, err := reloaded.DependencyStatuses().List(domain.DependencyStatusFilter{ServerInstanceID: "server-1"})
|
|
if err != nil || len(gotDependencies) != 1 || gotDependencies[0].PlanDigest != planDigest || gotDependencies[0].Evidence != "OpenJDK 21" {
|
|
t.Fatalf("unexpected reloaded dependency status: statuses=%+v err=%v", gotDependencies, err)
|
|
}
|
|
gotUpdates, err := reloaded.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: "server-1"})
|
|
if err != nil || len(gotUpdates) != 1 || gotUpdates[0].Phase != domain.RunUpdatePhaseRolledBack || !gotUpdates[0].Rollback || gotUpdates[0].TargetRelease != "release-2" {
|
|
t.Fatalf("unexpected reloaded Run update: updates=%+v err=%v", gotUpdates, err)
|
|
}
|
|
gotAuth, err := reloaded.AuthSessions().Get(authSession.ID)
|
|
if err != nil || gotAuth.TokenHash != authSession.TokenHash || gotAuth.Generation != 1 {
|
|
t.Fatalf("unexpected reloaded auth session: session=%+v err=%v", gotAuth, err)
|
|
}
|
|
gotRun, err := reloaded.RunControlSessions().Get(runSession.RunEndpointID)
|
|
if err != nil || gotRun.SessionToken != "" || gotRun.SessionTokenHash != runSession.SessionTokenHash || len(gotRun.UsedNonces) != 1 {
|
|
t.Fatalf("unexpected reloaded Run session: session=%+v err=%v", gotRun, err)
|
|
}
|
|
gotKey, err := reloaded.EncryptedComponentKeys().Get(componentKey.ID)
|
|
if err != nil || gotKey.EncryptedKey != componentKey.EncryptedKey || gotKey.SecretRef != componentKey.SecretRef {
|
|
t.Fatalf("unexpected reloaded component key metadata: key=%+v err=%v", gotKey, err)
|
|
}
|
|
}
|
|
|
|
func TestFileStoreNormalizesPluginVersionsAndPrunesLegacySCUMSnapshotData(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "metadata.json")
|
|
stalePlugin := domain.GamePlugin{ID: "game.scum.codex.20260804095301", Name: "SCUM Server", Version: "0.1.4", ServerType: "scum", ServerDisplayName: "SCUM Dedicated Server", ManifestRef: "plugins/examples/scum-server-plugin/game.scum.codex.20260804095301/manifest.json", Status: domain.GamePluginStatusInstalled}
|
|
latestPlugin := domain.GamePlugin{ID: "game.scum", Name: "SCUM Server", Version: "0.1.15", ServerType: "scum", ServerDisplayName: "SCUM Dedicated Server", ManifestRef: "artifact://manifests/game.scum/0.1.15", Status: domain.GamePluginStatusInstalled}
|
|
stamp := time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC)
|
|
snapshot := StoreSnapshot{
|
|
GamePlugins: []domain.GamePlugin{stalePlugin, latestPlugin},
|
|
ServerInstances: []domain.ServerInstance{{ID: "server-scum", PluginID: stalePlugin.ID, PluginVersion: stalePlugin.Version, Name: "SCUM", State: domain.ServerInstanceStateDraft, CreatedAt: stamp, UpdatedAt: stamp}},
|
|
RuntimeBindings: []domain.RuntimeBinding{{ID: "runtime-binding-server-scum", ServerInstanceID: "server-scum", PluginID: stalePlugin.ID, PluginVersion: stalePlugin.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: stamp, UpdatedAt: stamp}},
|
|
Jobs: []domain.Job{
|
|
{ID: "job-scum-sqlite-query", ServerInstanceID: "server-scum", RunEndpointID: "run-local", Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, IdempotencyKey: "legacy-query", State: domain.JobStateSucceeded, CreatedAt: stamp, UpdatedAt: stamp},
|
|
{ID: "job-scum-start", ServerInstanceID: "server-scum", RunEndpointID: "run-local", Capability: domain.JobCapabilityRemoteRunProgram, IdempotencyKey: "start", State: domain.JobStateSucceeded, CreatedAt: stamp, UpdatedAt: stamp},
|
|
},
|
|
PluginDataRecords: []domain.PluginDataRecord{
|
|
{ID: snapshotPluginDataID("server-scum", stalePlugin.ID, "scum_gifts", "starter"), PluginID: stalePlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_gifts", Key: "starter", Value: map[string]any{"name": "Starter"}, CreatedAt: stamp, UpdatedAt: stamp},
|
|
{ID: snapshotPluginDataID("server-scum", stalePlugin.ID, "scum_trajectories", "point-1"), PluginID: stalePlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_trajectories", Key: "point-1", Value: map[string]any{"source": "legacy"}, CreatedAt: stamp, UpdatedAt: stamp},
|
|
},
|
|
}
|
|
payload, err := json.Marshal(snapshot)
|
|
if err != nil {
|
|
t.Fatalf("marshal stale snapshot: %v", err)
|
|
}
|
|
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
|
t.Fatalf("write stale snapshot: %v", err)
|
|
}
|
|
|
|
store, err := NewFileStore(path)
|
|
if err != nil {
|
|
t.Fatalf("reload stale snapshot: %v", err)
|
|
}
|
|
plugins, err := store.GamePlugins().List(domain.GamePluginFilter{ServerType: "scum"})
|
|
if err != nil || len(plugins) != 1 || plugins[0].ID != latestPlugin.ID || plugins[0].Version != latestPlugin.Version {
|
|
t.Fatalf("expected only latest plugin, plugins=%+v err=%v", plugins, err)
|
|
}
|
|
server, err := store.ServerInstances().Get("server-scum")
|
|
if err != nil || server.PluginID != latestPlugin.ID || server.PluginVersion != latestPlugin.Version {
|
|
t.Fatalf("expected server reference migration, server=%+v err=%v", server, err)
|
|
}
|
|
binding, err := store.RuntimeBindings().Get("runtime-binding-server-scum")
|
|
if err != nil || binding.PluginID != latestPlugin.ID || binding.PluginVersion != latestPlugin.Version {
|
|
t.Fatalf("expected runtime binding migration, binding=%+v err=%v", binding, err)
|
|
}
|
|
config, err := store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: latestPlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_gifts"})
|
|
if err != nil || len(config) != 1 || config[0].Key != "starter" {
|
|
t.Fatalf("expected plugin-owned config data to migrate, data=%+v err=%v", config, err)
|
|
}
|
|
legacy, err := store.PluginDataRecords().List(domain.PluginDataFilter{ServerInstanceID: "server-scum", Collection: "scum_trajectories"})
|
|
if err != nil || len(legacy) != 0 {
|
|
t.Fatalf("expected legacy SCUM projection data to be pruned, data=%+v err=%v", legacy, err)
|
|
}
|
|
jobs, err := store.Jobs().List(domain.JobFilter{ServerInstanceID: "server-scum"})
|
|
if err != nil || len(jobs) != 2 {
|
|
t.Fatalf("expected plugin-declared SCUM sqlite query job to survive the snapshot, jobs=%+v err=%v", jobs, err)
|
|
}
|
|
}
|
|
|
|
func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
|
|
stamp := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC)
|
|
source := &MySQLStore{MemoryStore: NewMemoryStore()}
|
|
job := domain.Job{
|
|
ID: "job-mysql", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-mysql",
|
|
State: domain.JobStateRunning, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 3, InitialBackoffSeconds: 2, MaxBackoffSeconds: 60},
|
|
Attempt: 2, QueueEligibleAt: stamp, LeaseTokenHash: strings.Repeat("e", 64), LeaseSessionGen: 4,
|
|
LeaseExpiresAt: stamp.Add(time.Minute), LastProgressSeq: 8, CancelReason: "stop", CancelRequestedAt: stamp,
|
|
LastReconciledAt: stamp, ReconcileCount: 3, ReconcileOutcome: "confirmed active attempt", CreatedAt: stamp, UpdatedAt: stamp,
|
|
ExecutionInput: domain.JobExecutionInput{
|
|
WorkspaceScope: "local",
|
|
Content: "mysql-approved",
|
|
ExpectedVersion: 1,
|
|
ExpectedChecksum: "sha256:" + strings.Repeat("3", 64),
|
|
MaxReadBytes: 64 * 1024,
|
|
Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-456"},
|
|
Deployment: &domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ProfileKey: "local", CreateInputs: map[string]string{"gamePort": "27000"}, ServerRoot: "D:\\scumserver", Revision: 3},
|
|
ServerDeploymentPlan: &domain.ServerDeploymentPlan{
|
|
SchemaVersion: "1",
|
|
Operation: "install",
|
|
PluginID: "game.runtime",
|
|
},
|
|
},
|
|
ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, Summary: "atomic write"},
|
|
}
|
|
if err := source.MemoryStore.Jobs().Create(job); err != nil {
|
|
t.Fatalf("create source job: %v", err)
|
|
}
|
|
payload, err := json.Marshal(source.snapshot())
|
|
if err != nil {
|
|
t.Fatalf("marshal mysql snapshot: %v", err)
|
|
}
|
|
var snapshot StoreSnapshot
|
|
if err := json.Unmarshal(payload, &snapshot); err != nil {
|
|
t.Fatalf("unmarshal mysql snapshot: %v", err)
|
|
}
|
|
target := &MySQLStore{MemoryStore: NewMemoryStore()}
|
|
target.loadSnapshot(snapshot)
|
|
got, err := target.MemoryStore.Jobs().Get(job.ID)
|
|
if err != nil || got.Attempt != job.Attempt || got.LeaseTokenHash != job.LeaseTokenHash || got.LastProgressSeq != job.LastProgressSeq || got.ReconcileCount != job.ReconcileCount || got.ExecutionInput.Content != job.ExecutionInput.Content || got.ExecutionInput.Inputs["templateKey"] != "players.by-id" || got.ExecutionInput.Inputs["playerId"] != "steam-456" || got.ExecutionInput.Deployment == nil || got.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || got.ExecutionInput.ServerDeploymentPlan == nil || got.ExecutionInput.ServerDeploymentPlan.PluginID != "game.runtime" || got.ExecutionResult.Checksum != job.ExecutionResult.Checksum {
|
|
t.Fatalf("unexpected MySQL snapshot job: job=%+v err=%v", got, err)
|
|
}
|
|
got.ExecutionInput.Inputs["playerId"] = "mutated"
|
|
again, err := target.MemoryStore.Jobs().Get(job.ID)
|
|
if err != nil {
|
|
t.Fatalf("get MySQL snapshot job again: %v", err)
|
|
}
|
|
if again.ExecutionInput.Inputs["playerId"] != "steam-456" {
|
|
t.Fatalf("expected MySQL snapshot job inputs to be isolated, got %+v", again.ExecutionInput.Inputs)
|
|
}
|
|
}
|
|
|
|
func TestFileStorePersistsRunRuntimeStateSeparately(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "metadata.json")
|
|
store, err := NewFileStore(path)
|
|
if err != nil {
|
|
t.Fatalf("create file store: %v", err)
|
|
}
|
|
stamp := time.Date(2026, 7, 18, 10, 0, 0, 0, time.UTC)
|
|
if err := store.PluginDataRecords().Create(domain.PluginDataRecord{ID: "plugin-record-1", PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "players", Key: "steam-1", Value: map[string]any{"displayName": "Ada"}, CreatedAt: stamp, UpdatedAt: stamp}); err != nil {
|
|
t.Fatalf("create plugin data: %v", err)
|
|
}
|
|
mainBefore, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read main snapshot before runtime update: %v", err)
|
|
}
|
|
|
|
endpoint := domain.RunEndpoint{ID: "run-runtime", DisplayName: "Runtime Run", Version: "1.0.0", Platform: "linux", Architecture: "amd64", Status: domain.RunEndpointStatusOnline, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: stamp}
|
|
if err := store.RunEndpoints().Create(endpoint); err != nil {
|
|
t.Fatalf("create runtime endpoint: %v", err)
|
|
}
|
|
session := domain.RunControlSession{RunEndpointID: endpoint.ID, SessionToken: "raw-runtime-token", SessionTokenHash: strings.Repeat("a", 64), Status: domain.AuthSessionStatusActive, Generation: 1, CapabilityFingerprint: "cap-runtime", HeartbeatIntervalSeconds: 15, CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true, UsedNonces: []string{"nonce-1"}}
|
|
if err := store.RunControlSessions().Create(session); err != nil {
|
|
t.Fatalf("create runtime session: %v", err)
|
|
}
|
|
stream := domain.LogStream{ID: "run.run-runtime.server-1.scum.console.stdout", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "scum.console.stdout", LatestSeq: 42, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", CreatedAt: stamp, UpdatedAt: stamp}
|
|
if err := store.LogStreams().Create(stream); err != nil {
|
|
t.Fatalf("create runtime log stream: %v", err)
|
|
}
|
|
mainAfter, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read main snapshot after runtime update: %v", err)
|
|
}
|
|
if !bytes.Equal(mainBefore, mainAfter) {
|
|
t.Fatalf("runtime updates rewrote the main metadata snapshot")
|
|
}
|
|
runtimePayload, err := os.ReadFile(runtimeMetadataPath(path))
|
|
if err != nil {
|
|
t.Fatalf("read runtime snapshot: %v", err)
|
|
}
|
|
if !strings.Contains(string(runtimePayload), endpoint.ID) || !strings.Contains(string(runtimePayload), stream.ID) || strings.Contains(string(runtimePayload), session.SessionToken) {
|
|
t.Fatalf("unexpected runtime snapshot payload: %s", runtimePayload)
|
|
}
|
|
|
|
reloaded, err := NewFileStore(path)
|
|
if err != nil {
|
|
t.Fatalf("reload file store: %v", err)
|
|
}
|
|
if got, err := reloaded.RunEndpoints().Get(endpoint.ID); err != nil || got.Version != endpoint.Version {
|
|
t.Fatalf("runtime endpoint did not reload: %+v err=%v", got, err)
|
|
}
|
|
if got, err := reloaded.RunControlSessions().Get(endpoint.ID); err != nil || got.SessionTokenHash != session.SessionTokenHash || got.UsedNonces[0] != "nonce-1" {
|
|
t.Fatalf("runtime session did not reload: %+v err=%v", got, err)
|
|
}
|
|
if got, err := reloaded.LogStreams().Get(stream.ID); err != nil || got.LatestSeq != stream.LatestSeq {
|
|
t.Fatalf("runtime log stream did not reload: %+v err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "plugin-operations.json")
|
|
store, err := NewFileStore(path)
|
|
if err != nil {
|
|
t.Fatalf("create file store: %v", err)
|
|
}
|
|
stamp := time.Date(2026, 7, 18, 14, 0, 0, 0, time.UTC)
|
|
installation := domain.PluginLifecycleInstallation{
|
|
ID: "plugin-lifecycle-1", PluginID: "game.scum", ServerInstanceID: "server-1",
|
|
CurrentVersion: "1.0.0", TargetVersion: "2.0.0", PreviousVersion: "0.9.0",
|
|
DesiredState: domain.PluginLifecycleStateEnabled, CurrentState: domain.PluginLifecycleStateUpgrading,
|
|
LastOperation: domain.PluginLifecycleOperationUpgrade, Compatibility: "compatible",
|
|
DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade",
|
|
IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp,
|
|
}
|
|
diff := domain.AIConfigDiffPreview{
|
|
ID: "ai-config-diff-1", RequestID: "ai-request-1", CreatedBy: "operator-1",
|
|
ServerInstanceID: "server-1", PluginID: "game.scum", ProviderID: "ai.openai", Model: "gpt-4.1",
|
|
Key: "server.properties", ConfigVersion: 4, CurrentConfigChecksum: "sha256:" + strings.Repeat("a", 64),
|
|
ProposedConfig: "MaxPlayers=80\n", DiffSummary: "review required before config write dispatch",
|
|
State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(30 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp,
|
|
}
|
|
if err := store.PluginLifecycles().Create(installation); err != nil {
|
|
t.Fatalf("create plugin lifecycle: %v", err)
|
|
}
|
|
if err := store.AIConfigDiffs().Create(diff); err != nil {
|
|
t.Fatalf("create AI config diff: %v", err)
|
|
}
|
|
|
|
restarted, err := NewFileStore(path)
|
|
if err != nil {
|
|
t.Fatalf("restart file store: %v", err)
|
|
}
|
|
gotInstallation, lifecycleErr := restarted.PluginLifecycles().Get(installation.ID)
|
|
if lifecycleErr != nil || gotInstallation.CurrentState != installation.CurrentState || gotInstallation.TargetVersion != installation.TargetVersion || gotInstallation.JobID != installation.JobID {
|
|
t.Fatalf("unexpected durable plugin lifecycle: installation=%+v err=%v", gotInstallation, lifecycleErr)
|
|
}
|
|
gotDiff, diffErr := restarted.AIConfigDiffs().Get(diff.ID)
|
|
if diffErr != nil || gotDiff.State != diff.State || gotDiff.CurrentConfigChecksum != diff.CurrentConfigChecksum || gotDiff.ProposedConfig != diff.ProposedConfig {
|
|
t.Fatalf("unexpected durable AI config diff: diff=%+v err=%v", gotDiff, diffErr)
|
|
}
|
|
}
|
|
|
|
func TestMySQLStoreRequiresDSN(t *testing.T) {
|
|
_, err := NewMySQLStore("")
|
|
if err == nil || !strings.Contains(err.Error(), "PLATFORM_MYSQL_DSN") {
|
|
t.Fatalf("expected missing MySQL DSN error, got %v", err)
|
|
}
|
|
}
|