Fix SCUM companion trajectory storage
This commit is contained in:
@@ -333,13 +333,13 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
|||||||
runDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
|
runDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
|
||||||
assertErrorResponse(t, runDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
|
assertErrorResponse(t, runDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
|
||||||
|
|
||||||
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
|
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
|
||||||
if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef {
|
if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef {
|
||||||
t.Fatalf("unexpected client distribution: %+v", clientDistribution)
|
t.Fatalf("unexpected client distribution: %+v", clientDistribution)
|
||||||
}
|
}
|
||||||
clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
|
clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
|
||||||
assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
|
assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
|
||||||
clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession)
|
clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession)
|
||||||
lifecycleList := getJSONWithAuth[dto.ClientManagerInstallationListResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers", adminSession)
|
lifecycleList := getJSONWithAuth[dto.ClientManagerInstallationListResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers", adminSession)
|
||||||
if lifecycleList.Count != 1 || lifecycleList.Items[0].Status != string(domain.ClientManagerLifecycleBuilding) || lifecycleList.Items[0].Distribution == nil {
|
if lifecycleList.Count != 1 || lifecycleList.Items[0].Status != string(domain.ClientManagerLifecycleBuilding) || lifecycleList.Items[0].Distribution == nil {
|
||||||
t.Fatalf("expected safe client-manager lifecycle projection, got %+v", lifecycleList)
|
t.Fatalf("expected safe client-manager lifecycle projection, got %+v", lifecycleList)
|
||||||
@@ -1864,7 +1864,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
|||||||
pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
|
pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
|
||||||
pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
|
pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
|
||||||
pluginRequest.RuntimeProfiles.LogSources = []dto.RuntimeLogSourceBody{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
|
pluginRequest.RuntimeProfiles.LogSources = []dto.RuntimeLogSourceBody{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
|
||||||
pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://git.npc0.com/admin343/browser.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", WorkspaceRef: "plugins/examples/scum-server-plugin/companion", EntryRef: "cmd/scum-companion"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
||||||
|
|
||||||
endpoint := validRunEndpointRequest()
|
endpoint := validRunEndpointRequest()
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ type DistributionBuildInput struct {
|
|||||||
PackageFormat string
|
PackageFormat string
|
||||||
RepositoryURL string
|
RepositoryURL string
|
||||||
SourceRevision string
|
SourceRevision string
|
||||||
|
WorkspaceRef string
|
||||||
|
EntryRef string
|
||||||
|
ConfigTemplateRef string
|
||||||
ArtifactID string
|
ArtifactID string
|
||||||
OutputFilename string
|
OutputFilename string
|
||||||
SecretRef string
|
SecretRef string
|
||||||
|
|||||||
+44
-38
@@ -209,25 +209,28 @@ type DistributionBuildInputRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DistributionBuildInputResponse struct {
|
type DistributionBuildInputResponse struct {
|
||||||
JobID string `json:"jobId"`
|
JobID string `json:"jobId"`
|
||||||
ComponentKind string `json:"componentKind"`
|
ComponentKind string `json:"componentKind"`
|
||||||
ServerInstanceID string `json:"serverInstanceId"`
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
PluginID string `json:"pluginId"`
|
PluginID string `json:"pluginId"`
|
||||||
RunEndpointID string `json:"runEndpointId"`
|
RunEndpointID string `json:"runEndpointId"`
|
||||||
ProfileKey string `json:"profileKey,omitempty"`
|
ProfileKey string `json:"profileKey,omitempty"`
|
||||||
TargetOS string `json:"targetOs"`
|
TargetOS string `json:"targetOs"`
|
||||||
TargetArch string `json:"targetArch"`
|
TargetArch string `json:"targetArch"`
|
||||||
TargetRelease string `json:"targetRelease"`
|
TargetRelease string `json:"targetRelease"`
|
||||||
PlatformURL string `json:"platformUrl,omitempty"`
|
PlatformURL string `json:"platformUrl,omitempty"`
|
||||||
PackageFormat string `json:"packageFormat"`
|
PackageFormat string `json:"packageFormat"`
|
||||||
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
||||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||||
ArtifactID string `json:"artifactId"`
|
WorkspaceRef string `json:"workspaceRef,omitempty"`
|
||||||
OutputFilename string `json:"outputFilename"`
|
EntryRef string `json:"entryRef,omitempty"`
|
||||||
SecretRef string `json:"secretRef"`
|
ConfigTemplateRef string `json:"configTemplateRef,omitempty"`
|
||||||
KeyGeneration int `json:"keyGeneration"`
|
ArtifactID string `json:"artifactId"`
|
||||||
AuthKey string `json:"authKey"`
|
OutputFilename string `json:"outputFilename"`
|
||||||
WorkspaceSeed string `json:"workspaceSeed,omitempty"`
|
SecretRef string `json:"secretRef"`
|
||||||
|
KeyGeneration int `json:"keyGeneration"`
|
||||||
|
AuthKey string `json:"authKey"`
|
||||||
|
WorkspaceSeed string `json:"workspaceSeed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DependencyExecutionInputRequest struct {
|
type DependencyExecutionInputRequest struct {
|
||||||
@@ -551,25 +554,28 @@ func RunJobResultFromDomain(result domain.RunJobResultResult) RunJobResultRespon
|
|||||||
|
|
||||||
func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) DistributionBuildInputResponse {
|
func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) DistributionBuildInputResponse {
|
||||||
return DistributionBuildInputResponse{
|
return DistributionBuildInputResponse{
|
||||||
JobID: input.JobID,
|
JobID: input.JobID,
|
||||||
ComponentKind: string(input.ComponentKind),
|
ComponentKind: string(input.ComponentKind),
|
||||||
ServerInstanceID: input.ServerInstanceID,
|
ServerInstanceID: input.ServerInstanceID,
|
||||||
PluginID: input.PluginID,
|
PluginID: input.PluginID,
|
||||||
RunEndpointID: input.RunEndpointID,
|
RunEndpointID: input.RunEndpointID,
|
||||||
ProfileKey: input.ProfileKey,
|
ProfileKey: input.ProfileKey,
|
||||||
TargetOS: input.TargetOS,
|
TargetOS: input.TargetOS,
|
||||||
TargetArch: input.TargetArch,
|
TargetArch: input.TargetArch,
|
||||||
TargetRelease: input.TargetRelease,
|
TargetRelease: input.TargetRelease,
|
||||||
PlatformURL: input.PlatformURL,
|
PlatformURL: input.PlatformURL,
|
||||||
PackageFormat: input.PackageFormat,
|
PackageFormat: input.PackageFormat,
|
||||||
RepositoryURL: input.RepositoryURL,
|
RepositoryURL: input.RepositoryURL,
|
||||||
SourceRevision: input.SourceRevision,
|
SourceRevision: input.SourceRevision,
|
||||||
ArtifactID: input.ArtifactID,
|
WorkspaceRef: input.WorkspaceRef,
|
||||||
OutputFilename: input.OutputFilename,
|
EntryRef: input.EntryRef,
|
||||||
SecretRef: input.SecretRef,
|
ConfigTemplateRef: input.ConfigTemplateRef,
|
||||||
KeyGeneration: input.KeyGeneration,
|
ArtifactID: input.ArtifactID,
|
||||||
AuthKey: input.AuthKey,
|
OutputFilename: input.OutputFilename,
|
||||||
WorkspaceSeed: input.WorkspaceSeed,
|
SecretRef: input.SecretRef,
|
||||||
|
KeyGeneration: input.KeyGeneration,
|
||||||
|
AuthKey: input.AuthKey,
|
||||||
|
WorkspaceSeed: input.WorkspaceSeed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ func buildLifecycleDistribution(t *testing.T, svc *CoreService, session string,
|
|||||||
}
|
}
|
||||||
payload := []byte("client-manager-package-" + version)
|
payload := []byte("client-manager-package-" + version)
|
||||||
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
|
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://github.com/F88888/scum_client.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://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: idempotency})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generate lifecycle distribution: %v", err)
|
t.Fatalf("generate lifecycle distribution: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,29 +188,60 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DistributionBuildInput{}, err
|
return domain.DistributionBuildInput{}, err
|
||||||
}
|
}
|
||||||
|
profile, err := svc.clientManagerDistributionBuildProfile(distribution)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DistributionBuildInput{}, err
|
||||||
|
}
|
||||||
return domain.DistributionBuildInput{
|
return domain.DistributionBuildInput{
|
||||||
JobID: job.ID,
|
JobID: job.ID,
|
||||||
ComponentKind: domain.DistributionComponentClientManager,
|
ComponentKind: domain.DistributionComponentClientManager,
|
||||||
ServerInstanceID: distribution.ServerInstanceID,
|
ServerInstanceID: distribution.ServerInstanceID,
|
||||||
PluginID: distribution.PluginID,
|
PluginID: distribution.PluginID,
|
||||||
RunEndpointID: job.RunEndpointID,
|
RunEndpointID: job.RunEndpointID,
|
||||||
ProfileKey: distribution.ProfileKey,
|
ProfileKey: distribution.ProfileKey,
|
||||||
TargetOS: distribution.TargetOS,
|
TargetOS: distribution.TargetOS,
|
||||||
TargetArch: distribution.TargetArch,
|
TargetArch: distribution.TargetArch,
|
||||||
PlatformURL: runReleasePlatformURL(),
|
PlatformURL: runReleasePlatformURL(),
|
||||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||||
RepositoryURL: distribution.RepositoryURL,
|
RepositoryURL: distribution.RepositoryURL,
|
||||||
SourceRevision: distribution.SourceRevision,
|
SourceRevision: distribution.SourceRevision,
|
||||||
ArtifactID: distribution.ArtifactID,
|
WorkspaceRef: profile.WorkspaceRef,
|
||||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
EntryRef: profile.EntryRef,
|
||||||
SecretRef: distribution.SecretRef,
|
ConfigTemplateRef: clientManagerBuildConfigTemplateRef(profile),
|
||||||
KeyGeneration: distribution.KeyGeneration,
|
ArtifactID: distribution.ArtifactID,
|
||||||
AuthKey: plainKey,
|
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||||
|
SecretRef: distribution.SecretRef,
|
||||||
|
KeyGeneration: distribution.KeyGeneration,
|
||||||
|
AuthKey: plainKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
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 {
|
type runDistributionPackageContext struct {
|
||||||
profileKey string
|
profileKey string
|
||||||
workspaceSeed string
|
workspaceSeed string
|
||||||
|
|||||||
@@ -96,23 +96,30 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DistributionBuildInput{}, err
|
return domain.DistributionBuildInput{}, err
|
||||||
}
|
}
|
||||||
|
profile, err := svc.clientManagerDistributionBuildProfile(distribution)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DistributionBuildInput{}, err
|
||||||
|
}
|
||||||
return domain.DistributionBuildInput{
|
return domain.DistributionBuildInput{
|
||||||
JobID: job.ID,
|
JobID: job.ID,
|
||||||
ComponentKind: domain.DistributionComponentClientManager,
|
ComponentKind: domain.DistributionComponentClientManager,
|
||||||
ServerInstanceID: distribution.ServerInstanceID,
|
ServerInstanceID: distribution.ServerInstanceID,
|
||||||
PluginID: distribution.PluginID,
|
PluginID: distribution.PluginID,
|
||||||
RunEndpointID: job.RunEndpointID,
|
RunEndpointID: job.RunEndpointID,
|
||||||
ProfileKey: distribution.ProfileKey,
|
ProfileKey: distribution.ProfileKey,
|
||||||
TargetOS: distribution.TargetOS,
|
TargetOS: distribution.TargetOS,
|
||||||
TargetArch: distribution.TargetArch,
|
TargetArch: distribution.TargetArch,
|
||||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||||
RepositoryURL: distribution.RepositoryURL,
|
RepositoryURL: distribution.RepositoryURL,
|
||||||
SourceRevision: distribution.SourceRevision,
|
SourceRevision: distribution.SourceRevision,
|
||||||
ArtifactID: distribution.ArtifactID,
|
WorkspaceRef: profile.WorkspaceRef,
|
||||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
EntryRef: profile.EntryRef,
|
||||||
SecretRef: distribution.SecretRef,
|
ConfigTemplateRef: clientManagerBuildConfigTemplateRef(profile),
|
||||||
KeyGeneration: distribution.KeyGeneration,
|
ArtifactID: distribution.ArtifactID,
|
||||||
AuthKey: plainKey,
|
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||||
|
SecretRef: distribution.SecretRef,
|
||||||
|
KeyGeneration: distribution.KeyGeneration,
|
||||||
|
AuthKey: plainKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
||||||
|
|||||||
@@ -533,20 +533,39 @@ git init --quiet
|
|||||||
git remote add origin "$REPOSITORY_URL"
|
git remote add origin "$REPOSITORY_URL"
|
||||||
git fetch --quiet --depth 1 origin "$SOURCE_REVISION"
|
git fetch --quiet --depth 1 origin "$SOURCE_REVISION"
|
||||||
git checkout --quiet --detach FETCH_HEAD
|
git checkout --quiet --detach FETCH_HEAD
|
||||||
progress 36 'env_check: writing client-manager configuration'
|
workspace_ref="${CLIENT_MANAGER_WORKSPACE_REF:-}"
|
||||||
{
|
entry_ref="${CLIENT_MANAGER_ENTRY_REF:-.}"
|
||||||
printf 'server_url: "%s"\n' "$PLATFORM_URL"
|
config_template_ref="${CLIENT_MANAGER_CONFIG_TEMPLATE_REF:-config.yaml.example}"
|
||||||
printf 'server_instance_id: "%s"\n' "$SERVER_INSTANCE_ID"
|
for safe_ref in "$workspace_ref" "$entry_ref" "$config_template_ref"; do
|
||||||
printf 'scum_client_credential: "%s"\n' "$auth_key"
|
case "$safe_ref" in
|
||||||
printf 'scum_client_name: "%s"\n' "$PROFILE_KEY"
|
*..*|*://*|/*|*\\*) printf 'client-manager build reference is unsafe\n' >&2; exit 2 ;;
|
||||||
printf 'scum_client_version: "platform-build"\n'
|
esac
|
||||||
printf 'scum_client_machine_label: "managed-client"\n'
|
done
|
||||||
printf 'ftp_provider: 3\n'
|
build_workdir=/workspace/build
|
||||||
} > config.yaml
|
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 52 'deps_download: downloading Go modules'
|
progress 52 'deps_download: downloading Go modules'
|
||||||
go mod download
|
go mod download
|
||||||
progress 76 'build_compile: compiling client-manager executable'
|
progress 76 'build_compile: compiling client-manager executable'
|
||||||
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" .
|
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" "$build_target"
|
||||||
cp config.yaml /workspace/output/config.yaml
|
cp config.yaml /workspace/output/config.yaml
|
||||||
progress 88 'package_finalize: client-manager package inputs written'
|
progress 88 'package_finalize: client-manager package inputs written'
|
||||||
`
|
`
|
||||||
@@ -582,6 +601,9 @@ func (builder *DockerDistributionBuilder) containerArgs(input domain.Distributio
|
|||||||
"-e", "PLATFORM_URL=" + platformURL,
|
"-e", "PLATFORM_URL=" + platformURL,
|
||||||
"-e", "REPOSITORY_URL=" + input.RepositoryURL,
|
"-e", "REPOSITORY_URL=" + input.RepositoryURL,
|
||||||
"-e", "SOURCE_REVISION=" + input.SourceRevision,
|
"-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,
|
"-e", "OUTPUT_FILENAME=" + outputName,
|
||||||
builder.config.Image,
|
builder.config.Image,
|
||||||
"/workspace/input/build.sh",
|
"/workspace/input/build.sh",
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
|||||||
ProfileKey: "scum-client-manager",
|
ProfileKey: "scum-client-manager",
|
||||||
TargetOS: "windows",
|
TargetOS: "windows",
|
||||||
TargetArch: "amd64",
|
TargetArch: "amd64",
|
||||||
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
RepositoryURL: "https://git.npc0.com/admin343/browser.git",
|
||||||
SourceRevision: "main",
|
SourceRevision: "main",
|
||||||
IdempotencyKey: "idem-client-manager",
|
IdempotencyKey: "idem-client-manager",
|
||||||
})
|
})
|
||||||
@@ -551,7 +551,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get build job: %v", err)
|
t.Fatalf("get build job: %v", err)
|
||||||
}
|
}
|
||||||
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
|
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://git.npc0.com/admin343/browser.git" || build.SourceRevision != "main" {
|
||||||
t.Fatalf("unexpected build job: %+v", build)
|
t.Fatalf("unexpected build job: %+v", build)
|
||||||
}
|
}
|
||||||
clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive"))
|
clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive"))
|
||||||
@@ -565,7 +565,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
|||||||
ProfileKey: "scum-client-manager",
|
ProfileKey: "scum-client-manager",
|
||||||
TargetOS: "darwin",
|
TargetOS: "darwin",
|
||||||
TargetArch: "amd64",
|
TargetArch: "amd64",
|
||||||
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
RepositoryURL: "https://git.npc0.com/admin343/browser.git",
|
||||||
IdempotencyKey: "idem-client-manager-denied",
|
IdempotencyKey: "idem-client-manager-denied",
|
||||||
})
|
})
|
||||||
if err == nil || !strings.Contains(err.Error(), "targetOs") {
|
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.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.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.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://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}}}
|
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}}}
|
||||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
t.Fatalf("update plugin fixture: %v", err)
|
t.Fatalf("update plugin fixture: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
# SCUM Companion One-Shot Smoke
|
# SCUM Companion
|
||||||
|
|
||||||
Run stdout/stderr records provide bounded semantic player events. See
|
Run stdout/stderr records provide bounded semantic player events. See [UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the runtime boundary; this Companion never infers events from arbitrary log lines.
|
||||||
[UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the runtime boundary; this
|
|
||||||
Companion never infers events from arbitrary log lines.
|
|
||||||
|
|
||||||
This plugin-owned fixture proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The command registers the deployed component, sends one heartbeat, claims at most one command, processes only `companion.diagnostics`, and uploads one typed `companion.health` snapshot.
|
The production command is plugin-owned and runs as the SCUM Client Manager. It registers the deployed component, keeps heartbeats alive, dispatches declared companion commands, reads SCUM SQLite data, and stores trajectory samples directly into the shared platform MySQL database from the companion process.
|
||||||
|
|
||||||
Use it only with a dedicated non-production server instance whose bridge queue contains no shared or production work. The claim API cannot filter by command type, so this smoke command must never target a shared or production queue.
|
Trajectory samples keep the raw SCUM world coordinates (`world_x`, `world_y`, `world_z`). The companion does not project or convert coordinates before storage; any map pixel calculation is display-only in the plugin page.
|
||||||
|
|
||||||
Before starting it, confirm that the isolated queue is otherwise empty and queue exactly one `companion.diagnostics` command through the Platform SCUM operations page. Use the bounded payload `includeWindowState=false` and `maxEntries=1`. Do not pass an operator session or API token to the companion process.
|
The production process reads two environment variables supplied by the supervisor:
|
||||||
|
|
||||||
|
- `SCUM_COMPONENT_PROOF`: component registration proof material from the protected package.
|
||||||
|
- `SCUM_DB_FILE`: the local SCUM SQLite database file to sample.
|
||||||
|
- `PLATFORM_MYSQL_DSN`: the shared platform MySQL connection string used by plugin storage. It is read from the process environment, not written into `config.yaml`.
|
||||||
|
|
||||||
## Package
|
## Package
|
||||||
|
|
||||||
Build the one-shot command from this directory:
|
Build the production command from this directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o scum-companion-smoke ./cmd/scum-companion-smoke
|
go build -o scum_client.exe ./cmd/scum-companion
|
||||||
```
|
```
|
||||||
|
|
||||||
Place the generated `config.yaml` beside the executable. The command intentionally has no `--config` flag and reads only that sidecar filename from its working directory. `config.yaml.example` documents the generated shape; deployed identity and generation values must come from the fenced Client Manager lifecycle input.
|
Place the generated `config.yaml` beside the executable. The command intentionally has no `--config` flag and reads only that sidecar filename from its working directory. `config.yaml.example` documents the generated shape; deployed identity and generation values must come from the fenced Client Manager lifecycle input.
|
||||||
@@ -24,6 +26,22 @@ The Platform base URL must be a trusted HTTPS origin. The client uses host syste
|
|||||||
|
|
||||||
The supervisor supplies the component proof through the environment variable named by `proof.materialEnv`. Bind it from the protected component package at process start. Do not place the proof in `config.yaml`, command arguments, command-line environment assignments, shell history, documentation, or logs.
|
The supervisor supplies the component proof through the environment variable named by `proof.materialEnv`. Bind it from the protected component package at process start. Do not place the proof in `config.yaml`, command arguments, command-line environment assignments, shell history, documentation, or logs.
|
||||||
|
|
||||||
|
If `trajectory.enabled` is true but `SCUM_DB_FILE` or `PLATFORM_MYSQL_DSN` is missing, the companion stays registered and reports degraded health instead of silently exiting. This keeps diagnostics reachable while operators fix the machine environment.
|
||||||
|
|
||||||
|
## Smoke Fixture
|
||||||
|
|
||||||
|
`cmd/scum-companion-smoke` remains a non-production one-shot fixture. It proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The smoke command registers the deployed component, sends one heartbeat, claims at most one command, processes only `companion.diagnostics`, and uploads one typed `companion.health` snapshot.
|
||||||
|
|
||||||
|
Use it only with a dedicated non-production server instance whose bridge queue contains no shared or production work. The claim API cannot filter by command type, so this smoke command must never target a shared or production queue.
|
||||||
|
|
||||||
|
Before starting it, confirm that the isolated queue is otherwise empty and queue exactly one `companion.diagnostics` command through the Platform SCUM operations page. Use the bounded payload `includeWindowState=false` and `maxEntries=1`. Do not pass an operator session or API token to the companion process.
|
||||||
|
|
||||||
|
Build the smoke command from this directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o scum-companion-smoke ./cmd/scum-companion-smoke
|
||||||
|
```
|
||||||
|
|
||||||
The process environment must also set `SCUM_COMPANION_SMOKE_SCOPE` to `isolated-non-production`. This value is a non-secret safety acknowledgement; configure it in the supervisor rather than placing component proof material on a command line.
|
The process environment must also set `SCUM_COMPANION_SMOKE_SCOPE` to `isolated-non-production`. This value is a non-secret safety acknowledgement; configure it in the supervisor rather than placing component proof material on a command line.
|
||||||
|
|
||||||
Run the executable from the package working directory:
|
Run the executable from the package working directory:
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
companion "browser.local/plugins/scum-server-plugin/companion"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.Ldate | log.Ltime | log.LUTC)
|
||||||
|
config, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("SCUM companion configuration failed: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
client, err := companion.NewClient(config, companion.Options{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("SCUM companion client failed: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
trajectoryStatus := &companion.TrajectoryCollectionStatus{}
|
||||||
|
collector, cleanup := buildTrajectoryCollector(config, trajectoryStatus)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
registry := companion.NewHandlerRegistry(defaultHandlerAvailability(config), companion.RuntimeAdapter{
|
||||||
|
BoundServerID: config.Component.ServerInstanceID,
|
||||||
|
DiagnosticsState: map[string]string{
|
||||||
|
"trajectory": trajectoryDiagnostic(config, collector),
|
||||||
|
"coordinates": "raw-world",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
runtime := companion.Runtime{
|
||||||
|
Client: client,
|
||||||
|
Dispatcher: companion.Dispatcher{
|
||||||
|
Client: client,
|
||||||
|
Registry: registry,
|
||||||
|
PollLimit: 10,
|
||||||
|
Backoff: 2 * time.Second,
|
||||||
|
},
|
||||||
|
HeartbeatEvery: time.Duration(config.Timing.HeartbeatIntervalSeconds) * time.Second,
|
||||||
|
PollEvery: time.Duration(config.Timing.CommandPollIntervalSeconds) * time.Second,
|
||||||
|
Backoff: 2 * time.Second,
|
||||||
|
Health: trajectoryStatus.HealthReport,
|
||||||
|
}
|
||||||
|
|
||||||
|
errorsCh := make(chan error, 2)
|
||||||
|
go func() { errorsCh <- runtime.Run(ctx) }()
|
||||||
|
if collector != nil {
|
||||||
|
go func() { errorsCh <- collector.Run(ctx, trajectoryStatus) }()
|
||||||
|
}
|
||||||
|
|
||||||
|
err = <-errorsCh
|
||||||
|
stop()
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
log.Printf("SCUM companion stopped: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (companion.Config, error) {
|
||||||
|
file, err := os.Open("config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return companion.Config{}, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return companion.LoadConfig(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTrajectoryCollector(config companion.Config, status *companion.TrajectoryCollectionStatus) (*companion.TrajectoryCollector, func()) {
|
||||||
|
cleanup := func() {}
|
||||||
|
if !config.Trajectory.Enabled {
|
||||||
|
status.Record(companion.TrajectoryCollectionReport{Status: "healthy", Reason: "trajectory collection disabled"}, nil)
|
||||||
|
return nil, cleanup
|
||||||
|
}
|
||||||
|
source, err := companion.OpenSCUMSQLiteSourceFromEnv(config.Trajectory.FileEnv)
|
||||||
|
if err != nil {
|
||||||
|
status.Record(companion.TrajectoryCollectionReport{Status: "degraded", Reason: "trajectory source unavailable"}, err)
|
||||||
|
return nil, cleanup
|
||||||
|
}
|
||||||
|
store, err := companion.OpenSCUMSQLStoreFromEnv(companion.PlatformMySQLDSNEnvironment)
|
||||||
|
if err != nil {
|
||||||
|
_ = source.Close()
|
||||||
|
status.Record(companion.TrajectoryCollectionReport{Status: "degraded", Reason: "trajectory store unavailable"}, err)
|
||||||
|
return nil, cleanup
|
||||||
|
}
|
||||||
|
cleanup = func() {
|
||||||
|
_ = source.Close()
|
||||||
|
_ = store.Close()
|
||||||
|
}
|
||||||
|
return companion.NewTrajectoryCollector(config, source, store), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultHandlerAvailability(config companion.Config) companion.HandlerAvailability {
|
||||||
|
capabilities := map[string]bool{"companion.diagnostics": true}
|
||||||
|
for _, capability := range config.Capabilities {
|
||||||
|
if capability == "handler.vehicle.spawn" {
|
||||||
|
capabilities["vehicle.spawn"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return companion.HandlerAvailability{BoundServerID: config.Component.ServerInstanceID, Approved: true, Capabilities: capabilities}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trajectoryDiagnostic(config companion.Config, collector *companion.TrajectoryCollector) string {
|
||||||
|
if !config.Trajectory.Enabled {
|
||||||
|
return "disabled"
|
||||||
|
}
|
||||||
|
if collector == nil {
|
||||||
|
return "waiting"
|
||||||
|
}
|
||||||
|
return "enabled"
|
||||||
|
}
|
||||||
@@ -11,10 +11,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ConfigSchemaVersion = 1
|
ConfigSchemaVersion = 1
|
||||||
PluginID = "game.scum"
|
PluginID = "game.scum"
|
||||||
ProfileKey = "scum-client-manager"
|
ProfileKey = "scum-client-manager"
|
||||||
ProofEnvironment = "SCUM_COMPONENT_PROOF"
|
ProofEnvironment = "SCUM_COMPONENT_PROOF"
|
||||||
|
SCUMDatabaseFileEnvironment = "SCUM_DB_FILE"
|
||||||
|
TrajectorySourceSCUMSQLite = "scum-sqlite"
|
||||||
|
TrajectoryStoreSharedPlatformMySQL = "shared-platform-mysql"
|
||||||
|
DefaultTrajectoryCollectionIntervalSecs = 3
|
||||||
|
DefaultTrajectoryCollectionMaxRows = 500
|
||||||
)
|
)
|
||||||
|
|
||||||
var requiredCapabilities = []string{
|
var requiredCapabilities = []string{
|
||||||
@@ -41,6 +46,7 @@ type Config struct {
|
|||||||
Capabilities []string `json:"capabilities" yaml:"capabilities"`
|
Capabilities []string `json:"capabilities" yaml:"capabilities"`
|
||||||
Timing TimingConfig `json:"timing" yaml:"timing"`
|
Timing TimingConfig `json:"timing" yaml:"timing"`
|
||||||
TLS TransportTLSConfig `json:"tls" yaml:"tls"`
|
TLS TransportTLSConfig `json:"tls" yaml:"tls"`
|
||||||
|
Trajectory TrajectoryConfig `json:"trajectory" yaml:"trajectory"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlatformConfig struct {
|
type PlatformConfig struct {
|
||||||
@@ -80,6 +86,15 @@ type TransportTLSConfig struct {
|
|||||||
Policy string `json:"policy" yaml:"policy"`
|
Policy string `json:"policy" yaml:"policy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrajectoryConfig struct {
|
||||||
|
Enabled bool `json:"enabled" yaml:"enabled"`
|
||||||
|
Source string `json:"source" yaml:"source"`
|
||||||
|
Store string `json:"store" yaml:"store"`
|
||||||
|
FileEnv string `json:"fileEnv" yaml:"fileEnv"`
|
||||||
|
IntervalSeconds int `json:"intervalSeconds" yaml:"intervalSeconds"`
|
||||||
|
MaxRows int `json:"maxRows" yaml:"maxRows"`
|
||||||
|
}
|
||||||
|
|
||||||
func LoadConfig(reader io.Reader) (Config, error) {
|
func LoadConfig(reader io.Reader) (Config, error) {
|
||||||
decoder := yaml.NewDecoder(reader)
|
decoder := yaml.NewDecoder(reader)
|
||||||
decoder.KnownFields(true)
|
decoder.KnownFields(true)
|
||||||
@@ -94,6 +109,7 @@ func LoadConfig(reader io.Reader) (Config, error) {
|
|||||||
}
|
}
|
||||||
return Config{}, fmt.Errorf("decode companion config: %w", err)
|
return Config{}, fmt.Errorf("decode companion config: %w", err)
|
||||||
}
|
}
|
||||||
|
config.applyDefaults()
|
||||||
if err := config.Validate(); err != nil {
|
if err := config.Validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
@@ -102,6 +118,24 @@ func LoadConfig(reader io.Reader) (Config, error) {
|
|||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (config *Config) applyDefaults() {
|
||||||
|
if config.Trajectory.Source == "" {
|
||||||
|
config.Trajectory.Source = TrajectorySourceSCUMSQLite
|
||||||
|
}
|
||||||
|
if config.Trajectory.Store == "" {
|
||||||
|
config.Trajectory.Store = TrajectoryStoreSharedPlatformMySQL
|
||||||
|
}
|
||||||
|
if config.Trajectory.FileEnv == "" {
|
||||||
|
config.Trajectory.FileEnv = SCUMDatabaseFileEnvironment
|
||||||
|
}
|
||||||
|
if config.Trajectory.IntervalSeconds == 0 {
|
||||||
|
config.Trajectory.IntervalSeconds = DefaultTrajectoryCollectionIntervalSecs
|
||||||
|
}
|
||||||
|
if config.Trajectory.MaxRows == 0 {
|
||||||
|
config.Trajectory.MaxRows = DefaultTrajectoryCollectionMaxRows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (config Config) Validate() error {
|
func (config Config) Validate() error {
|
||||||
if config.SchemaVersion != ConfigSchemaVersion {
|
if config.SchemaVersion != ConfigSchemaVersion {
|
||||||
return fmt.Errorf("companion config schema version is unsupported")
|
return fmt.Errorf("companion config schema version is unsupported")
|
||||||
@@ -134,9 +168,43 @@ func (config Config) Validate() error {
|
|||||||
if config.Timing.HeartbeatIntervalSeconds < 5 || config.Timing.HeartbeatIntervalSeconds > 300 || config.Timing.CommandPollIntervalSeconds < 1 || config.Timing.CommandPollIntervalSeconds > 60 || config.Timing.RequestTimeoutSeconds < 1 || config.Timing.RequestTimeoutSeconds > 60 {
|
if config.Timing.HeartbeatIntervalSeconds < 5 || config.Timing.HeartbeatIntervalSeconds > 300 || config.Timing.CommandPollIntervalSeconds < 1 || config.Timing.CommandPollIntervalSeconds > 60 || config.Timing.RequestTimeoutSeconds < 1 || config.Timing.RequestTimeoutSeconds > 60 {
|
||||||
return fmt.Errorf("companion timing policy is invalid")
|
return fmt.Errorf("companion timing policy is invalid")
|
||||||
}
|
}
|
||||||
|
if err := config.Trajectory.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (config TrajectoryConfig) Validate() error {
|
||||||
|
if config.Source != TrajectorySourceSCUMSQLite || config.Store != TrajectoryStoreSharedPlatformMySQL {
|
||||||
|
return fmt.Errorf("SCUM trajectory collection mode is unsupported")
|
||||||
|
}
|
||||||
|
if !validCompanionEnvironmentName(config.FileEnv) {
|
||||||
|
return fmt.Errorf("SCUM database file environment name is invalid")
|
||||||
|
}
|
||||||
|
if config.IntervalSeconds < 1 || config.IntervalSeconds > 3600 || config.MaxRows < 1 || config.MaxRows > 5000 {
|
||||||
|
return fmt.Errorf("SCUM trajectory collection bounds are invalid")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCompanionEnvironmentName(value string) bool {
|
||||||
|
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, char := range value[1:] {
|
||||||
|
if char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '_' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case "PATH", "LD_PRELOAD", "DYLD_INSERT_LIBRARIES":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func canonicalPlatformOrigin(value string) (string, error) {
|
func canonicalPlatformOrigin(value string) (string, error) {
|
||||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" && parsed.Path != "/" {
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" && parsed.Path != "/" {
|
||||||
|
|||||||
@@ -31,3 +31,10 @@ timing:
|
|||||||
requestTimeoutSeconds: 15
|
requestTimeoutSeconds: 15
|
||||||
tls:
|
tls:
|
||||||
policy: verify-system-roots
|
policy: verify-system-roots
|
||||||
|
trajectory:
|
||||||
|
enabled: true
|
||||||
|
source: scum-sqlite
|
||||||
|
store: shared-platform-mysql
|
||||||
|
fileEnv: SCUM_DB_FILE
|
||||||
|
intervalSeconds: 3
|
||||||
|
maxRows: 500
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package companion
|
package companion
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) {
|
func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) {
|
||||||
base := append([]string(nil), requiredCapabilities...)
|
base := append([]string(nil), requiredCapabilities...)
|
||||||
@@ -14,3 +18,29 @@ func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) {
|
|||||||
t.Fatal("undeclared raw command handler capability must be rejected")
|
t.Fatal("undeclared raw command handler capability must be rejected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigDeclaresRawTrajectoryCollection(t *testing.T) {
|
||||||
|
config := loadTestConfig(t)
|
||||||
|
if !config.Trajectory.Enabled || config.Trajectory.Source != TrajectorySourceSCUMSQLite || config.Trajectory.Store != TrajectoryStoreSharedPlatformMySQL {
|
||||||
|
t.Fatalf("trajectory collection is not enabled with plugin-owned source/store: %+v", config.Trajectory)
|
||||||
|
}
|
||||||
|
if config.Trajectory.FileEnv != SCUMDatabaseFileEnvironment || config.Trajectory.IntervalSeconds != DefaultTrajectoryCollectionIntervalSecs || config.Trajectory.MaxRows != DefaultTrajectoryCollectionMaxRows {
|
||||||
|
t.Fatalf("trajectory collection did not use bounded defaults: %+v", config.Trajectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrajectoryConfigRejectsUnsafeEnvironmentNames(t *testing.T) {
|
||||||
|
fixture := strings.ReplaceAll(string(mustReadConfigFixture(t)), "fileEnv: SCUM_DB_FILE", "fileEnv: PATH")
|
||||||
|
if _, err := LoadConfig(strings.NewReader(fixture)); err == nil || !strings.Contains(err.Error(), "environment") {
|
||||||
|
t.Fatalf("expected reserved env name to be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadConfigFixture(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := os.ReadFile("config.yaml.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read config fixture: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,21 @@ module browser.local/plugins/scum-server-plugin/companion
|
|||||||
go 1.25.1
|
go 1.25.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
|
||||||
github.com/go-sql-driver/mysql v1.10.0
|
github.com/go-sql-driver/mysql v1.10.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.38.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
|
modernc.org/libc v1.66.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,57 @@
|
|||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
|
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
|
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
|
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
|
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||||
|
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||||
|
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package companion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
var openSQLite = sql.Open
|
||||||
|
|
||||||
|
type SCUMSQLiteSource struct{ db *sql.DB }
|
||||||
|
|
||||||
|
func NewSCUMSQLiteSource(db *sql.DB) (*SCUMSQLiteSource, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, fmt.Errorf("SCUM database source is required")
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
return &SCUMSQLiteSource{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenSCUMSQLiteSourceFromEnv(envName string) (*SCUMSQLiteSource, error) {
|
||||||
|
name := strings.TrimSpace(envName)
|
||||||
|
if name == "" {
|
||||||
|
name = SCUMDatabaseFileEnvironment
|
||||||
|
}
|
||||||
|
databaseFile := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if databaseFile == "" {
|
||||||
|
return nil, fmt.Errorf("%s is required for SCUM database collection", name)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(databaseFile)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("%s must reference a readable SCUM database file", name)
|
||||||
|
}
|
||||||
|
db, err := openSQLite("sqlite", databaseFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open SCUM database source: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("PRAGMA query_only = ON"); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("prepare SCUM database source for read-only collection: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("prepare SCUM database source timeout: %w", err)
|
||||||
|
}
|
||||||
|
return NewSCUMSQLiteSource(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *SCUMSQLiteSource) Close() error {
|
||||||
|
if source == nil || source.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return source.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *SCUMSQLiteSource) ReadPositionRows(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||||
|
return source.readRows(ctx, scumPositionRowsSQL, limit,
|
||||||
|
sql.Named("subjectType", nil),
|
||||||
|
sql.Named("subjectId", nil),
|
||||||
|
sql.Named("limit", boundedTrajectoryLimit(limit)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *SCUMSQLiteSource) ReadVehicleRows(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||||
|
return source.readRows(ctx, scumVehicleRowsSQL, limit,
|
||||||
|
sql.Named("vehicleId", nil),
|
||||||
|
sql.Named("search", nil),
|
||||||
|
sql.Named("limit", boundedTrajectoryLimit(limit)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *SCUMSQLiteSource) readRows(ctx context.Context, query string, limit int, args ...any) ([]map[string]any, error) {
|
||||||
|
if source == nil || source.db == nil {
|
||||||
|
return nil, fmt.Errorf("SCUM database source is not configured")
|
||||||
|
}
|
||||||
|
rows, err := source.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read SCUM database rows: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read SCUM database columns: %w", err)
|
||||||
|
}
|
||||||
|
maxRows := boundedTrajectoryLimit(limit)
|
||||||
|
result := make([]map[string]any, 0, maxRows)
|
||||||
|
values := make([]any, len(columns))
|
||||||
|
scanTargets := make([]any, len(columns))
|
||||||
|
for index := range values {
|
||||||
|
scanTargets[index] = &values[index]
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
if len(result) >= maxRows {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := rows.Scan(scanTargets...); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan SCUM database rows: %w", err)
|
||||||
|
}
|
||||||
|
row := make(map[string]any, len(columns))
|
||||||
|
for index, column := range columns {
|
||||||
|
row[column] = normalizeSQLiteValue(values[index])
|
||||||
|
}
|
||||||
|
result = append(result, row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read SCUM database rows: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boundedTrajectoryLimit(limit int) int {
|
||||||
|
if limit <= 0 {
|
||||||
|
return DefaultTrajectoryCollectionMaxRows
|
||||||
|
}
|
||||||
|
if limit > 5000 {
|
||||||
|
return 5000
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSQLiteValue(value any) any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
return string(typed)
|
||||||
|
case time.Time:
|
||||||
|
return typed.UTC().Format(time.RFC3339Nano)
|
||||||
|
default:
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scumPositionRowsSQL = `SELECT
|
||||||
|
'player' AS subjectType,
|
||||||
|
account.id AS subjectId,
|
||||||
|
CAST(profile.id AS TEXT) AS userProfileId,
|
||||||
|
CAST(prisoner.id AS TEXT) AS gamePlayerId,
|
||||||
|
NULL AS vehicleId,
|
||||||
|
CAST(entity.id AS TEXT) AS entityId,
|
||||||
|
NULL AS baseId,
|
||||||
|
entity.location_x AS x,
|
||||||
|
entity.location_y AS y,
|
||||||
|
entity.location_z AS z,
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt
|
||||||
|
FROM user_profile profile
|
||||||
|
JOIN user account ON account.id = profile.user_id
|
||||||
|
JOIN prisoner ON prisoner.id = profile.prisoner_id
|
||||||
|
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
|
||||||
|
JOIN entity ON entity.id = prisoner_entity.entity_id
|
||||||
|
WHERE (:subjectType IS NULL OR :subjectType = 'player')
|
||||||
|
AND (:subjectId IS NULL OR account.id = :subjectId)
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL,
|
||||||
|
CAST(spawner.vehicle_entity_id AS TEXT), CAST(entity.id AS TEXT), NULL,
|
||||||
|
entity.location_x, entity.location_y, entity.location_z,
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch')
|
||||||
|
FROM vehicle_spawner spawner
|
||||||
|
JOIN entity ON entity.id = spawner.vehicle_entity_id
|
||||||
|
WHERE (:subjectType IS NULL OR :subjectType = 'vehicle')
|
||||||
|
AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId)
|
||||||
|
LIMIT COALESCE(:limit, 500)`
|
||||||
|
|
||||||
|
const scumVehicleRowsSQL = `SELECT
|
||||||
|
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
|
||||||
|
CAST(spawner.vehicle_entity_id AS TEXT) AS entityId,
|
||||||
|
entity.class AS className,
|
||||||
|
spawner.vehicle_alias AS label,
|
||||||
|
entity.location_x AS x,
|
||||||
|
entity.location_y AS y,
|
||||||
|
entity.location_z AS z,
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime,
|
||||||
|
spawner.is_vehicle_functional AS isFunctional
|
||||||
|
FROM vehicle_spawner spawner
|
||||||
|
JOIN entity ON entity.id = spawner.vehicle_entity_id
|
||||||
|
WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId)
|
||||||
|
AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%')
|
||||||
|
ORDER BY spawner.vehicle_last_access_time DESC
|
||||||
|
LIMIT COALESCE(:limit, 500)`
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package companion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSCUMSQLiteSourceReadsRawCoordinates(t *testing.T) {
|
||||||
|
databaseFile := filepath.Join(t.TempDir(), "SCUM.db")
|
||||||
|
db, err := sql.Open("sqlite", databaseFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite fixture: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
for _, statement := range []string{
|
||||||
|
`CREATE TABLE user (id TEXT PRIMARY KEY)`,
|
||||||
|
`CREATE TABLE user_profile (id INTEGER PRIMARY KEY, user_id TEXT NOT NULL, prisoner_id INTEGER NOT NULL)`,
|
||||||
|
`CREATE TABLE prisoner (id INTEGER PRIMARY KEY, last_save_time INTEGER NOT NULL)`,
|
||||||
|
`CREATE TABLE prisoner_entity (prisoner_id INTEGER NOT NULL, entity_id INTEGER NOT NULL)`,
|
||||||
|
`CREATE TABLE entity (id INTEGER PRIMARY KEY, class TEXT, location_x REAL NOT NULL, location_y REAL NOT NULL, location_z REAL NOT NULL)`,
|
||||||
|
`CREATE TABLE vehicle_spawner (vehicle_entity_id INTEGER PRIMARY KEY, vehicle_alias TEXT, vehicle_last_access_time INTEGER NOT NULL, is_vehicle_functional INTEGER NOT NULL)`,
|
||||||
|
`INSERT INTO user (id) VALUES ('76561198000000001')`,
|
||||||
|
`INSERT INTO prisoner (id, last_save_time) VALUES (2001, 1788146999)`,
|
||||||
|
`INSERT INTO user_profile (id, user_id, prisoner_id) VALUES (1001, '76561198000000001', 2001)`,
|
||||||
|
`INSERT INTO entity (id, class, location_x, location_y, location_z) VALUES (3001, 'BP_Prisoner_C', 123.25, -456.5, 7.75)`,
|
||||||
|
`INSERT INTO prisoner_entity (prisoner_id, entity_id) VALUES (2001, 3001)`,
|
||||||
|
`INSERT INTO entity (id, class, location_x, location_y, location_z) VALUES (4001, 'BPC_Laika_C', -10.5, 20.25, 0)`,
|
||||||
|
`INSERT INTO vehicle_spawner (vehicle_entity_id, vehicle_alias, vehicle_last_access_time, is_vehicle_functional) VALUES (4001, 'Laika', 1788146988, 1)`,
|
||||||
|
} {
|
||||||
|
if _, err := db.Exec(statement); err != nil {
|
||||||
|
t.Fatalf("exec sqlite fixture statement %q: %v", statement, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source, err := NewSCUMSQLiteSource(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlite source: %v", err)
|
||||||
|
}
|
||||||
|
positions, err := source.ReadPositionRows(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read positions: %v", err)
|
||||||
|
}
|
||||||
|
vehicles, err := source.ReadVehicleRows(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read vehicles: %v", err)
|
||||||
|
}
|
||||||
|
player := rowByText(t, positions, "subjectType", "player")
|
||||||
|
vehiclePosition := rowByText(t, positions, "subjectType", "vehicle")
|
||||||
|
vehicle := rowByText(t, vehicles, "vehicleId", "4001")
|
||||||
|
assertNumber(t, player["x"], 123.25)
|
||||||
|
assertNumber(t, player["y"], -456.5)
|
||||||
|
assertNumber(t, player["z"], 7.75)
|
||||||
|
assertNumber(t, vehiclePosition["x"], -10.5)
|
||||||
|
assertNumber(t, vehiclePosition["y"], 20.25)
|
||||||
|
assertNumber(t, vehicle["x"], -10.5)
|
||||||
|
assertNumber(t, vehicle["y"], 20.25)
|
||||||
|
if vehicle["className"] != "BPC_Laika_C" || vehicle["label"] != "Laika" {
|
||||||
|
t.Fatalf("vehicle metadata changed: %+v", vehicle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowByText(t *testing.T, rows []map[string]any, key string, value string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
for _, row := range rows {
|
||||||
|
if textFromRow(row[key]) == value {
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing row where %s=%s: %+v", key, value, rows)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNumber(t *testing.T, value any, expected float64) {
|
||||||
|
t.Helper()
|
||||||
|
actual, ok := numberFromRow(value)
|
||||||
|
if !ok || actual != expected {
|
||||||
|
t.Fatalf("number = %v, want %v", value, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,13 @@ func NewSCUMSQLStore(db *sql.DB) (*SCUMSQLStore, error) {
|
|||||||
return &SCUMSQLStore{db: db}, nil
|
return &SCUMSQLStore{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *SCUMSQLStore) Close() error {
|
||||||
|
if store == nil || store.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return store.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func OpenSCUMSQLStoreFromEnv(envName string) (*SCUMSQLStore, error) {
|
func OpenSCUMSQLStoreFromEnv(envName string) (*SCUMSQLStore, error) {
|
||||||
name := strings.TrimSpace(envName)
|
name := strings.TrimSpace(envName)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package companion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrajectorySource interface {
|
||||||
|
ReadPositionRows(context.Context, int) ([]map[string]any, error)
|
||||||
|
ReadVehicleRows(context.Context, int) ([]map[string]any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrajectoryStore interface {
|
||||||
|
EnsureSchema(context.Context) error
|
||||||
|
StorePositionRows(context.Context, string, []map[string]any, time.Time) (int, error)
|
||||||
|
StoreVehicleRows(context.Context, string, []map[string]any, time.Time) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrajectoryCollectionReport struct {
|
||||||
|
CollectedAt time.Time
|
||||||
|
PositionRows int
|
||||||
|
VehicleRows int
|
||||||
|
StoredSamples int
|
||||||
|
Status string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrajectoryCollector struct {
|
||||||
|
Source TrajectorySource
|
||||||
|
Store TrajectoryStore
|
||||||
|
ServerInstanceID string
|
||||||
|
Interval time.Duration
|
||||||
|
MaxRows int
|
||||||
|
Now func() time.Time
|
||||||
|
schemaOnce sync.Once
|
||||||
|
schemaErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTrajectoryCollector(config Config, source TrajectorySource, store TrajectoryStore) *TrajectoryCollector {
|
||||||
|
return &TrajectoryCollector{
|
||||||
|
Source: source,
|
||||||
|
Store: store,
|
||||||
|
ServerInstanceID: config.Component.ServerInstanceID,
|
||||||
|
Interval: time.Duration(config.Trajectory.IntervalSeconds) * time.Second,
|
||||||
|
MaxRows: config.Trajectory.MaxRows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (collector *TrajectoryCollector) CollectOnce(ctx context.Context) (TrajectoryCollectionReport, error) {
|
||||||
|
if collector == nil || collector.Source == nil || collector.Store == nil || strings.TrimSpace(collector.ServerInstanceID) == "" {
|
||||||
|
return TrajectoryCollectionReport{}, fmt.Errorf("SCUM trajectory collector is not configured")
|
||||||
|
}
|
||||||
|
collector.schemaOnce.Do(func() { collector.schemaErr = collector.Store.EnsureSchema(ctx) })
|
||||||
|
if collector.schemaErr != nil {
|
||||||
|
return TrajectoryCollectionReport{}, collector.schemaErr
|
||||||
|
}
|
||||||
|
sampledAt := collector.clock()().UTC()
|
||||||
|
report := TrajectoryCollectionReport{CollectedAt: sampledAt, Status: "healthy"}
|
||||||
|
positions, err := collector.Source.ReadPositionRows(ctx, collector.MaxRows)
|
||||||
|
if err != nil {
|
||||||
|
report.Status, report.Reason = "degraded", "position collection failed"
|
||||||
|
return report, err
|
||||||
|
}
|
||||||
|
report.PositionRows = len(positions)
|
||||||
|
written, err := collector.Store.StorePositionRows(ctx, collector.ServerInstanceID, positions, sampledAt)
|
||||||
|
if err != nil {
|
||||||
|
report.Status, report.Reason = "degraded", "position storage failed"
|
||||||
|
return report, err
|
||||||
|
}
|
||||||
|
report.StoredSamples += written
|
||||||
|
vehicles, err := collector.Source.ReadVehicleRows(ctx, collector.MaxRows)
|
||||||
|
if err != nil {
|
||||||
|
report.Status, report.Reason = "degraded", "vehicle collection failed"
|
||||||
|
return report, err
|
||||||
|
}
|
||||||
|
report.VehicleRows = len(vehicles)
|
||||||
|
written, err = collector.Store.StoreVehicleRows(ctx, collector.ServerInstanceID, vehicles, sampledAt)
|
||||||
|
if err != nil {
|
||||||
|
report.Status, report.Reason = "degraded", "vehicle storage failed"
|
||||||
|
return report, err
|
||||||
|
}
|
||||||
|
report.StoredSamples += written
|
||||||
|
report.Reason = "raw world coordinates stored"
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (collector *TrajectoryCollector) Run(ctx context.Context, status *TrajectoryCollectionStatus) error {
|
||||||
|
interval := collector.Interval
|
||||||
|
if interval < time.Second {
|
||||||
|
interval = time.Duration(DefaultTrajectoryCollectionIntervalSecs) * time.Second
|
||||||
|
}
|
||||||
|
if report, err := collector.CollectOnce(ctx); status != nil {
|
||||||
|
status.Record(report, err)
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
report, err := collector.CollectOnce(ctx)
|
||||||
|
if status != nil {
|
||||||
|
status.Record(report, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (collector *TrajectoryCollector) clock() func() time.Time {
|
||||||
|
if collector.Now != nil {
|
||||||
|
return collector.Now
|
||||||
|
}
|
||||||
|
return time.Now
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrajectoryCollectionStatus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
latest TrajectoryCollectionReport
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *TrajectoryCollectionStatus) Record(report TrajectoryCollectionReport, err error) {
|
||||||
|
if status == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status.mu.Lock()
|
||||||
|
defer status.mu.Unlock()
|
||||||
|
status.latest = report
|
||||||
|
status.err = err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *TrajectoryCollectionStatus) HealthReport() HealthReport {
|
||||||
|
if status == nil {
|
||||||
|
return HealthReport{Status: "healthy", Reason: "typed companion dispatcher ready"}
|
||||||
|
}
|
||||||
|
status.mu.Lock()
|
||||||
|
defer status.mu.Unlock()
|
||||||
|
if status.latest.Status == "healthy" && status.err == nil {
|
||||||
|
return HealthReport{Status: "healthy", Reason: safeHealthReason(status.latest.Reason, "typed companion dispatcher ready")}
|
||||||
|
}
|
||||||
|
if !status.latest.CollectedAt.IsZero() && status.err == nil {
|
||||||
|
return HealthReport{Status: "healthy", Reason: "raw world coordinate collection ready"}
|
||||||
|
}
|
||||||
|
if status.err != nil {
|
||||||
|
return HealthReport{Status: "degraded", Reason: safeHealthReason(status.latest.Reason, "trajectory collection waiting for source data")}
|
||||||
|
}
|
||||||
|
return HealthReport{Status: "degraded", Reason: "trajectory collection waiting for first sample"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeHealthReason(value string, fallback string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package companion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type trajectorySourceFixture struct {
|
||||||
|
positions []map[string]any
|
||||||
|
vehicles []map[string]any
|
||||||
|
limits []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *trajectorySourceFixture) ReadPositionRows(_ context.Context, limit int) ([]map[string]any, error) {
|
||||||
|
source.limits = append(source.limits, limit)
|
||||||
|
return source.positions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *trajectorySourceFixture) ReadVehicleRows(_ context.Context, limit int) ([]map[string]any, error) {
|
||||||
|
source.limits = append(source.limits, limit)
|
||||||
|
return source.vehicles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type trajectoryStoreFixture struct {
|
||||||
|
ensureCalls int
|
||||||
|
samples []TrajectorySample
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *trajectoryStoreFixture) EnsureSchema(context.Context) error {
|
||||||
|
store.ensureCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *trajectoryStoreFixture) StorePositionRows(_ context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
|
||||||
|
samples, err := TrajectorySamplesFromPositionRows(serverInstanceID, rows, sampledAt)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
store.samples = append(store.samples, samples...)
|
||||||
|
return len(samples), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *trajectoryStoreFixture) StoreVehicleRows(_ context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
|
||||||
|
samples, err := TrajectorySamplesFromVehicleRows(serverInstanceID, rows, sampledAt)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
store.samples = append(store.samples, samples...)
|
||||||
|
return len(samples), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrajectoryCollectorStoresRawSCUMWorldCoordinates(t *testing.T) {
|
||||||
|
sampledAt := time.Date(2026, 8, 31, 3, 30, 0, 0, time.UTC)
|
||||||
|
source := &trajectorySourceFixture{
|
||||||
|
positions: []map[string]any{{"subjectType": "player", "subjectId": "76561198000000001", "gamePlayerId": "player-1", "x": 123.25, "y": -456.5, "z": 7.75, "observedAt": "2026-08-31T03:29:59Z"}},
|
||||||
|
vehicles: []map[string]any{{"vehicleId": "vehicle-1", "entityId": "entity-1", "className": "BPC_Laika_C", "label": "Laika", "x": -10.5, "y": 20.25, "z": 0}},
|
||||||
|
}
|
||||||
|
store := &trajectoryStoreFixture{}
|
||||||
|
collector := &TrajectoryCollector{Source: source, Store: store, ServerInstanceID: "server-1", MaxRows: 777, Now: func() time.Time { return sampledAt }}
|
||||||
|
report, err := collector.CollectOnce(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("collect trajectories: %v", err)
|
||||||
|
}
|
||||||
|
if report.PositionRows != 1 || report.VehicleRows != 1 || report.StoredSamples != 2 || report.Reason != "raw world coordinates stored" {
|
||||||
|
t.Fatalf("unexpected collection report: %+v", report)
|
||||||
|
}
|
||||||
|
if store.ensureCalls != 1 || len(source.limits) != 2 || source.limits[0] != 777 || source.limits[1] != 777 {
|
||||||
|
t.Fatalf("collector did not use bounded source/store once: ensure=%d limits=%v", store.ensureCalls, source.limits)
|
||||||
|
}
|
||||||
|
if len(store.samples) != 2 {
|
||||||
|
t.Fatalf("expected two trajectory samples, got %+v", store.samples)
|
||||||
|
}
|
||||||
|
if store.samples[0].WorldX != 123.25 || store.samples[0].WorldY != -456.5 || store.samples[0].WorldZ == nil || *store.samples[0].WorldZ != 7.75 {
|
||||||
|
t.Fatalf("player coordinates were changed before storage: %+v", store.samples[0])
|
||||||
|
}
|
||||||
|
if store.samples[1].SubjectType != "vehicle" || store.samples[1].WorldX != -10.5 || store.samples[1].WorldY != 20.25 || store.samples[1].Source != "plugin.sql.scum.vehicles" {
|
||||||
|
t.Fatalf("vehicle coordinates were changed before storage: %+v", store.samples[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"databaseUserVersion": 57,
|
"databaseUserVersion": 57,
|
||||||
"owner": "game.scum",
|
"owner": "game.scum",
|
||||||
"store": "platform-mysql",
|
"store": "plugin-shared-platform-mysql",
|
||||||
"tables": [
|
"tables": [
|
||||||
{
|
{
|
||||||
"name": "scum_trajectories",
|
"name": "scum_trajectories",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatu
|
|||||||
async requestStatePatch(patch) { const error = validateStatePatch(patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); },
|
async requestStatePatch(patch) { const error = validateStatePatch(patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); },
|
||||||
async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); },
|
async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); },
|
||||||
async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", view: "gifts" }); return result.status === "ok" ? decode<SCUMGiftGrant[]>(result.result) ?? [] : []; },
|
async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", view: "gifts" }); return result.status === "ok" ? decode<SCUMGiftGrant[]>(result.result) ?? [] : []; },
|
||||||
async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", view: "trajectories" }); return result.status === "ok" ? decode<SCUMTrajectoryCollection>(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; }
|
async trajectories() { return { available: false, reason: "轨迹由 SCUM 插件 companion 直接写入 scum_trajectories;页面数据请读取插件表。", trajectories: [] }; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1756,7 +1756,7 @@
|
|||||||
"displayName": "SCUM Client Manager",
|
"displayName": "SCUM Client Manager",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"url": "https://github.com/F88888/scum_client.git",
|
"url": "https://git.npc0.com/admin343/browser.git",
|
||||||
"revisionPolicy": "branch",
|
"revisionPolicy": "branch",
|
||||||
"branch": "main"
|
"branch": "main"
|
||||||
},
|
},
|
||||||
@@ -1768,7 +1768,8 @@
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"system": "go",
|
"system": "go",
|
||||||
"entryRef": "main.go"
|
"workspaceRef": "plugins/examples/scum-server-plugin/companion",
|
||||||
|
"entryRef": "cmd/scum-companion"
|
||||||
},
|
},
|
||||||
"configTemplates": [
|
"configTemplates": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,5 +38,13 @@
|
|||||||
},
|
},
|
||||||
"tls": {
|
"tls": {
|
||||||
"policy": "verify-system-roots"
|
"policy": "verify-system-roots"
|
||||||
|
},
|
||||||
|
"trajectory": {
|
||||||
|
"enabled": true,
|
||||||
|
"source": "scum-sqlite",
|
||||||
|
"store": "shared-platform-mysql",
|
||||||
|
"fileEnv": "SCUM_DB_FILE",
|
||||||
|
"intervalSeconds": 3,
|
||||||
|
"maxRows": 500
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,19 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"policy": { "const": "verify-system-roots" }
|
"policy": { "const": "verify-system-roots" }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"trajectory": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["enabled", "source", "store", "fileEnv", "intervalSeconds", "maxRows"],
|
||||||
|
"properties": {
|
||||||
|
"enabled": { "type": "boolean" },
|
||||||
|
"source": { "const": "scum-sqlite" },
|
||||||
|
"store": { "const": "shared-platform-mysql" },
|
||||||
|
"fileEnv": { "const": "SCUM_DB_FILE" },
|
||||||
|
"intervalSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 },
|
||||||
|
"maxRows": { "type": "integer", "minimum": 1, "maximum": 5000 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"$defs": {
|
"$defs": {
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ describe("plugin manifest validation", () => {
|
|||||||
expect(validate(example), JSON.stringify(validate.errors)).toBe(true);
|
expect(validate(example), JSON.stringify(validate.errors)).toBe(true);
|
||||||
expect(JSON.stringify(example)).not.toMatch(/authKey|componentKey|credential|password|sessionToken|secret|\/api\/v1\/scum-clients\//i);
|
expect(JSON.stringify(example)).not.toMatch(/authKey|componentKey|credential|password|sessionToken|secret|\/api\/v1\/scum-clients\//i);
|
||||||
expect(example).toMatchObject({ proof: { materialEnv: "SCUM_COMPONENT_PROOF" }, session: { mode: "component-session" }, tls: { policy: "verify-system-roots" } });
|
expect(example).toMatchObject({ proof: { materialEnv: "SCUM_COMPONENT_PROOF" }, session: { mode: "component-session" }, tls: { policy: "verify-system-roots" } });
|
||||||
|
expect(example).toMatchObject({ trajectory: { enabled: true, source: "scum-sqlite", store: "shared-platform-mysql", fileEnv: "SCUM_DB_FILE", intervalSeconds: 3, maxRows: 500 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unsafe SCUM companion bootstrap policy and inline session material", () => {
|
it("rejects unsafe SCUM companion bootstrap policy and inline session material", () => {
|
||||||
@@ -745,7 +746,7 @@ describe("plugin manifest validation", () => {
|
|||||||
expect(configMaps.maps.map((map: { key: string }) => map.key)).toEqual(expect.arrayContaining(["server-settings", "economy-override", "raid-times", "notifications", "admin-users", "banned-users"]));
|
expect(configMaps.maps.map((map: { key: string }) => map.key)).toEqual(expect.arrayContaining(["server-settings", "economy-override", "raid-times", "notifications", "admin-users", "banned-users"]));
|
||||||
expect(giftMetadata).toMatchObject({ databaseUserVersion: 57, catalogSource: { configMapKey: "economy-override" } });
|
expect(giftMetadata).toMatchObject({ databaseUserVersion: 57, catalogSource: { configMapKey: "economy-override" } });
|
||||||
expect(mapGeometry).toMatchObject({ databaseUserVersion: 57, image: { path: "assets/map/scum-map-overview.jpg", width: 256, height: 256 }, runtimeOverride: { kilometersToWorldUnits: 100000 } });
|
expect(mapGeometry).toMatchObject({ databaseUserVersion: 57, image: { path: "assets/map/scum-map-overview.jpg", width: 256, height: 256 }, runtimeOverride: { kilometersToWorldUnits: 100000 } });
|
||||||
expect(storageModel).toMatchObject({ databaseUserVersion: 57, store: "platform-mysql", tables: [expect.objectContaining({ name: "scum_trajectories", writer: "companion.SCUMSQLStore.StoreTrajectorySamples", coordinateColumns: ["world_x", "world_y", "world_z"], coordinatePolicy: "store-game-world-coordinates-only" })] });
|
expect(storageModel).toMatchObject({ databaseUserVersion: 57, store: "plugin-shared-platform-mysql", tables: [expect.objectContaining({ name: "scum_trajectories", writer: "companion.SCUMSQLStore.StoreTrajectorySamples", coordinateColumns: ["world_x", "world_y", "world_z"], coordinatePolicy: "store-game-world-coordinates-only" })] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
||||||
@@ -843,6 +844,7 @@ describe("plugin manifest validation", () => {
|
|||||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
|
||||||
runtimeProfiles?: { clientManagers?: Array<{
|
runtimeProfiles?: { clientManagers?: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
|
repository?: { url?: string; branch?: string };
|
||||||
build?: { workspaceRef?: string; entryRef?: string };
|
build?: { workspaceRef?: string; entryRef?: string };
|
||||||
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
|
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
|
||||||
deployment?: { arguments?: string[] };
|
deployment?: { arguments?: string[] };
|
||||||
@@ -850,8 +852,8 @@ describe("plugin manifest validation", () => {
|
|||||||
}> };
|
}> };
|
||||||
};
|
};
|
||||||
const manager = manifest.runtimeProfiles?.clientManagers?.find((profile) => profile.key === "scum-client-manager");
|
const manager = manifest.runtimeProfiles?.clientManagers?.find((profile) => profile.key === "scum-client-manager");
|
||||||
expect(manager?.build).toMatchObject({ entryRef: "main.go" });
|
expect(manager?.repository).toMatchObject({ url: "https://git.npc0.com/admin343/browser.git", branch: "main" });
|
||||||
expect(manager?.build).not.toHaveProperty("workspaceRef");
|
expect(manager?.build).toMatchObject({ workspaceRef: "plugins/examples/scum-server-plugin/companion", entryRef: "cmd/scum-companion" });
|
||||||
expect(manager?.configTemplates).toEqual([{ key: "client-config", templateRef: "config.yaml.example", outputRef: "config.yaml" }]);
|
expect(manager?.configTemplates).toEqual([{ key: "client-config", templateRef: "config.yaml.example", outputRef: "config.yaml" }]);
|
||||||
expect(manager?.deployment?.arguments).toBeUndefined();
|
expect(manager?.deployment?.arguments).toBeUndefined();
|
||||||
expect(manager?.health).toMatchObject({ intervalSeconds: 30, degradedAfterSeconds: 90, offlineAfterSeconds: 120 });
|
expect(manager?.health).toMatchObject({ intervalSeconds: 30, degradedAfterSeconds: 90, offlineAfterSeconds: 120 });
|
||||||
|
|||||||
Reference in New Issue
Block a user