Restore durable log ingest and typed plugin projections
This commit is contained in:
@@ -231,7 +231,7 @@ func buildLifecycleDistribution(t *testing.T, svc *CoreService, session string,
|
||||
}
|
||||
payload := []byte("client-manager-package-" + version)
|
||||
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
|
||||
distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: idempotency})
|
||||
distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: idempotency})
|
||||
if err != nil {
|
||||
t.Fatalf("generate lifecycle distribution: %v", err)
|
||||
}
|
||||
|
||||
@@ -188,60 +188,29 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
profile, err := svc.clientManagerDistributionBuildProfile(distribution)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PlatformURL: runReleasePlatformURL(),
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
WorkspaceRef: profile.WorkspaceRef,
|
||||
EntryRef: profile.EntryRef,
|
||||
ConfigTemplateRef: clientManagerBuildConfigTemplateRef(profile),
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PlatformURL: runReleasePlatformURL(),
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
}, nil
|
||||
}
|
||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
||||
}
|
||||
|
||||
func (svc *CoreService) clientManagerDistributionBuildProfile(distribution domain.ClientManagerDistribution) (domain.RuntimeClientManagerProfile, error) {
|
||||
plugin, err := svc.store.GamePlugins().Get(distribution.PluginID)
|
||||
if err != nil {
|
||||
return domain.RuntimeClientManagerProfile{}, err
|
||||
}
|
||||
profile, err := findRuntimeClientManagerProfile(plugin, distribution.ProfileKey)
|
||||
if err != nil {
|
||||
return domain.RuntimeClientManagerProfile{}, err
|
||||
}
|
||||
if profile.RepositoryURL != distribution.RepositoryURL || !clientManagerProfileAllowsRevision(profile, distribution.SourceRevision) || !clientManagerProfileSupportsTarget(profile, distribution.TargetOS, distribution.TargetArch) {
|
||||
return domain.RuntimeClientManagerProfile{}, validationError("client-manager build no longer matches the declared profile")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func clientManagerBuildConfigTemplateRef(profile domain.RuntimeClientManagerProfile) string {
|
||||
for _, candidate := range profile.ConfigTemplates {
|
||||
if candidate.OutputRef == "config.yaml" && strings.TrimSpace(candidate.TemplateRef) != "" {
|
||||
return candidate.TemplateRef
|
||||
}
|
||||
}
|
||||
return "config.yaml.example"
|
||||
}
|
||||
|
||||
type runDistributionPackageContext struct {
|
||||
profileKey string
|
||||
workspaceSeed string
|
||||
@@ -277,39 +246,8 @@ func (svc *CoreService) runDistributionPackageContext(distribution domain.RunDis
|
||||
return runDistributionPackageContext{profileKey: profileKey, workspaceSeed: seed, autonomousLifecycle: plan}, nil
|
||||
}
|
||||
|
||||
func defaultRunDistributionProfileKey(plugin domain.GamePlugin, profileKey string) string {
|
||||
profileKey = strings.TrimSpace(profileKey)
|
||||
if profileKey != "" {
|
||||
if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey); ok {
|
||||
return profile.Key
|
||||
}
|
||||
return profileKey
|
||||
}
|
||||
return firstRuntimeLifecycleProfileKey(plugin)
|
||||
}
|
||||
|
||||
func lifecycleDefaultProfileKey(instance domain.ServerInstance, plugin domain.GamePlugin, profileKey string) string {
|
||||
profileKey = strings.TrimSpace(profileKey)
|
||||
if profileKey != "" {
|
||||
if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey); ok {
|
||||
return profile.Key
|
||||
}
|
||||
return profileKey
|
||||
}
|
||||
if instance.RunEndpointID != "" && instance.RunEndpointID == generatedRunEndpointID(instance.ID) {
|
||||
return firstRuntimeLifecycleProfileKey(plugin)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstRuntimeLifecycleProfileKey(plugin domain.GamePlugin) string {
|
||||
if len(plugin.RuntimeProfiles.LifecycleProfiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
return plugin.RuntimeProfiles.LifecycleProfiles[0].Key
|
||||
}
|
||||
|
||||
func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance domain.ServerInstance, plugin domain.GamePlugin, profileKey string, bindings map[string]string) (*domain.RunAutonomousLifecyclePlan, error) {
|
||||
privateBindings := autonomousDataTargetBindings(bindings, instance, plugin.RuntimeProfiles.DataTargets)
|
||||
plan := &domain.RunAutonomousLifecyclePlan{
|
||||
SchemaVersion: "1",
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -321,8 +259,8 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
TargetArch: distribution.TargetArch,
|
||||
TargetRelease: distribution.ID,
|
||||
DeploymentRevision: instance.Deployment.Revision,
|
||||
RuntimeBindings: domain.CopyStringMap(bindings),
|
||||
Deployment: autonomousDeploymentFromDefinition(instance.Deployment, profileKey, bindings),
|
||||
RuntimeBindings: privateBindings,
|
||||
Deployment: autonomousDeploymentFromDefinition(instance.Deployment, profileKey, privateBindings),
|
||||
}
|
||||
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey)
|
||||
for _, action := range []domain.ServerLifecycleAction{domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionStatus} {
|
||||
@@ -351,6 +289,11 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
plan.LogSources = append(plan.LogSources, autonomousLogSource(source))
|
||||
}
|
||||
}
|
||||
for _, target := range plugin.RuntimeProfiles.DataTargets {
|
||||
if runtimePlatformsContain(target.Platforms, distribution.TargetOS) {
|
||||
plan.DataTargets = append(plan.DataTargets, autonomousDataTarget(target))
|
||||
}
|
||||
}
|
||||
if hasProfile && len(profile.DLLExtensionRefs) > 0 {
|
||||
endpoint := domain.RunEndpoint{ID: distribution.RunEndpointID, Platform: distribution.TargetOS, Architecture: distribution.TargetArch}
|
||||
extensions, err := lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint)
|
||||
@@ -364,6 +307,19 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
return domain.CopyRunAutonomousLifecyclePlanPtr(plan), nil
|
||||
}
|
||||
|
||||
func autonomousDataTargetBindings(bindings map[string]string, instance domain.ServerInstance, targets []domain.RuntimeDataTarget) map[string]string {
|
||||
result := domain.CopyStringMap(bindings)
|
||||
if result == nil {
|
||||
result = map[string]string{}
|
||||
}
|
||||
for _, target := range targets {
|
||||
if result[target.SourceRootKey] == "" && target.SourceRootKey == "server-root" && instance.Deployment.ServerRoot != "" {
|
||||
result[target.SourceRootKey] = instance.Deployment.ServerRoot
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func autonomousLifecycleAction(plugin domain.GamePlugin, profile domain.RuntimeLifecycleProfile, hasProfile bool, action domain.ServerLifecycleAction) domain.RunAutonomousLifecycleAction {
|
||||
targetKey := ""
|
||||
if hasProfile {
|
||||
@@ -409,6 +365,10 @@ func autonomousDLLExtension(extension domain.RuntimeDLLExtensionPlan) domain.Run
|
||||
return domain.RunAutonomousDLLExtension{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}
|
||||
}
|
||||
|
||||
func autonomousDataTarget(target domain.RuntimeDataTarget) domain.RunAutonomousDataTarget {
|
||||
return domain.RunAutonomousDataTarget{Key: target.Key, Kind: target.Kind, TransportKey: target.TransportKey, SourceRootKey: target.SourceRootKey, SourcePath: target.SourcePath, WorkspaceKey: target.WorkspaceKey, RefreshPolicy: target.RefreshPolicy, MaxBytes: target.MaxBytes, Platforms: domain.CopyStringSlice(target.Platforms)}
|
||||
}
|
||||
|
||||
func autonomousDeploymentFromDefinition(definition domain.ServerDeploymentDefinition, profileKey string, bindings map[string]string) *domain.RunAutonomousDeployment {
|
||||
if definition.Mode == "" {
|
||||
return nil
|
||||
|
||||
@@ -101,9 +101,24 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
domain.RuntimeLogSource{Key: "console", Kind: "process.stdout", TargetKey: "server/process", StreamKey: "console", CursorKind: "sequence", RetentionDays: 14},
|
||||
domain.RuntimeLogSource{Key: "server-events", Kind: "file.tail", TargetKey: "logs/server", StreamKey: "scum.server", CursorKind: "fingerprint", RetentionDays: 90},
|
||||
)
|
||||
plugin.RuntimeProfiles.DataTargets = []domain.RuntimeDataTarget{{
|
||||
Key: "scum-database",
|
||||
Kind: "sqlite.snapshot",
|
||||
TransportKey: "scum-database",
|
||||
SourceRootKey: "server-root",
|
||||
SourcePath: "SCUM/Saved/SaveFiles/SCUM.db",
|
||||
WorkspaceKey: "databases/scum-database",
|
||||
RefreshPolicy: "on-demand-snapshot",
|
||||
MaxBytes: 1024 * 1024,
|
||||
Platforms: []string{"linux"},
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("seed plugin lifecycle assets: %v", err)
|
||||
}
|
||||
instance.Deployment.ServerRoot = "/srv/scum"
|
||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||
t.Fatalf("seed instance deployment root: %v", err)
|
||||
}
|
||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
@@ -149,6 +164,9 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
if len(plan.DependencyProbes) != 1 || plan.DependencyProbes[0].Key != "java-runtime" || len(plan.InstallPlans) != 1 || plan.InstallPlans[0].Key != "java-install" || len(plan.LogSources) != 1 || !hasAutonomousLogSource(plan.LogSources, "process.stdout", "console") || plan.RuntimeBindings["logs/latest"] != "runtime.logs.latest" {
|
||||
t.Fatalf("autonomous lifecycle plan lost plugin runtime declarations: %+v", plan)
|
||||
}
|
||||
if len(plan.DataTargets) != 1 || plan.DataTargets[0].Key != "scum-database" || plan.DataTargets[0].Kind != "sqlite.snapshot" || plan.DataTargets[0].SourcePath != "SCUM/Saved/SaveFiles/SCUM.db" || plan.RuntimeBindings["server-root"] != instance.Deployment.ServerRoot {
|
||||
t.Fatalf("autonomous lifecycle plan lost private database target bindings: %+v", plan)
|
||||
}
|
||||
if hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || hasAutonomousLogSource(plan.LogSources, "file.tail", "scum.server") {
|
||||
t.Fatalf("autonomous lifecycle plan must not carry file-tail sources into process.start: %+v", plan.LogSources)
|
||||
}
|
||||
@@ -214,55 +232,6 @@ func hasAutonomousLogSource(sources []domain.RunAutonomousLogSource, kind string
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCoreServiceRunDistributionDefaultsEmptyDeploymentProfileToPluginLifecycleProfile(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin := scumDeploymentTestPlugin()
|
||||
plugin.Name = "SCUM"
|
||||
plugin.Version = "0.1.1"
|
||||
plugin.ServerType = "scum"
|
||||
plugin.Status = domain.GamePluginStatusInstalled
|
||||
plugin.SupportedOS = []string{"windows"}
|
||||
plugin.DeclaredPermissions = []string{"server.run.distribution"}
|
||||
plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"}
|
||||
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "run-local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus}, ActionRefs: domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"}, Platforms: []string{"windows"}}}
|
||||
requireManualSCUMRuntimeBindings(&plugin, "run-local")
|
||||
if err := svc.store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("create SCUM plugin: %v", err)
|
||||
}
|
||||
session := createServiceUserAndLogin(t, svc, domain.User{ID: "scum-default-profile-owner", DisplayName: "SCUM Default Profile Owner", Email: "scum-default-profile@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
created, err := svc.CreateServerInstanceWorkflowForSession(session, domain.ServerLifecycleCreate{
|
||||
ID: "scum-default-profile-package", PluginID: plugin.ID, Name: "SCUM Default Profile Package", IdempotencyKey: "scum-default-profile-package-create",
|
||||
Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: `D:\scum-default-profile`, CreateInputs: map[string]string{"serverName": "Moon", "gamePort": "27000", "queryPort": "27015", "maxPlayers": "128"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create SCUM guided draft without explicit profile: %v", err)
|
||||
}
|
||||
if created.Instance.Deployment.ProfileKey != "run-local" {
|
||||
t.Fatalf("expected create defaults to persist plugin lifecycle profile, got %+v", created.Instance.Deployment)
|
||||
}
|
||||
|
||||
legacy := created.Instance
|
||||
legacy.Deployment.ProfileKey = ""
|
||||
if err := svc.store.ServerInstances().Update(legacy); err != nil {
|
||||
t.Fatalf("simulate legacy empty deployment profile: %v", err)
|
||||
}
|
||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||
svc.ConfigureDistributionBuilder(captureDistributionBuilder{inputs: inputs, payload: []byte("profile-default-build")})
|
||||
if _, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: legacy.ID, TargetOS: "windows", TargetArch: "amd64", IdempotencyKey: "scum-default-profile-package"}); err != nil {
|
||||
t.Fatalf("generate Run distribution with empty deployment profile: %v", err)
|
||||
}
|
||||
var platformInput domain.DistributionBuildInput
|
||||
select {
|
||||
case platformInput = <-inputs:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("platform builder did not receive profile-defaulted input")
|
||||
}
|
||||
plan := platformInput.AutonomousLifecycle
|
||||
if platformInput.ProfileKey != "run-local" || plan == nil || plan.ProfileKey != "run-local" || plan.Deployment == nil || plan.Deployment.ProfileKey != "run-local" {
|
||||
t.Fatalf("expected package context to default empty deployment profile to run-local, input=%+v plan=%+v", platformInput, plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceBuildsWithoutRegisteredDistributionWorker(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
bootstrapEndpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
|
||||
@@ -96,30 +96,23 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
profile, err := svc.clientManagerDistributionBuildProfile(distribution)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
WorkspaceRef: profile.WorkspaceRef,
|
||||
EntryRef: profile.EntryRef,
|
||||
ConfigTemplateRef: clientManagerBuildConfigTemplateRef(profile),
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
}, nil
|
||||
}
|
||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
||||
|
||||
@@ -508,7 +508,7 @@ EOF
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildServerInstanceID=$SERVER_INSTANCE_ID"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildPluginID=$PLUGIN_ID"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildComponentKind=$COMPONENT_KIND"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildComponentKey="
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildComponentKey=$PROFILE_KEY"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildKeyGeneration=$KEY_GENERATION"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildVersion=$TARGET_RELEASE"
|
||||
progress 48 'build_compile: downloading Go modules'
|
||||
@@ -533,39 +533,20 @@ git init --quiet
|
||||
git remote add origin "$REPOSITORY_URL"
|
||||
git fetch --quiet --depth 1 origin "$SOURCE_REVISION"
|
||||
git checkout --quiet --detach FETCH_HEAD
|
||||
workspace_ref="${CLIENT_MANAGER_WORKSPACE_REF:-}"
|
||||
entry_ref="${CLIENT_MANAGER_ENTRY_REF:-.}"
|
||||
config_template_ref="${CLIENT_MANAGER_CONFIG_TEMPLATE_REF:-config.yaml.example}"
|
||||
for safe_ref in "$workspace_ref" "$entry_ref" "$config_template_ref"; do
|
||||
case "$safe_ref" in
|
||||
*..*|*://*|/*|*\\*) printf 'client-manager build reference is unsafe\n' >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
build_workdir=/workspace/build
|
||||
if [ -n "$workspace_ref" ]; then
|
||||
build_workdir="/workspace/build/$workspace_ref"
|
||||
fi
|
||||
if [ ! -d "$build_workdir" ]; then
|
||||
printf 'client-manager workspace is missing\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
cd "$build_workdir"
|
||||
if [ ! -f "$config_template_ref" ]; then
|
||||
printf 'client-manager config template is missing\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
progress 36 'env_check: materializing client-manager configuration template'
|
||||
cp "$config_template_ref" config.yaml
|
||||
build_target="$entry_ref"
|
||||
case "$build_target" in
|
||||
''|.) build_target=. ;;
|
||||
./*) ;;
|
||||
*) build_target="./$build_target" ;;
|
||||
esac
|
||||
progress 36 'env_check: writing client-manager configuration'
|
||||
{
|
||||
printf 'server_url: "%s"\n' "$PLATFORM_URL"
|
||||
printf 'server_instance_id: "%s"\n' "$SERVER_INSTANCE_ID"
|
||||
printf 'scum_client_credential: "%s"\n' "$auth_key"
|
||||
printf 'scum_client_name: "%s"\n' "$PROFILE_KEY"
|
||||
printf 'scum_client_version: "platform-build"\n'
|
||||
printf 'scum_client_machine_label: "managed-client"\n'
|
||||
printf 'ftp_provider: 3\n'
|
||||
} > config.yaml
|
||||
progress 52 'deps_download: downloading Go modules'
|
||||
go mod download
|
||||
progress 76 'build_compile: compiling client-manager executable'
|
||||
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" "$build_target"
|
||||
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" .
|
||||
cp config.yaml /workspace/output/config.yaml
|
||||
progress 88 'package_finalize: client-manager package inputs written'
|
||||
`
|
||||
@@ -601,9 +582,6 @@ func (builder *DockerDistributionBuilder) containerArgs(input domain.Distributio
|
||||
"-e", "PLATFORM_URL=" + platformURL,
|
||||
"-e", "REPOSITORY_URL=" + input.RepositoryURL,
|
||||
"-e", "SOURCE_REVISION=" + input.SourceRevision,
|
||||
"-e", "CLIENT_MANAGER_WORKSPACE_REF=" + input.WorkspaceRef,
|
||||
"-e", "CLIENT_MANAGER_ENTRY_REF=" + input.EntryRef,
|
||||
"-e", "CLIENT_MANAGER_CONFIG_TEMPLATE_REF=" + input.ConfigTemplateRef,
|
||||
"-e", "OUTPUT_FILENAME=" + outputName,
|
||||
builder.config.Image,
|
||||
"/workspace/input/build.sh",
|
||||
|
||||
@@ -203,9 +203,6 @@ func TestDockerDistributionBuilderKeepsSecretInIsolatedInput(t *testing.T) {
|
||||
if bytes.Contains(script, []byte(secret)) {
|
||||
t.Fatal("build script must not embed the component auth key")
|
||||
}
|
||||
if bytes.Contains(script, []byte("BuildComponentKey=$PROFILE_KEY")) || !bytes.Contains(script, []byte("BuildComponentKey=")) {
|
||||
t.Fatalf("Run build script must not use lifecycle profile as component identity: %s", script)
|
||||
}
|
||||
for _, ordered := range [][2]string{{"find /workspace/source -mindepth 1 -maxdepth 1 ! -name pax_global_header", "run_command_dir=\"$(find /workspace/build/run-source -type d -path '*/cmd/run' -print -quit)\""}, {"run_command_dir=\"$(find /workspace/build/run-source -type d -path '*/cmd/run' -print -quit)\"", "run_module_dir=\"${run_command_dir%/cmd/run}\""}, {"run_module_dir=\"${run_command_dir%/cmd/run}\"", "mkdir -p \"$run_module_dir/config\""}, {"mkdir -p \"$run_module_dir/config\"", "workspace_seed_generated.go"}, {"workspace_seed_generated.go", "cd \"$run_module_dir\""}, {"cd \"$run_module_dir\"", "go build -trimpath -ldflags"}} {
|
||||
if bytes.Index(script, []byte(ordered[0])) >= bytes.Index(script, []byte(ordered[1])) {
|
||||
t.Fatalf("Run build script must prepare source before injecting config and compiling: %q before %q", ordered[0], ordered[1])
|
||||
@@ -230,7 +227,6 @@ func TestDockerDistributionBuilderKeepsSecretInIsolatedInput(t *testing.T) {
|
||||
ServerInstanceID: "server-one",
|
||||
PluginID: "game.scum",
|
||||
RunEndpointID: "server-run-server-one",
|
||||
ProfileKey: "run-local",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
TargetRelease: "release-one",
|
||||
|
||||
@@ -533,7 +533,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
||||
ProfileKey: "scum-client-manager",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
RepositoryURL: "https://git.npc0.com/admin343/browser.git",
|
||||
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
||||
SourceRevision: "main",
|
||||
IdempotencyKey: "idem-client-manager",
|
||||
})
|
||||
@@ -551,7 +551,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
||||
if err != nil {
|
||||
t.Fatalf("get build job: %v", err)
|
||||
}
|
||||
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://git.npc0.com/admin343/browser.git" || build.SourceRevision != "main" {
|
||||
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
|
||||
t.Fatalf("unexpected build job: %+v", build)
|
||||
}
|
||||
clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive"))
|
||||
@@ -565,7 +565,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
||||
ProfileKey: "scum-client-manager",
|
||||
TargetOS: "darwin",
|
||||
TargetArch: "amd64",
|
||||
RepositoryURL: "https://git.npc0.com/admin343/browser.git",
|
||||
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
||||
IdempotencyKey: "idem-client-manager-denied",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "targetOs") {
|
||||
@@ -625,7 +625,7 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
|
||||
plugin.RuntimeProfiles.DependencyProbes = []domain.RuntimeDependencyProbe{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
|
||||
plugin.RuntimeProfiles.InstallPlans = []domain.RuntimeInstallPlan{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []domain.RuntimeInstallStep{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
|
||||
plugin.RuntimeProfiles.LogSources = []domain.RuntimeLogSource{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
|
||||
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://git.npc0.com/admin343/browser.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", WorkspaceRef: "plugins/examples/scum-server-plugin/companion", EntryRef: "cmd/scum-companion", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin fixture: %v", err)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func seedGameClientBridgeComponentSession(t *testing.T, svc *CoreService, now time.Time, token string) (domain.ClientManagerInstallation, domain.ClientManagerSession) {
|
||||
t.Helper()
|
||||
installation := domain.ClientManagerInstallation{ID: "installation-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", RunEndpointID: "run-1", Status: domain.ClientManagerLifecycleOnline, ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3}
|
||||
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability, gameClientBridgeLogStreamCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||
key := domain.EncryptedComponentKey{ID: "key-1", ServerInstanceID: installation.ServerInstanceID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: installation.ProfileKey, Generation: installation.KeyGeneration, Status: domain.ComponentKeyStatusActive}
|
||||
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -80,34 +80,6 @@ func TestGameClientBridgeComponentSessionAuthorizesCommandsAndSnapshots(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeComponentSessionAuthorizesLiveLogStream(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const token = "component-session-token"
|
||||
_, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||
if err := svc.store.RunEndpoints().Create(domain.RunEndpoint{ID: session.RunEndpointID, DisplayName: "Component Run", Status: domain.RunEndpointStatusOnline, Capabilities: []string{"process.start", "logs.read"}, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: *clock}); err != nil {
|
||||
t.Fatalf("seed run endpoint: %v", err)
|
||||
}
|
||||
server := domain.ServerInstance{ID: session.ServerInstanceID, PluginID: "game.scum", PluginVersion: "1.0.0", RunEndpointID: session.RunEndpointID, Name: "SCUM"}
|
||||
if err := svc.store.ServerInstances().Create(server); err != nil {
|
||||
t.Fatalf("create server instance: %v", err)
|
||||
}
|
||||
instance, err := svc.AuthorizeGameClientBridgeLogStream(domain.GameClientBridgeLogStreamRequest{SessionToken: token})
|
||||
if err != nil || instance.ID != server.ID || instance.PluginID != server.PluginID {
|
||||
t.Fatalf("authorize companion log stream: instance=%#v err=%v", instance, err)
|
||||
}
|
||||
if instance.RunEndpointID != session.RunEndpointID {
|
||||
t.Fatalf("authorized stream was not bound to component server: %#v", instance)
|
||||
}
|
||||
|
||||
session.Capabilities = []string{"component.heartbeat", gameClientBridgeCapability}
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.AuthorizeGameClientBridgeLogStream(domain.GameClientBridgeLogStreamRequest{SessionToken: token}); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected missing logs.stream rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeClaimCannotBeCompletedByAnotherCurrentSession(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const firstToken = "component-session-token-one"
|
||||
|
||||
@@ -12,7 +12,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
store := repo.NewMemoryStore()
|
||||
plugin := domain.GamePlugin{ID: "game.scum", Name: "SCUM", Version: "1.0.0", ServerType: "scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability, gameClientBridgeLogStreamCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||
plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||
if err := store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("seed bridge plugin: %v", err)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,17 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
|
||||
}
|
||||
job, ok := firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
|
||||
if !ok {
|
||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
||||
if err := svc.scheduleDuePluginQueryProjectionJobs(claim, stamp); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
|
||||
if err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
job, ok = firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
|
||||
if !ok {
|
||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
||||
}
|
||||
}
|
||||
|
||||
leaseToken, err := randomToken()
|
||||
@@ -343,6 +353,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectPluginQueryJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ const (
|
||||
type LogEventSubscriptionEvent struct {
|
||||
Kind LogEventSubscriptionEventKind
|
||||
LogEvent domain.LogStreamEvent
|
||||
Live bool
|
||||
ServerInstanceID string
|
||||
ProcessState domain.ServerInstanceState
|
||||
}
|
||||
@@ -67,14 +66,6 @@ func (svc *CoreService) SubscribeLogEventsForSession(sessionID string, serverIns
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLogEvents(stream domain.LogStream, entries []domain.LogEntry) {
|
||||
svc.publishLogEventsWithMode(stream, entries, false)
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLiveLogEvents(stream domain.LogStream, entries []domain.LogEntry) {
|
||||
svc.publishLogEventsWithMode(stream, entries, true)
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLogEventsWithMode(stream domain.LogStream, entries []domain.LogEntry, live bool) {
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -82,7 +73,6 @@ func (svc *CoreService) publishLogEventsWithMode(stream domain.LogStream, entrie
|
||||
for index, entry := range entries {
|
||||
events[index] = LogEventSubscriptionEvent{
|
||||
Kind: LogEventSubscriptionEventLog,
|
||||
Live: live,
|
||||
LogEvent: domain.CopyLogStreamEvent(domain.LogStreamEvent{
|
||||
ServerInstanceID: stream.ServerInstanceID,
|
||||
Stream: stream,
|
||||
|
||||
@@ -12,65 +12,6 @@ import (
|
||||
|
||||
const defaultLogQueryLimit = 100
|
||||
|
||||
// RelayLiveLogBatch forwards output observed by Run to live subscribers. It
|
||||
// intentionally updates only stream metadata; the log body is not written to
|
||||
// the platform log store. Game plugins own durable log storage and analysis.
|
||||
func (svc *CoreService) RelayLiveLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
|
||||
batch = domain.CopyLogBatchIngest(batch)
|
||||
if err := validator.ValidateLogBatchIngest(batch); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
|
||||
lock := svc.logIngestLock(batch.ServerInstanceID)
|
||||
lock.Lock()
|
||||
stamp := svc.now()
|
||||
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
if repairErr := svc.ensureLogStreamForBatch(batch, stamp); repairErr != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, repairErr
|
||||
}
|
||||
stream, err = svc.store.LogStreams().Get(batch.LogStreamID)
|
||||
}
|
||||
if err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := validateLogBatchStream(batch, stream); err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if batch.LastSeq > stream.LatestSeq {
|
||||
stream.LatestSeq = batch.LastSeq
|
||||
}
|
||||
stream.UpdatedAt = stamp
|
||||
if err := svc.store.LogStreams().Update(stream); err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
lock.Unlock()
|
||||
|
||||
entries := domain.CopyLogEntries(batch.Entries)
|
||||
// Run may be on a machine whose wall clock is skewed. Relay time is the
|
||||
// authoritative observation time for this best-effort live event; using it
|
||||
// keeps the SSE live boundary from treating current output as old history.
|
||||
for index := range entries {
|
||||
entries[index].Timestamp = stamp.Add(time.Duration(index) * time.Nanosecond)
|
||||
}
|
||||
svc.publishLiveLogEvents(stream, entries)
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
AcceptedFrom: batch.FirstSeq,
|
||||
AcceptedTo: batch.LastSeq,
|
||||
LatestSeq: stream.LatestSeq,
|
||||
ServerTime: stamp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
|
||||
batch = domain.CopyLogBatchIngest(batch)
|
||||
if err := validator.ValidateLogBatchIngest(batch); err != nil {
|
||||
@@ -110,9 +51,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
if exists && record.LastSeq == batch.LastSeq && logBatchRecordMatches(record, batch) {
|
||||
locked = false
|
||||
lock.Unlock()
|
||||
if err := svc.projectPluginLogBatch(stream, storedLogEntries(batch.Entries)); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
@@ -130,7 +68,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}
|
||||
|
||||
storedBatch := domain.CopyLogBatchIngest(batch)
|
||||
sanitizeLogNetworkFields(&storedBatch)
|
||||
record := domain.CopyLogBatchRecord(domain.LogBatchRecord{
|
||||
Checksum: batch.Checksum,
|
||||
FirstSeq: batch.FirstSeq,
|
||||
@@ -147,9 +84,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}
|
||||
locked = false
|
||||
lock.Unlock()
|
||||
if err := svc.projectPluginLogBatch(stream, storedBatch.Entries); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
svc.publishLogEvents(stream, storedBatch.Entries)
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
@@ -162,10 +96,7 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}
|
||||
|
||||
func storedLogEntries(entries []domain.LogEntry) []domain.LogEntry {
|
||||
stored := domain.CopyLogEntries(entries)
|
||||
batch := domain.LogBatchIngest{Entries: stored}
|
||||
sanitizeLogNetworkFields(&batch)
|
||||
return batch.Entries
|
||||
return domain.CopyLogEntries(entries)
|
||||
}
|
||||
|
||||
func (svc *CoreService) ensureJobLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error {
|
||||
@@ -209,7 +140,7 @@ func (svc *CoreService) ensureRunLogStreamForBatch(batch domain.LogBatchIngest,
|
||||
expectedStreamID = runSessionLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.LogSessionID, batch.StreamKey)
|
||||
}
|
||||
if batch.LogStreamID != expectedStreamID {
|
||||
if !legacySessionRunLogStream(batch) && !legacyAutonomousLogStream(batch) {
|
||||
if batch.LogSessionID != "" || !legacyAutonomousLogStream(batch) {
|
||||
return repo.ErrNotFound
|
||||
}
|
||||
}
|
||||
@@ -227,10 +158,6 @@ func (svc *CoreService) ensureRunLogStreamForBatch(batch domain.LogBatchIngest,
|
||||
return err
|
||||
}
|
||||
|
||||
func legacySessionRunLogStream(batch domain.LogBatchIngest) bool {
|
||||
return batch.LogSessionID != "" && batch.LogStreamID == runLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.StreamKey)
|
||||
}
|
||||
|
||||
func legacyAutonomousLogStream(batch domain.LogBatchIngest) bool {
|
||||
jobID, ok := jobIDFromLogBatch(batch)
|
||||
return ok && strings.HasPrefix(jobID, "autonomous-")
|
||||
@@ -260,19 +187,6 @@ func logBatchRecordMatches(record domain.LogBatchRecord, batch domain.LogBatchIn
|
||||
return false
|
||||
}
|
||||
|
||||
// sanitizeLogNetworkFields removes raw network material before the durable log body is written.
|
||||
func sanitizeLogNetworkFields(batch *domain.LogBatchIngest) {
|
||||
for index := range batch.Entries {
|
||||
fields := batch.Entries[index].Fields
|
||||
if fields == nil {
|
||||
continue
|
||||
}
|
||||
delete(fields, "networkFingerprint")
|
||||
delete(fields, "ip")
|
||||
delete(fields, "ipAddress")
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *CoreService) QueryLogStream(query domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) {
|
||||
if err := validator.ValidateLogStreamCursorQuery(query); err != nil {
|
||||
return domain.LogStreamCursorResult{}, err
|
||||
|
||||
@@ -92,45 +92,6 @@ func TestCoreServicePublishesLogEventsForAcceptedBatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRelaysLiveBatchWithoutPersistingBody(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
createLogStreamFixture(t, svc)
|
||||
subscription, err := svc.SubscribeLogEvents("server-1")
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe log events: %v", err)
|
||||
}
|
||||
defer subscription.Close()
|
||||
|
||||
batch := validLogBatch(t, sessionToken, 1, 1)
|
||||
batch.Entries[0].Timestamp = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
batch.Checksum, err = validator.LogEntriesChecksum(batch.Entries)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum live batch: %v", err)
|
||||
}
|
||||
ack, err := svc.RelayLiveLogBatch(batch)
|
||||
if err != nil || !ack.Accepted || ack.LatestSeq != 1 {
|
||||
t.Fatalf("relay live batch: ack=%+v err=%v", ack, err)
|
||||
}
|
||||
query, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: batch.LogStreamID, AfterSeq: 0, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("query relayed log stream: %v", err)
|
||||
}
|
||||
if len(query.Entries) != 0 || query.LatestSeq != 1 {
|
||||
t.Fatalf("live relay wrote a platform log body: %+v", query)
|
||||
}
|
||||
select {
|
||||
case event := <-subscription.Events:
|
||||
if !event.Live {
|
||||
t.Fatalf("expected live relay event marker: %+v", event)
|
||||
}
|
||||
if event.LogEvent.Entry.Timestamp.Before(fixedTime) {
|
||||
t.Fatalf("live relay kept stale source timestamp: %+v", event.LogEvent.Entry)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected relayed live log event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServicePersistsAndEnforcesImmutableProcessLogSessionMetadata(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
startedAt := time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
|
||||
@@ -480,47 +441,14 @@ func TestCoreServiceAcceptsLegacyAutonomousJobLogStreamWithoutPlatformJob(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceAcceptsSessionMetadataOnLegacyAutonomousStreamID(t *testing.T) {
|
||||
func TestCoreServiceRejectsSessionMetadataOnLegacyAutonomousStreamID(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
batch := validLogBatch(t, sessionToken, 1, 1)
|
||||
batch.LogStreamID = jobLogStreamID("autonomous-bootstrap-start", "stdout")
|
||||
batch.LogSessionID = "session-a"
|
||||
batch.SessionStartedAt = time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
|
||||
ack, err := svc.IngestLogBatch(batch)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest session-scoped legacy autonomous stream: %v", err)
|
||||
}
|
||||
if !ack.Accepted || ack.LogStreamID != batch.LogStreamID || ack.LatestSeq != batch.LastSeq {
|
||||
t.Fatalf("unexpected legacy autonomous session ack: %+v", ack)
|
||||
}
|
||||
stream, err := svc.GetLogStream(batch.LogStreamID)
|
||||
if err != nil {
|
||||
t.Fatalf("get session-scoped legacy autonomous stream: %v", err)
|
||||
}
|
||||
if stream.LogSessionID != batch.LogSessionID || !stream.SessionStartedAt.Equal(batch.SessionStartedAt) {
|
||||
t.Fatalf("session metadata was not persisted: %+v", stream)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceAcceptsSessionMetadataOnLegacyRunStreamID(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
batch := validLogBatch(t, sessionToken, 1, 1)
|
||||
batch.LogStreamID = runLogStreamID("run-local", "server-1", "stdout")
|
||||
batch.LogSessionID = "session-a"
|
||||
batch.SessionStartedAt = time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
|
||||
ack, err := svc.IngestLogBatch(batch)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest session-scoped legacy run stream: %v", err)
|
||||
}
|
||||
if !ack.Accepted || ack.LogStreamID != batch.LogStreamID || ack.LatestSeq != batch.LastSeq {
|
||||
t.Fatalf("unexpected legacy run session ack: %+v", ack)
|
||||
}
|
||||
stream, err := svc.GetLogStream(batch.LogStreamID)
|
||||
if err != nil {
|
||||
t.Fatalf("get session-scoped legacy run stream: %v", err)
|
||||
}
|
||||
if stream.LogSessionID != batch.LogSessionID || !stream.SessionStartedAt.Equal(batch.SessionStartedAt) {
|
||||
t.Fatalf("session metadata was not persisted: %+v", stream)
|
||||
if _, err := svc.IngestLogBatch(batch); err == nil {
|
||||
t.Fatal("expected session-scoped batch with legacy autonomous stream ID to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,6 +529,31 @@ func TestFileLogBodyStoreReloadsBatchesAndCursorEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileLogBodyStoreReloadsVerbatimLongLogLine(t *testing.T) {
|
||||
rootDir := filepath.Join(t.TempDir(), "logs")
|
||||
store, err := NewFileLogBodyStore(rootDir)
|
||||
if err != nil {
|
||||
t.Fatalf("create file log store: %v", err)
|
||||
}
|
||||
line := strings.Repeat("log-output-", 16*1024)
|
||||
entries := []domain.LogEntry{{Seq: 1, Timestamp: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), Line: line}}
|
||||
checksum, err := validator.LogEntriesChecksum(entries)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum: %v", err)
|
||||
}
|
||||
if err := store.AppendBatch("log-long", domain.LogBatchRecord{Checksum: checksum, FirstSeq: 1, LastSeq: 1, Entries: entries}); err != nil {
|
||||
t.Fatalf("append long log batch: %v", err)
|
||||
}
|
||||
reloaded, err := NewFileLogBodyStore(rootDir)
|
||||
if err != nil {
|
||||
t.Fatalf("reload long log store: %v", err)
|
||||
}
|
||||
batch, exists, err := reloaded.GetBatch("log-long", 1)
|
||||
if err != nil || !exists || len(batch.Entries) != 1 || batch.Entries[0].Line != line {
|
||||
t.Fatalf("long log line changed after reload: batch=%+v exists=%t err=%v", batch, exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newRegisteredLogIngestService(t *testing.T) (*CoreService, string) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
|
||||
@@ -137,7 +137,6 @@ func TestDeclaredSQLiteQueryDoesNotMutatePluginOrPlatformUserData(t *testing.T)
|
||||
func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) {
|
||||
svc, plugin, endpoint, _, _ := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
|
||||
plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{Collection: "scum_users", RowPath: "rows", UpsertKeys: []string{"steamId"}}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("enable query refresh hint: %v", err)
|
||||
}
|
||||
@@ -158,8 +157,9 @@ func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredSQLiteQueryProjectionsAreNotAppliedByPlatform(t *testing.T) {
|
||||
func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) {
|
||||
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
|
||||
plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{
|
||||
Collection: "scum_users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"},
|
||||
FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt",
|
||||
@@ -168,38 +168,39 @@ func TestDeclaredSQLiteQueryProjectionsAreNotAppliedByPlatform(t *testing.T) {
|
||||
FieldMappings: map[string]string{"className": "displayName"}, FixedValues: map[string]string{"code": "#spawnvehicle {{displayName}}", "spawnCommand": "#spawnvehicle {{displayName}}", "catalogType": "vehicle", "type": "21", "typeName": "其他载具", "imagePath": "/original/{{displayName}}.webp", "source": "sqlite"}, ObservedAtField: "lastSeenAt", MergeExisting: true,
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("store legacy query projection declarations: %v", err)
|
||||
t.Fatalf("enable query projection polling: %v", err)
|
||||
}
|
||||
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-projection-ignored-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-projection-ignored-1", "input.templateKey": "players.by-id",
|
||||
}})
|
||||
if err != nil || queued.Status != "queued" {
|
||||
t.Fatalf("queue declared query=%+v err=%v", queued, err)
|
||||
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods", Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: "#spawnvehicle Truck", Value: map[string]any{"code": "#spawnvehicle Truck", "name": "Named Truck"}}}}); err != nil {
|
||||
t.Fatalf("seed vehicle catalog: %v", err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-projection-ignored"
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-scheduler"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job == nil {
|
||||
t.Fatalf("declared query job was not claimable: %+v err=%v", claim, err)
|
||||
t.Fatalf("projection query was not scheduled: %+v err=%v", claim, err)
|
||||
}
|
||||
if claim.Job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || claim.Job.ExecutionInput.Inputs["sqlRef"] != "sql/players.by-id.sql" {
|
||||
t.Fatalf("declared query lost template inputs: %+v", claim.Job.ExecutionInput.Inputs)
|
||||
t.Fatalf("scheduled projection query lost template inputs: %+v", claim.Job.ExecutionInput.Inputs)
|
||||
}
|
||||
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"kind":"player","steamId":"steam-1","displayName":"Ada"},{"kind":"vehicle","steamId":"vehicle-1","displayName":"Truck"}]}`}})
|
||||
if err != nil {
|
||||
t.Fatalf("complete declared query job: %v", err)
|
||||
t.Fatalf("complete projection query job: %v", err)
|
||||
}
|
||||
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("platform applied query projection to plugin data=%+v err=%v", items, err)
|
||||
if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" || items[0].Value["source"] != "sqlite" || items[0].Value["sampledAt"] == nil {
|
||||
t.Fatalf("declared projection did not write scoped plugin data=%+v err=%v", items, err)
|
||||
}
|
||||
goods, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods"})
|
||||
if err != nil || len(goods) != 0 {
|
||||
t.Fatalf("platform applied vehicle catalog query projection=%+v err=%v", goods, err)
|
||||
if err != nil || len(goods) != 1 || goods[0].Key != "#spawnvehicle Truck" || goods[0].Value["name"] != "Named Truck" || goods[0].Value["className"] != "Truck" || goods[0].Value["type"] != "21" || goods[0].Value["lastSeenAt"] == nil {
|
||||
t.Fatalf("declared vehicle catalog projection did not merge scoped plugin data=%+v err=%v", goods, err)
|
||||
}
|
||||
second, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || second.HasJob {
|
||||
t.Fatalf("fresh projection poll should not reschedule immediately: %+v err=%v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (svc *CoreService) applyPluginBulkProjection(instance domain.ServerInstance
|
||||
func pluginBulkProjectionValues(fixedValues map[string]string, stamp time.Time, row map[string]any, observedAtField string) map[string]any {
|
||||
value := make(map[string]any, len(fixedValues)+1)
|
||||
for key, fixed := range fixedValues {
|
||||
value[key] = renderPluginValueTemplate(fixed, row)
|
||||
value[key] = renderQueryProjectionTemplate(fixed, row)
|
||||
}
|
||||
if observedAtField != "" {
|
||||
value[observedAtField] = stamp.UTC().Format(time.RFC3339Nano)
|
||||
@@ -78,18 +78,10 @@ func pluginBulkActivityValue(target domain.GameClientBridgeBulkActivityTargetDec
|
||||
value[destination] = row[source]
|
||||
}
|
||||
for key, fixed := range target.FixedValues {
|
||||
value[key] = renderPluginValueTemplate(fixed, row)
|
||||
value[key] = renderQueryProjectionTemplate(fixed, row)
|
||||
}
|
||||
if target.ObservedAtField != "" {
|
||||
value[target.ObservedAtField] = stamp.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func renderPluginValueTemplate(template string, row map[string]any) string {
|
||||
result := template
|
||||
for key, value := range row {
|
||||
result = strings.ReplaceAll(result, "{{"+key+"}}", fmt.Sprint(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func (svc *CoreService) scheduleDuePluginQueryProjectionJobs(claim domain.RunJobClaim, stamp time.Time) error {
|
||||
if !containsString(claim.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
return nil
|
||||
}
|
||||
endpoint, err := svc.store.RunEndpoints().Get(claim.RunEndpointID)
|
||||
if err != nil || !containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
return err
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: claim.RunEndpointID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, instance := range instances {
|
||||
if instance.State != domain.ServerInstanceStateRunning || strings.TrimSpace(instance.PluginID) == "" {
|
||||
continue
|
||||
}
|
||||
plugin, pluginErr := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if pluginErr != nil || !plugin.Permissions.RemoteAccess || !containsString(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) || !containsString(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
continue
|
||||
}
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.PollIntervalSeconds <= 0 || len(template.Projections) == 0 || !pluginQueryTemplateTransportReady(plugin, endpoint, template) {
|
||||
continue
|
||||
}
|
||||
interval := time.Duration(template.PollIntervalSeconds) * time.Second
|
||||
prefix := pluginQueryPollPrefix(instance.ID, plugin.ID, template.Key)
|
||||
if pluginQueryPollActiveOrFresh(jobs, prefix, stamp, interval) {
|
||||
continue
|
||||
}
|
||||
bucket := stamp.Unix() / int64(template.PollIntervalSeconds)
|
||||
idempotencyKey := fmt.Sprintf("%s%d", prefix, bucket)
|
||||
inputs := map[string]string{"templateKey": template.Key, "maxRows": fmt.Sprint(template.MaxRows)}
|
||||
if template.SQLRef != "" {
|
||||
inputs["sqlRef"] = template.SQLRef
|
||||
}
|
||||
job := domain.Job{
|
||||
ID: jobIDFromParts("job-plugin-query-poll", instance.ID, idempotencyKey),
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
TargetKey: template.TargetKey,
|
||||
InputRef: fmt.Sprintf("input://plugin-query-poll/%s/%s", instance.ID, template.Key),
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "plugin query projection poll queued"},
|
||||
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 2, MaxBackoffSeconds: 2},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: string(domain.RemoteAdapterDatabase), TimeoutSeconds: template.TimeoutSeconds, PluginID: plugin.ID, Inputs: inputs},
|
||||
}
|
||||
if _, createErr := svc.CreateJob(job); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginQueryTemplateTransportReady(plugin domain.GamePlugin, endpoint domain.RunEndpoint, template domain.GameClientBridgeQueryTemplateDeclaration) bool {
|
||||
if template.Engine != "sqlite" || template.TransportKey == "" || template.TargetKey == "" {
|
||||
return false
|
||||
}
|
||||
for _, profile := range plugin.RuntimeProfiles.TransportProfiles {
|
||||
if profile.Key == template.TransportKey && profile.Kind == "sqlite" && profile.TargetKey == template.TargetKey && containsString(profile.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
return containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pluginQueryPollPrefix(serverID, pluginID, templateKey string) string {
|
||||
return fmt.Sprintf("plugin-query-poll:%s:%s:%s:", serverID, pluginID, templateKey)
|
||||
}
|
||||
|
||||
func pluginQueryPollActiveOrFresh(jobs []domain.Job, prefix string, stamp time.Time, interval time.Duration) bool {
|
||||
for _, job := range jobs {
|
||||
if !strings.HasPrefix(job.IdempotencyKey, prefix) {
|
||||
continue
|
||||
}
|
||||
if !isTerminalJobState(job.State) {
|
||||
return true
|
||||
}
|
||||
freshAt := job.TerminalAt
|
||||
if freshAt.IsZero() {
|
||||
freshAt = job.UpdatedAt
|
||||
}
|
||||
if !freshAt.IsZero() && stamp.Sub(freshAt) < interval {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectPluginQueryJobResult(job domain.Job, stamp time.Time) error {
|
||||
if job.State != domain.JobStateSucceeded || job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.ExecutionResult.Kind != "sqlite.query" {
|
||||
return nil
|
||||
}
|
||||
templateKey := strings.TrimSpace(job.ExecutionInput.Inputs["templateKey"])
|
||||
if templateKey == "" || strings.TrimSpace(job.ServerInstanceID) == "" {
|
||||
return nil
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template, ok := pluginQueryTemplateByKey(plugin, templateKey)
|
||||
if !ok || len(template.Projections) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := pluginQueryRows(job.ExecutionResult.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mutationsByCollection := map[string]map[string]domain.PluginDataMutation{}
|
||||
for _, projection := range template.Projections {
|
||||
if projection.RowPath != "rows" {
|
||||
continue
|
||||
}
|
||||
collectionMutations := mutationsByCollection[projection.Collection]
|
||||
if collectionMutations == nil {
|
||||
collectionMutations = map[string]domain.PluginDataMutation{}
|
||||
mutationsByCollection[projection.Collection] = collectionMutations
|
||||
}
|
||||
for _, row := range rows {
|
||||
if projection.MatchField != "" && strings.TrimSpace(fmt.Sprint(row[projection.MatchField])) != projection.MatchValue {
|
||||
continue
|
||||
}
|
||||
value := pluginQueryProjectionValue(projection, row, stamp)
|
||||
key, keyErr := pluginDataRowKey(value, projection.UpsertKeys)
|
||||
if keyErr != nil {
|
||||
return keyErr
|
||||
}
|
||||
if projection.MergeExisting {
|
||||
if existing, existingErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, projection.Collection, key)); existingErr == nil {
|
||||
value = mergePluginDataValues(existing.Value, value)
|
||||
} else if !errors.Is(existingErr, repo.ErrNotFound) {
|
||||
return existingErr
|
||||
}
|
||||
}
|
||||
collectionMutations[key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value}
|
||||
}
|
||||
}
|
||||
for collection, keyed := range mutationsByCollection {
|
||||
mutations := make([]domain.PluginDataMutation, 0, len(keyed))
|
||||
for _, mutation := range keyed {
|
||||
mutations = append(mutations, mutation)
|
||||
}
|
||||
if len(mutations) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: collection, Mutations: mutations}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginQueryTemplateByKey(plugin domain.GamePlugin, templateKey string) (domain.GameClientBridgeQueryTemplateDeclaration, bool) {
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.Key == templateKey {
|
||||
return template, true
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, false
|
||||
}
|
||||
|
||||
func pluginQueryRows(content string) ([]map[string]any, error) {
|
||||
var payload struct {
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewBufferString(content))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return nil, validationError("sqlite query result content is not a row payload")
|
||||
}
|
||||
return payload.Rows, nil
|
||||
}
|
||||
|
||||
func pluginQueryProjectionValue(projection domain.GameClientBridgeQueryProjectionDeclaration, row map[string]any, observedAt time.Time) map[string]any {
|
||||
value := map[string]any{}
|
||||
if len(projection.FieldMappings) == 0 {
|
||||
for key, item := range row {
|
||||
value[key] = item
|
||||
}
|
||||
} else {
|
||||
for destination, source := range projection.FieldMappings {
|
||||
value[destination] = row[source]
|
||||
}
|
||||
}
|
||||
for key, fixed := range projection.FixedValues {
|
||||
value[key] = renderQueryProjectionTemplate(fixed, row)
|
||||
}
|
||||
if projection.ObservedAtField != "" {
|
||||
value[projection.ObservedAtField] = observedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func renderQueryProjectionTemplate(template string, row map[string]any) string {
|
||||
result := template
|
||||
for key, value := range row {
|
||||
result = strings.ReplaceAll(result, "{{"+key+"}}", fmt.Sprint(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -57,9 +57,7 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
|
||||
instance.State = domain.ServerInstanceStateDraft
|
||||
}
|
||||
if instance.Deployment.Mode != "" {
|
||||
if strings.TrimSpace(create.ProfileKey) != "" {
|
||||
instance.Deployment.ProfileKey = create.ProfileKey
|
||||
}
|
||||
instance.Deployment.ProfileKey = create.ProfileKey
|
||||
instance.Deployment.RuntimeBindings = domain.CopyStringMap(create.Bindings)
|
||||
instance.Deployment.Revision = maxInt(1, instance.Deployment.Revision)
|
||||
instance.Deployment.UpdatedAt = stamp
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
|
||||
@@ -106,43 +104,6 @@ func TestLifecycleProjectedStateUsesRunProcessFacts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedRunLifecycleUsesPackageDefaultProfileWithoutRuntimeBinding(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin := createLifecyclePlugin(t, svc)
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.LifecycleCapabilityStatus)
|
||||
plugin.LifecycleActions.Status = "actions/status.json"
|
||||
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{
|
||||
Key: "run-local",
|
||||
Mode: "local-process",
|
||||
Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus},
|
||||
ActionRefs: domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"},
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin profile: %v", err)
|
||||
}
|
||||
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "generated-run-owner", DisplayName: "Generated Run Owner", Email: "generated-run-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
serverID := "generated-run-server"
|
||||
endpointID := generatedRunEndpointID(serverID)
|
||||
if err := svc.store.RunEndpoints().Create(domain.RunEndpoint{ID: endpointID, DisplayName: "Generated Run", Version: "0.1.0", Status: domain.RunEndpointStatusOnline, Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus}, Capacity: domain.RunCapacity{MaxJobs: 1}, LastHeartbeatAt: fixedTime}); err != nil {
|
||||
t.Fatalf("create generated run endpoint: %v", err)
|
||||
}
|
||||
instance := domain.ServerInstance{ID: serverID, PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: endpointID, Name: "Generated Run Server", OwnerUserID: "generated-run-owner", State: domain.ServerInstanceStateFailed, ConfigVersion: 1, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: `C:\scumserver`, Revision: 1}, CreatedAt: fixedTime, UpdatedAt: fixedTime}
|
||||
if err := svc.store.ServerInstances().Create(instance); err != nil {
|
||||
t.Fatalf("create generated run server: %v", err)
|
||||
}
|
||||
if _, err := svc.runtimeBindingForServer(serverID); !errors.Is(err, repo.ErrNotFound) {
|
||||
t.Fatalf("expected generated run server to have no manual runtime binding: %v", err)
|
||||
}
|
||||
|
||||
result, err := svc.QueryServerInstanceProcessForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: serverID, ExpectedConfigVersion: 1, IdempotencyKey: "generated-run-status"})
|
||||
if err != nil {
|
||||
t.Fatalf("query generated run status: %v", err)
|
||||
}
|
||||
if result.Job.ExecutionInput.WorkspaceScope != "run-local" || result.Job.TargetKey != "actions/status.json" {
|
||||
t.Fatalf("expected generated run status to use packaged profile scope, job=%+v", result.Job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleJobResultsPublishProcessStateEvents(t *testing.T) {
|
||||
svc, sessionToken := newLifecycleRunService(t)
|
||||
createLifecyclePlugin(t, svc)
|
||||
|
||||
Reference in New Issue
Block a user