Remove legacy client-manager platform path

This commit is contained in:
npc0-hue
2026-09-03 16:40:05 +08:00
parent ec2462a312
commit 80cddbf19d
51 changed files with 382 additions and 4271 deletions
+66
View File
@@ -18,6 +18,7 @@ import (
"browser.local/platform/dto" "browser.local/platform/dto"
"browser.local/platform/repo" "browser.local/platform/repo"
"browser.local/platform/service" "browser.local/platform/service"
"browser.local/platform/validator"
) )
func TestAuthorizedRouterEnforcesAdminAndCrossOwnerBoundaries(t *testing.T) { func TestAuthorizedRouterEnforcesAdminAndCrossOwnerBoundaries(t *testing.T) {
@@ -165,6 +166,71 @@ func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
} }
} }
func TestSignedRunArtifactChunkUploadUsesHeaderEnvelope(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
t.Fatalf("create plugin: %v", err)
}
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
t.Fatalf("create endpoint: %v", err)
}
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-signed-artifact", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Signed Artifact", State: domain.ServerInstanceStateReady, ConfigVersion: 1}); err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := core.CreateJob(domain.Job{ID: "job-signed-artifact", ServerInstanceID: "server-signed-artifact", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "job-signed-artifact"}); err != nil {
t.Fatalf("create job: %v", err)
}
token := "run-artifact-session-secret"
stamp := time.Now().UTC()
hash := sha256.Sum256([]byte(token))
if err := store.RunControlSessions().Create(domain.RunControlSession{RunEndpointID: "run-local", SessionTokenHash: hex.EncodeToString(hash[:]), Status: domain.AuthSessionStatusActive, Generation: 1, CapabilityFingerprint: "cap-v1", HeartbeatIntervalSeconds: 15, CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true}); err != nil {
t.Fatalf("create Run session: %v", err)
}
payload := []byte("signed raw artifact chunk")
opened, err := core.OpenArtifactTransfer(domain.ArtifactTransferOpen{RunEndpointID: "run-local", SessionToken: token, ArtifactID: "artifact-signed-raw", Direction: domain.ArtifactTransferDirectionUpload, OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: "job-signed-artifact", SizeBytes: int64(len(payload)), ChunkSizeBytes: len(payload), Checksum: validator.BytesChecksum(payload), IdempotencyKey: "signed-raw-artifact"})
if err != nil {
t.Fatalf("open transfer: %v", err)
}
router := NewTestRouterWithCore(core)
unsigned := rawArtifactChunkRequest(t, router, token, opened.TransferID, payload, "nonce-unsigned-raw", stamp, false)
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
signed := rawArtifactChunkRequest(t, router, token, opened.TransferID, payload, "nonce-signed-raw", stamp, true)
assertStatus(t, signed, http.StatusOK)
response := decodeBody[dto.ArtifactChunkUploadResponse](t, signed)
if !response.Accepted || response.TransferID != opened.TransferID || response.NextMissingChunkIndex != 1 {
t.Fatalf("unexpected signed chunk response: %+v", response)
}
}
func rawArtifactChunkRequest(t *testing.T, router http.Handler, token string, transferID string, payload []byte, nonce string, stamp time.Time, signed bool) *httptest.ResponseRecorder {
t.Helper()
path := "/api/v1/run/artifacts/chunks"
timestamp := strconv.FormatInt(stamp.Unix(), 10)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("X-Run-Endpoint", "run-local")
req.Header.Set("X-Run-Session-Token", token)
req.Header.Set("X-Run-Timestamp", timestamp)
req.Header.Set("X-Run-Nonce", nonce)
req.Header.Set("X-Artifact-Transfer-Id", transferID)
req.Header.Set("X-Artifact-Id", "artifact-signed-raw")
req.Header.Set("X-Artifact-Chunk-Index", "0")
req.Header.Set("X-Artifact-Offset", "0")
req.Header.Set("X-Artifact-Size", strconv.Itoa(len(payload)))
req.Header.Set("X-Artifact-Checksum", validator.BytesChecksum(payload))
if signed {
bodyHash := sha256.Sum256(payload)
canonical := strings.Join([]string{http.MethodPost, path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
mac := hmac.New(sha256.New, []byte(token))
_, _ = mac.Write([]byte(canonical))
req.Header.Set("X-Run-Signature", hex.EncodeToString(mac.Sum(nil)))
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
return recorder
}
func TestAuthorizedRouterAllowsRunLifecycleReportWithoutBearer(t *testing.T) { func TestAuthorizedRouterAllowsRunLifecycleReportWithoutBearer(t *testing.T) {
store := repo.NewMemoryStore() store := repo.NewMemoryStore()
core := service.NewCoreService(store) core := service.NewCoreService(store)
-12
View File
@@ -17,7 +17,6 @@ type componentLogServerContextKey struct{}
const ( const (
logEventHeartbeatInterval = 15 * time.Second logEventHeartbeatInterval = 15 * time.Second
liveLogSourceClockSkew = 90 * time.Second
managedLogSessionIDPrefix = "log-session:" managedLogSessionIDPrefix = "log-session:"
) )
@@ -27,7 +26,6 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
writeMethodNotAllowed(w, http.MethodGet) writeMethodNotAllowed(w, http.MethodGet)
return return
} }
liveBoundary := time.Now().UTC().Add(-liveLogSourceClockSkew)
instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r) instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r)
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
@@ -150,12 +148,6 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
if !sourceLogEntryIsLive(event.Entry, liveBoundary) {
if event.Entry.Seq > emittedThrough[event.Stream.ID] {
emittedThrough[event.Stream.ID] = event.Entry.Seq
}
continue
}
if !active.hasStream(event.Stream.ID) { if !active.hasStream(event.Stream.ID) {
active.streams = append(active.streams, event.Stream) active.streams = append(active.streams, event.Stream)
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(event.Stream)); err != nil { if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(event.Stream)); err != nil {
@@ -179,10 +171,6 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
} }
} }
func sourceLogEntryIsLive(entry domain.LogEntry, liveBoundary time.Time) bool {
return !entry.Timestamp.IsZero() && !entry.Timestamp.Before(liveBoundary)
}
type supervisedLogSession struct { type supervisedLogSession struct {
sessionID string sessionID string
startedAt time.Time startedAt time.Time
+4 -3
View File
@@ -153,7 +153,7 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) {
assertSSEEvent(t, reader, "log", `"seq":2`) assertSSEEvent(t, reader, "log", `"seq":2`)
} }
func TestLogEventsSSESkipsBufferedBackfillAfterOpen(t *testing.T) { func TestLogEventsSSEStreamsBufferedAppendAfterOpen(t *testing.T) {
router := newTestRouter() router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router) hello := createLogIngestAPIFixtures(t, router)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK)
@@ -180,11 +180,12 @@ func TestLogEventsSSESkipsBufferedBackfillAfterOpen(t *testing.T) {
assertSSEEvent(t, reader, "stream", `"id":"log-1"`) assertSSEEvent(t, reader, "stream", `"id":"log-1"`)
assertSSEEvent(t, reader, "ready", `"streamCount":1`) assertSSEEvent(t, reader, "ready", `"streamCount":1`)
stale := validLogBatchRequest(t, hello.SessionToken, 2, 2) buffered := validLogBatchRequest(t, hello.SessionToken, 2, 2)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", stale), http.StatusOK) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", buffered), http.StatusOK)
fresh := validLogBatchRequest(t, hello.SessionToken, 3, 3) fresh := validLogBatchRequest(t, hello.SessionToken, 3, 3)
retimestampLogBatchRequest(t, &fresh, time.Now().UTC().Add(time.Second)) retimestampLogBatchRequest(t, &fresh, time.Now().UTC().Add(time.Second))
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", fresh), http.StatusOK) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", fresh), http.StatusOK)
assertSSEEvent(t, reader, "log", `"seq":2`)
assertSSEEvent(t, reader, "log", `"seq":3`) assertSSEEvent(t, reader, "log", `"seq":3`)
} }
+4
View File
@@ -944,6 +944,10 @@ func (h *coreHandlers) gamePluginManifestRegistration(w http.ResponseWriter, r *
writeDecodeError(w, err) writeDecodeError(w, err)
return return
} }
if violations := request.Manifest.RuntimeProfiles.UnsupportedLegacyClientManagerViolations("manifest.runtimeProfiles"); len(violations) > 0 {
writeServiceError(w, validator.ValidationError{Violations: violations})
return
}
plugin, err := h.core.RegisterGamePluginManifest(request.ToDomain()) plugin, err := h.core.RegisterGamePluginManifest(request.ToDomain())
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
-392
View File
@@ -1,392 +0,0 @@
package domain
import "time"
type ClientManagerLifecycleStatus string
const (
ClientManagerLifecycleRequested ClientManagerLifecycleStatus = "requested"
ClientManagerLifecycleBuilding ClientManagerLifecycleStatus = "building"
ClientManagerLifecycleAvailable ClientManagerLifecycleStatus = "available"
ClientManagerLifecycleDeploying ClientManagerLifecycleStatus = "deploying"
ClientManagerLifecycleInstalled ClientManagerLifecycleStatus = "installed"
ClientManagerLifecycleRegistering ClientManagerLifecycleStatus = "registering"
ClientManagerLifecycleOnline ClientManagerLifecycleStatus = "online"
ClientManagerLifecycleDegraded ClientManagerLifecycleStatus = "degraded"
ClientManagerLifecycleOffline ClientManagerLifecycleStatus = "offline"
ClientManagerLifecycleUpdating ClientManagerLifecycleStatus = "updating"
ClientManagerLifecycleRollingBack ClientManagerLifecycleStatus = "rolling_back"
ClientManagerLifecycleStopping ClientManagerLifecycleStatus = "stopping"
ClientManagerLifecycleUninstalled ClientManagerLifecycleStatus = "uninstalled"
ClientManagerLifecycleFailed ClientManagerLifecycleStatus = "failed"
)
type ClientManagerHealthStatus string
const (
ClientManagerHealthUnknown ClientManagerHealthStatus = "unknown"
ClientManagerHealthHealthy ClientManagerHealthStatus = "healthy"
ClientManagerHealthDegraded ClientManagerHealthStatus = "degraded"
ClientManagerHealthUnhealthy ClientManagerHealthStatus = "unhealthy"
ClientManagerHealthOffline ClientManagerHealthStatus = "offline"
)
type ClientManagerLifecycleOperation string
const (
ClientManagerOperationDeploy ClientManagerLifecycleOperation = "deploy"
ClientManagerOperationStart ClientManagerLifecycleOperation = "start"
ClientManagerOperationStop ClientManagerLifecycleOperation = "stop"
ClientManagerOperationRestart ClientManagerLifecycleOperation = "restart"
ClientManagerOperationStatus ClientManagerLifecycleOperation = "status"
ClientManagerOperationUpdate ClientManagerLifecycleOperation = "update"
ClientManagerOperationRollback ClientManagerLifecycleOperation = "rollback"
ClientManagerOperationUninstall ClientManagerLifecycleOperation = "uninstall"
)
const (
JobCapabilityClientManagerDeploy = "client-manager.deploy"
JobCapabilityClientManagerControl = "client-manager.control"
JobCapabilityClientManagerUpdate = "client-manager.update"
JobCapabilityClientManagerRollback = "client-manager.rollback"
JobCapabilityClientManagerUninstall = "client-manager.uninstall"
)
const ClientManagerCompanionConfigSchemaVersion = 1
type ClientManagerSessionStatus string
const (
ClientManagerSessionActive ClientManagerSessionStatus = "active"
ClientManagerSessionRevoked ClientManagerSessionStatus = "revoked"
ClientManagerSessionExpired ClientManagerSessionStatus = "expired"
)
type RuntimeClientManagerDeployment struct {
Mode string
ExecutableRef string
Arguments []string
AutoStart bool
RequiredRunCapabilities []string
}
type RuntimeClientManagerLifecycle struct {
Actions []string
StartupTimeoutSeconds int
StopTimeoutSeconds int
}
type RuntimeClientManagerHealth struct {
Mode string
IntervalSeconds int
DegradedAfterSeconds int
OfflineAfterSeconds int
RequiredCapabilities []string
}
type RuntimeClientManagerCompatibility struct {
MinimumVersion string
MaximumVersion string
AllowDowngrade bool
}
type RuntimeClientManagerUpdatePolicy struct {
Strategy string
RequireApproval bool
HealthConfirmationSeconds int
RetainPrevious bool
}
type ClientManagerInstallation struct {
ID string
ServerInstanceID string
PluginID string
ProfileKey string
RunEndpointID string
TargetOS string
TargetArch string
Status ClientManagerLifecycleStatus
Phase string
DesiredVersion string
ActiveVersion string
PreviousVersion string
DesiredRevision string
ActiveRevision string
PreviousRevision string
DesiredArtifactID string
ActiveArtifactID string
PreviousArtifactID string
Checksum string
KeyGeneration int
DeploymentGeneration int
CurrentJobID string
LastSuccessfulJobID string
LastOperation ClientManagerLifecycleOperation
Health ClientManagerHealthStatus
HealthReason string
LastSeenAt time.Time
LastHeartbeatSequence uint64
Retryable bool
RequiresRedeploy bool
CreatedAt time.Time
UpdatedAt time.Time
InstalledAt time.Time
UninstalledAt time.Time
}
type ClientManagerSession struct {
ID string
InstallationID string
ServerInstanceID string
ProfileKey string
RunEndpointID string
ArtifactID string
KeyGeneration int
DeploymentGeneration int
TokenHash string
Capabilities []string
Status ClientManagerSessionStatus
LastHeartbeatSequence uint64
LastSeenAt time.Time
ExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
RevokedAt time.Time
}
type ClientManagerRegistrationNonce struct {
ID string
InstallationID string
ExpiresAt time.Time
CreatedAt time.Time
}
type ClientManagerLifecycleActionAvailability struct {
Operation ClientManagerLifecycleOperation
Available bool
Reason string
}
type ClientManagerLifecycleView struct {
Installation ClientManagerInstallation
Distribution ClientManagerDistribution
Job Job
Actions []ClientManagerLifecycleActionAvailability
}
type ClientManagerDeployRequest struct {
ServerInstanceID string
ProfileKey string
DistributionID string
ExpectedDeploymentGeneration int
IdempotencyKey string
}
type ClientManagerControlRequest struct {
ServerInstanceID string
ProfileKey string
Operation ClientManagerLifecycleOperation
ExpectedDeploymentGeneration int
IdempotencyKey string
}
type ClientManagerUpdateRequest struct {
ServerInstanceID string
ProfileKey string
DistributionID string
ExpectedDeploymentGeneration int
Approved bool
IdempotencyKey string
}
type ClientManagerUninstallRequest struct {
ServerInstanceID string
ProfileKey string
ExpectedDeploymentGeneration int
Confirmed bool
IdempotencyKey string
}
type ClientManagerRetryRequest struct {
ServerInstanceID string
ProfileKey string
ExpectedDeploymentGeneration int
IdempotencyKey string
}
type ClientManagerRevokeSessionRequest struct {
ServerInstanceID string
ProfileKey string
Reason string
}
type ClientManagerLifecycleInputRequest struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
}
type ClientManagerLifecycleInput struct {
InstallationID string
ServerInstanceID string
ProfileKey string
Operation ClientManagerLifecycleOperation
ArtifactID string
Checksum string
TargetOS string
TargetArch string
Version string
SourceRevision string
KeyGeneration int
DeploymentGeneration int
ExecutableRef string
Arguments []string
AutoStart bool
StartupTimeoutSeconds int
StopTimeoutSeconds int
HealthConfirmationSeconds int
IdempotencyKey string
CompanionConfig *ClientManagerCompanionConfigInput
}
type ClientManagerCompanionConfigInput struct {
SchemaVersion int
ConfigTemplateKey string
ConfigTemplateRef string
ConfigOutputRef string
ConfigSchemaRef string
ConfigFormat string
PlatformBaseURLSource string
InstallationID string
ServerInstanceID string
PluginID string
ProfileKey string
ArtifactID string
Version string
SourceRevision string
TargetOS string
TargetArch string
KeyGeneration int
DeploymentGeneration int
Capabilities []string
RegistrationProof string
ProofMaterialSource string
ProofMaterialEnv string
SessionMode string
TLSPolicy string
HeartbeatIntervalSeconds int
CommandPollIntervalSeconds int
RequestTimeoutSeconds int
}
type ClientManagerRegisterRequest struct {
InstallationID string
ServerInstanceID string
ProfileKey string
ArtifactID string
Version string
SourceRevision string
TargetOS string
TargetArch string
KeyGeneration int
DeploymentGeneration int
Capabilities []string
Timestamp time.Time
Nonce string
Signature string
}
type ClientManagerRegisterResult struct {
Accepted bool
InstallationID string
SessionToken string
ExpiresAt time.Time
HeartbeatEvery int
ServerTime time.Time
}
type ClientManagerHeartbeat struct {
InstallationID string
SessionToken string
Sequence uint64
Health ClientManagerHealthStatus
HealthReason string
Capabilities []string
SentAt time.Time
}
type ClientManagerHeartbeatResult struct {
Accepted bool
InstallationID string
Status ClientManagerLifecycleStatus
Health ClientManagerHealthStatus
NextHeartbeat int
SessionExpiresAt time.Time
ServerTime time.Time
}
type ClientManagerInstallationFilter struct {
ServerInstanceID string
ProfileKey string
RunEndpointID string
Status ClientManagerLifecycleStatus
}
type ClientManagerSessionFilter struct {
InstallationID string
ServerInstanceID string
ProfileKey string
Status ClientManagerSessionStatus
}
type ClientManagerNonceFilter struct {
InstallationID string
ExpiresBefore time.Time
}
func CopyClientManagerInstallation(value ClientManagerInstallation) ClientManagerInstallation {
return value
}
func CopyClientManagerSession(value ClientManagerSession) ClientManagerSession {
value.Capabilities = CopyStringSlice(value.Capabilities)
return value
}
func CopyClientManagerRegistrationNonce(value ClientManagerRegistrationNonce) ClientManagerRegistrationNonce {
return value
}
func CopyClientManagerLifecycleView(value ClientManagerLifecycleView) ClientManagerLifecycleView {
value.Installation = CopyClientManagerInstallation(value.Installation)
value.Distribution = CopyClientManagerDistribution(value.Distribution)
value.Job = CopyJob(value.Job)
value.Actions = append([]ClientManagerLifecycleActionAvailability(nil), value.Actions...)
return value
}
func CopyClientManagerLifecycleInput(value ClientManagerLifecycleInput) ClientManagerLifecycleInput {
value.Arguments = CopyStringSlice(value.Arguments)
if value.CompanionConfig != nil {
companion := CopyClientManagerCompanionConfigInput(*value.CompanionConfig)
value.CompanionConfig = &companion
}
return value
}
func CopyClientManagerCompanionConfigInput(value ClientManagerCompanionConfigInput) ClientManagerCompanionConfigInput {
value.Capabilities = CopyStringSlice(value.Capabilities)
return value
}
func CopyClientManagerRegisterRequest(value ClientManagerRegisterRequest) ClientManagerRegisterRequest {
value.Capabilities = CopyStringSlice(value.Capabilities)
return value
}
func CopyClientManagerHeartbeat(value ClientManagerHeartbeat) ClientManagerHeartbeat {
value.Capabilities = CopyStringSlice(value.Capabilities)
return value
}
@@ -1,22 +0,0 @@
package domain
import "testing"
func TestCopyClientManagerLifecycleInputDeepCopiesCompanionCapabilities(t *testing.T) {
original := ClientManagerLifecycleInput{
Arguments: []string{"--foreground"},
CompanionConfig: &ClientManagerCompanionConfigInput{
Capabilities: []string{"component.register", "game-client.bridge"},
},
}
cloned := CopyClientManagerLifecycleInput(original)
cloned.Arguments[0] = "--changed"
cloned.CompanionConfig.Capabilities[0] = "changed"
if original.Arguments[0] != "--foreground" {
t.Fatalf("arguments were not deeply copied: %+v", original.Arguments)
}
if original.CompanionConfig.Capabilities[0] != "component.register" {
t.Fatalf("companion capabilities were not deeply copied: %+v", original.CompanionConfig.Capabilities)
}
}
+1 -111
View File
@@ -108,8 +108,7 @@ const (
type DistributionComponentKind string type DistributionComponentKind string
const ( const (
DistributionComponentRun DistributionComponentKind = "run" DistributionComponentRun DistributionComponentKind = "run"
DistributionComponentClientManager DistributionComponentKind = "client-manager"
) )
type ComponentKeyStatus string type ComponentKeyStatus string
@@ -402,7 +401,6 @@ type RuntimeLifecycleProfile struct {
Capabilities []string Capabilities []string
ActionRefs PluginLifecycleActions ActionRefs PluginLifecycleActions
TransportKeys []string TransportKeys []string
ClientManagerRef string
DLLExtensionRefs []string DLLExtensionRefs []string
Platforms []string Platforms []string
} }
@@ -463,28 +461,6 @@ type RuntimeDataTarget struct {
Platforms []string Platforms []string
} }
type RuntimeClientManagerProfile struct {
Key string
DisplayName string
Version string
RepositoryURL string
RevisionPolicy string
Branch string
Tag string
Revision string
SupportedTargets []RuntimeTarget
BuildSystem string
WorkspaceRef string
EntryRef string
ConfigTemplates []RuntimeConfigTemplate
OutputArtifacts []string
Deployment RuntimeClientManagerDeployment
Lifecycle RuntimeClientManagerLifecycle
Health RuntimeClientManagerHealth
Compatibility RuntimeClientManagerCompatibility
UpdatePolicy RuntimeClientManagerUpdatePolicy
}
type RuntimeDLLExtensionProfile struct { type RuntimeDLLExtensionProfile struct {
Key string Key string
DisplayName string DisplayName string
@@ -593,7 +569,6 @@ type GamePluginRuntimeProfiles struct {
LogSources []RuntimeLogSource LogSources []RuntimeLogSource
TransportProfiles []RuntimeTransportProfile TransportProfiles []RuntimeTransportProfile
DataTargets []RuntimeDataTarget DataTargets []RuntimeDataTarget
ClientManagers []RuntimeClientManagerProfile
DLLExtensions []RuntimeDLLExtensionProfile DLLExtensions []RuntimeDLLExtensionProfile
} }
@@ -1432,26 +1407,6 @@ type RunDistribution struct {
UpdatedAt time.Time UpdatedAt time.Time
} }
type ClientManagerDistribution struct {
ID string
ServerInstanceID string
PluginID string
ProfileKey string
Version string
TargetOS string
TargetArch string
RepositoryURL string
SourceRevision string
BuildJobID string
ArtifactID string
Checksum string
KeyGeneration int
SecretRef string
Status DistributionStatus
CreatedAt time.Time
UpdatedAt time.Time
}
type DependencyStatus struct { type DependencyStatus struct {
ID string ID string
ServerInstanceID string ServerInstanceID string
@@ -1471,25 +1426,6 @@ type DependencyStatus struct {
UpdatedAt time.Time UpdatedAt time.Time
} }
type ClientManagerBuildJob struct {
ID string
ServerInstanceID string
PluginID string
ProfileKey string
Version string
TargetOS string
TargetArch string
RepositoryURL string
SourceRevision string
ArtifactID string
Checksum string
KeyGeneration int
LogsRef string
Status DistributionJobStatus
CreatedAt time.Time
UpdatedAt time.Time
}
type RunUpdateJob struct { type RunUpdateJob struct {
ID string ID string
ServerInstanceID string ServerInstanceID string
@@ -1517,16 +1453,6 @@ type RunDistributionGenerateRequest struct {
IdempotencyKey string IdempotencyKey string
} }
type ClientManagerBuildRequest struct {
ServerInstanceID string
ProfileKey string
TargetOS string
TargetArch string
RepositoryURL string
SourceRevision string
IdempotencyKey string
}
type ComponentKeyResetRequest struct { type ComponentKeyResetRequest struct {
ServerInstanceID string ServerInstanceID string
ComponentKind DistributionComponentKind ComponentKind DistributionComponentKind
@@ -1716,26 +1642,12 @@ type RunDistributionFilter struct {
Status DistributionStatus Status DistributionStatus
} }
type ClientManagerDistributionFilter struct {
ServerInstanceID string
ProfileKey string
TargetOS string
TargetArch string
Status DistributionStatus
}
type DependencyStatusFilter struct { type DependencyStatusFilter struct {
ServerInstanceID string ServerInstanceID string
ProbeKey string ProbeKey string
State DependencyState State DependencyState
} }
type ClientManagerBuildJobFilter struct {
ServerInstanceID string
ProfileKey string
Status DistributionJobStatus
}
type RunUpdateJobFilter struct { type RunUpdateJobFilter struct {
ServerInstanceID string ServerInstanceID string
Status DistributionJobStatus Status DistributionJobStatus
@@ -1927,16 +1839,6 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi
for i := range profiles.DataTargets { for i := range profiles.DataTargets {
profiles.DataTargets[i].Platforms = CopyStringSlice(profiles.DataTargets[i].Platforms) profiles.DataTargets[i].Platforms = CopyStringSlice(profiles.DataTargets[i].Platforms)
} }
profiles.ClientManagers = append([]RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
for i := range profiles.ClientManagers {
profiles.ClientManagers[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.ClientManagers[i].SupportedTargets...)
profiles.ClientManagers[i].ConfigTemplates = append([]RuntimeConfigTemplate(nil), profiles.ClientManagers[i].ConfigTemplates...)
profiles.ClientManagers[i].OutputArtifacts = CopyStringSlice(profiles.ClientManagers[i].OutputArtifacts)
profiles.ClientManagers[i].Deployment.Arguments = CopyStringSlice(profiles.ClientManagers[i].Deployment.Arguments)
profiles.ClientManagers[i].Deployment.RequiredRunCapabilities = CopyStringSlice(profiles.ClientManagers[i].Deployment.RequiredRunCapabilities)
profiles.ClientManagers[i].Lifecycle.Actions = CopyStringSlice(profiles.ClientManagers[i].Lifecycle.Actions)
profiles.ClientManagers[i].Health.RequiredCapabilities = CopyStringSlice(profiles.ClientManagers[i].Health.RequiredCapabilities)
}
profiles.DLLExtensions = append([]RuntimeDLLExtensionProfile(nil), profiles.DLLExtensions...) profiles.DLLExtensions = append([]RuntimeDLLExtensionProfile(nil), profiles.DLLExtensions...)
for i := range profiles.DLLExtensions { for i := range profiles.DLLExtensions {
profiles.DLLExtensions[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.DLLExtensions[i].SupportedTargets...) profiles.DLLExtensions[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.DLLExtensions[i].SupportedTargets...)
@@ -2242,10 +2144,6 @@ func CopyRunDistribution(distribution RunDistribution) RunDistribution {
return distribution return distribution
} }
func CopyClientManagerDistribution(distribution ClientManagerDistribution) ClientManagerDistribution {
return distribution
}
func CopyDependencyStatus(status DependencyStatus) DependencyStatus { func CopyDependencyStatus(status DependencyStatus) DependencyStatus {
return status return status
} }
@@ -2259,10 +2157,6 @@ func CopyDependencyCatalog(catalog DependencyCatalog) DependencyCatalog {
return catalog return catalog
} }
func CopyClientManagerBuildJob(job ClientManagerBuildJob) ClientManagerBuildJob {
return job
}
func CopyRunUpdateJob(job RunUpdateJob) RunUpdateJob { func CopyRunUpdateJob(job RunUpdateJob) RunUpdateJob {
return job return job
} }
@@ -2271,10 +2165,6 @@ func CopyRunDistributionGenerateRequest(request RunDistributionGenerateRequest)
return request return request
} }
func CopyClientManagerBuildRequest(request ClientManagerBuildRequest) ClientManagerBuildRequest {
return request
}
func CopyComponentKeyResetRequest(request ComponentKeyResetRequest) ComponentKeyResetRequest { func CopyComponentKeyResetRequest(request ComponentKeyResetRequest) ComponentKeyResetRequest {
return request return request
} }
+1 -1
View File
@@ -104,7 +104,7 @@ Run sessions persist only a token hash, generation, status, expiry, capability f
- `serverInstanceId`: server instance using the binding. - `serverInstanceId`: server instance using the binding.
- `pluginId` and `pluginVersion`: installed plugin contract that declared the logical runtime profile. - `pluginId` and `pluginVersion`: installed plugin contract that declared the logical runtime profile.
- `profileKey`: declared lifecycle/runtime profile key. - `profileKey`: declared lifecycle/runtime profile key.
- `mode`: runtime mode such as `local-process`, `hosted-ftp-rcon`, `ftp-only`, or `custom-client`. - `mode`: runtime mode such as `local-process`, `hosted-ftp-rcon`, or `ftp-only`.
- `bindings`: logical binding keys to operator-provided settings. - `bindings`: logical binding keys to operator-provided settings.
- `missingKeys`: logical keys that must be completed before dependent actions are available. - `missingKeys`: logical keys that must be completed before dependent actions are available.
- `status`: `complete` or `incomplete`. - `status`: `complete` or `incomplete`.
-308
View File
@@ -1,308 +0,0 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type ClientManagerDeployRequest struct {
ProfileKey string `json:"profileKey"`
DistributionID string `json:"distributionId"`
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ClientManagerControlRequest struct {
ProfileKey string `json:"profileKey"`
Operation string `json:"operation"`
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ClientManagerUpdateRequest struct {
ProfileKey string `json:"profileKey"`
DistributionID string `json:"distributionId"`
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
Approved bool `json:"approved"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ClientManagerUninstallRequest struct {
ProfileKey string `json:"profileKey"`
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
Confirmed bool `json:"confirmed"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ClientManagerRetryRequest struct {
ProfileKey string `json:"profileKey"`
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ClientManagerRevokeSessionRequest struct {
ProfileKey string `json:"profileKey"`
Reason string `json:"reason,omitempty"`
}
type ClientManagerLifecycleActionResponse struct {
Operation string `json:"operation"`
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
}
type ClientManagerLifecycleJobResponse struct {
ID string `json:"id,omitempty"`
State domain.JobState `json:"state,omitempty"`
Progress JobProgressBody `json:"progress,omitempty"`
Attempt int `json:"attempt,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
}
type ClientManagerInstallationResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
ProfileKey string `json:"profileKey"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
Status string `json:"status"`
Phase string `json:"phase,omitempty"`
DesiredVersion string `json:"desiredVersion,omitempty"`
ActiveVersion string `json:"activeVersion,omitempty"`
PreviousVersion string `json:"previousVersion,omitempty"`
DesiredRevision string `json:"desiredRevision,omitempty"`
ActiveRevision string `json:"activeRevision,omitempty"`
PreviousRevision string `json:"previousRevision,omitempty"`
DesiredArtifactID string `json:"desiredArtifactId,omitempty"`
ActiveArtifactID string `json:"activeArtifactId,omitempty"`
PreviousArtifactID string `json:"previousArtifactId,omitempty"`
KeyGeneration int `json:"keyGeneration"`
DeploymentGeneration int `json:"deploymentGeneration"`
CurrentJobID string `json:"currentJobId,omitempty"`
LastSuccessfulJobID string `json:"lastSuccessfulJobId,omitempty"`
LastOperation string `json:"lastOperation,omitempty"`
Health string `json:"health"`
HealthReason string `json:"healthReason,omitempty"`
LastSeenAt time.Time `json:"lastSeenAt,omitempty"`
Retryable bool `json:"retryable"`
RequiresRedeploy bool `json:"requiresRedeploy"`
InstalledAt time.Time `json:"installedAt,omitempty"`
UninstalledAt time.Time `json:"uninstalledAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
Distribution *ClientManagerDistributionSummaryResponse `json:"distribution,omitempty"`
Job *ClientManagerLifecycleJobResponse `json:"job,omitempty"`
Actions []ClientManagerLifecycleActionResponse `json:"actions"`
}
type ClientManagerDistributionSummaryResponse struct {
ID string `json:"id"`
ArtifactID string `json:"artifactId"`
SourceRevision string `json:"sourceRevision"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
Checksum string `json:"checksum"`
KeyGeneration int `json:"keyGeneration"`
Status string `json:"status"`
}
type ClientManagerInstallationListResponse struct {
Items []ClientManagerInstallationResponse `json:"items"`
Count int `json:"count"`
}
type ClientManagerLifecycleInputRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
}
type ClientManagerLifecycleInputResponse struct {
InstallationID string `json:"installationId"`
ServerInstanceID string `json:"serverInstanceId"`
ProfileKey string `json:"profileKey"`
Operation string `json:"operation"`
ArtifactID string `json:"artifactId,omitempty"`
Checksum string `json:"checksum,omitempty"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
Version string `json:"version,omitempty"`
SourceRevision string `json:"sourceRevision,omitempty"`
KeyGeneration int `json:"keyGeneration"`
DeploymentGeneration int `json:"deploymentGeneration"`
ExecutableRef string `json:"executableRef"`
Arguments []string `json:"arguments"`
AutoStart bool `json:"autoStart"`
StartupTimeoutSeconds int `json:"startupTimeoutSeconds"`
StopTimeoutSeconds int `json:"stopTimeoutSeconds"`
HealthConfirmationSeconds int `json:"healthConfirmationSeconds"`
IdempotencyKey string `json:"idempotencyKey"`
CompanionConfig *ClientManagerCompanionConfigInputResponse `json:"companionConfig,omitempty"`
}
type ClientManagerCompanionConfigInputResponse struct {
SchemaVersion int `json:"schemaVersion"`
ConfigTemplateKey string `json:"configTemplateKey"`
ConfigTemplateRef string `json:"configTemplateRef"`
ConfigOutputRef string `json:"configOutputRef"`
ConfigSchemaRef string `json:"configSchemaRef"`
ConfigFormat string `json:"configFormat"`
PlatformBaseURLSource string `json:"platformBaseUrlSource"`
InstallationID string `json:"installationId"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
ProfileKey string `json:"profileKey"`
ArtifactID string `json:"artifactId"`
Version string `json:"version"`
SourceRevision string `json:"sourceRevision"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
KeyGeneration int `json:"keyGeneration"`
DeploymentGeneration int `json:"deploymentGeneration"`
Capabilities []string `json:"capabilities"`
RegistrationProof string `json:"registrationProof"`
ProofMaterialSource string `json:"proofMaterialSource"`
ProofMaterialEnv string `json:"proofMaterialEnv"`
SessionMode string `json:"sessionMode"`
TLSPolicy string `json:"tlsPolicy"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds"`
RequestTimeoutSeconds int `json:"requestTimeoutSeconds"`
}
type ClientManagerRegisterRequest struct {
InstallationID string `json:"installationId"`
ServerInstanceID string `json:"serverInstanceId"`
ProfileKey string `json:"profileKey"`
ArtifactID string `json:"artifactId"`
Version string `json:"version"`
SourceRevision string `json:"sourceRevision"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
KeyGeneration int `json:"keyGeneration"`
DeploymentGeneration int `json:"deploymentGeneration"`
Capabilities []string `json:"capabilities"`
Timestamp time.Time `json:"timestamp"`
Nonce string `json:"nonce"`
Signature string `json:"signature"`
}
type ClientManagerRegisterResponse struct {
Accepted bool `json:"accepted"`
InstallationID string `json:"installationId"`
SessionToken string `json:"sessionToken"`
ExpiresAt time.Time `json:"expiresAt"`
HeartbeatEvery int `json:"heartbeatEverySeconds"`
ServerTime time.Time `json:"serverTime"`
}
type ClientManagerHeartbeatRequest struct {
InstallationID string `json:"installationId"`
SessionToken string `json:"sessionToken"`
Sequence uint64 `json:"sequence"`
Health string `json:"health"`
HealthReason string `json:"healthReason,omitempty"`
Capabilities []string `json:"capabilities"`
SentAt time.Time `json:"sentAt"`
}
type ClientManagerHeartbeatResponse struct {
Accepted bool `json:"accepted"`
InstallationID string `json:"installationId"`
Status string `json:"status"`
Health string `json:"health"`
NextHeartbeat int `json:"nextHeartbeatSeconds"`
SessionExpiresAt time.Time `json:"sessionExpiresAt"`
ServerTime time.Time `json:"serverTime"`
}
func (request ClientManagerDeployRequest) ToDomain(serverID string) domain.ClientManagerDeployRequest {
return domain.ClientManagerDeployRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, DistributionID: request.DistributionID, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey}
}
func (request ClientManagerControlRequest) ToDomain(serverID string) domain.ClientManagerControlRequest {
return domain.ClientManagerControlRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, Operation: domain.ClientManagerLifecycleOperation(request.Operation), ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey}
}
func (request ClientManagerUpdateRequest) ToDomain(serverID string) domain.ClientManagerUpdateRequest {
return domain.ClientManagerUpdateRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, DistributionID: request.DistributionID, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, Approved: request.Approved, IdempotencyKey: request.IdempotencyKey}
}
func (request ClientManagerUninstallRequest) ToDomain(serverID string) domain.ClientManagerUninstallRequest {
return domain.ClientManagerUninstallRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, Confirmed: request.Confirmed, IdempotencyKey: request.IdempotencyKey}
}
func (request ClientManagerRetryRequest) ToDomain(serverID string) domain.ClientManagerRetryRequest {
return domain.ClientManagerRetryRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey}
}
func (request ClientManagerRevokeSessionRequest) ToDomain(serverID string) domain.ClientManagerRevokeSessionRequest {
return domain.ClientManagerRevokeSessionRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, Reason: request.Reason}
}
func (request ClientManagerLifecycleInputRequest) ToDomain() domain.ClientManagerLifecycleInputRequest {
return domain.ClientManagerLifecycleInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
}
func ClientManagerLifecycleInputFromDomain(value domain.ClientManagerLifecycleInput) ClientManagerLifecycleInputResponse {
value = domain.CopyClientManagerLifecycleInput(value)
response := ClientManagerLifecycleInputResponse{InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, ProfileKey: value.ProfileKey, Operation: string(value.Operation), ArtifactID: value.ArtifactID, Checksum: value.Checksum, TargetOS: value.TargetOS, TargetArch: value.TargetArch, Version: value.Version, SourceRevision: value.SourceRevision, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, ExecutableRef: value.ExecutableRef, Arguments: value.Arguments, AutoStart: value.AutoStart, StartupTimeoutSeconds: value.StartupTimeoutSeconds, StopTimeoutSeconds: value.StopTimeoutSeconds, HealthConfirmationSeconds: value.HealthConfirmationSeconds, IdempotencyKey: value.IdempotencyKey}
if value.CompanionConfig != nil {
companion := ClientManagerCompanionConfigInputFromDomain(*value.CompanionConfig)
response.CompanionConfig = &companion
}
return response
}
func ClientManagerCompanionConfigInputFromDomain(value domain.ClientManagerCompanionConfigInput) ClientManagerCompanionConfigInputResponse {
value = domain.CopyClientManagerCompanionConfigInput(value)
return ClientManagerCompanionConfigInputResponse{SchemaVersion: value.SchemaVersion, ConfigTemplateKey: value.ConfigTemplateKey, ConfigTemplateRef: value.ConfigTemplateRef, ConfigOutputRef: value.ConfigOutputRef, ConfigSchemaRef: value.ConfigSchemaRef, ConfigFormat: value.ConfigFormat, PlatformBaseURLSource: value.PlatformBaseURLSource, InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, ProfileKey: value.ProfileKey, ArtifactID: value.ArtifactID, Version: value.Version, SourceRevision: value.SourceRevision, TargetOS: value.TargetOS, TargetArch: value.TargetArch, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, Capabilities: value.Capabilities, RegistrationProof: value.RegistrationProof, ProofMaterialSource: value.ProofMaterialSource, ProofMaterialEnv: value.ProofMaterialEnv, SessionMode: value.SessionMode, TLSPolicy: value.TLSPolicy, HeartbeatIntervalSeconds: value.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.RequestTimeoutSeconds}
}
func (request ClientManagerRegisterRequest) ToDomain() domain.ClientManagerRegisterRequest {
return domain.ClientManagerRegisterRequest{InstallationID: request.InstallationID, ServerInstanceID: request.ServerInstanceID, ProfileKey: request.ProfileKey, ArtifactID: request.ArtifactID, Version: request.Version, SourceRevision: request.SourceRevision, TargetOS: request.TargetOS, TargetArch: request.TargetArch, KeyGeneration: request.KeyGeneration, DeploymentGeneration: request.DeploymentGeneration, Capabilities: domain.CopyStringSlice(request.Capabilities), Timestamp: request.Timestamp, Nonce: request.Nonce, Signature: request.Signature}
}
func ClientManagerRegisterFromDomain(value domain.ClientManagerRegisterResult) ClientManagerRegisterResponse {
return ClientManagerRegisterResponse{Accepted: value.Accepted, InstallationID: value.InstallationID, SessionToken: value.SessionToken, ExpiresAt: value.ExpiresAt, HeartbeatEvery: value.HeartbeatEvery, ServerTime: value.ServerTime}
}
func (request ClientManagerHeartbeatRequest) ToDomain() domain.ClientManagerHeartbeat {
return domain.ClientManagerHeartbeat{InstallationID: request.InstallationID, SessionToken: request.SessionToken, Sequence: request.Sequence, Health: domain.ClientManagerHealthStatus(request.Health), HealthReason: request.HealthReason, Capabilities: domain.CopyStringSlice(request.Capabilities), SentAt: request.SentAt}
}
func ClientManagerHeartbeatFromDomain(value domain.ClientManagerHeartbeatResult) ClientManagerHeartbeatResponse {
return ClientManagerHeartbeatResponse{Accepted: value.Accepted, InstallationID: value.InstallationID, Status: string(value.Status), Health: string(value.Health), NextHeartbeat: value.NextHeartbeat, SessionExpiresAt: value.SessionExpiresAt, ServerTime: value.ServerTime}
}
func ClientManagerLifecycleViewFromDomain(value domain.ClientManagerLifecycleView) ClientManagerInstallationResponse {
value = domain.CopyClientManagerLifecycleView(value)
installation := value.Installation
response := ClientManagerInstallationResponse{ID: installation.ID, ServerInstanceID: installation.ServerInstanceID, PluginID: installation.PluginID, ProfileKey: installation.ProfileKey, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Status: string(installation.Status), Phase: installation.Phase, DesiredVersion: installation.DesiredVersion, ActiveVersion: installation.ActiveVersion, PreviousVersion: installation.PreviousVersion, DesiredRevision: installation.DesiredRevision, ActiveRevision: installation.ActiveRevision, PreviousRevision: installation.PreviousRevision, DesiredArtifactID: installation.DesiredArtifactID, ActiveArtifactID: installation.ActiveArtifactID, PreviousArtifactID: installation.PreviousArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, CurrentJobID: installation.CurrentJobID, LastSuccessfulJobID: installation.LastSuccessfulJobID, LastOperation: string(installation.LastOperation), Health: string(installation.Health), HealthReason: installation.HealthReason, LastSeenAt: installation.LastSeenAt, Retryable: installation.Retryable, RequiresRedeploy: installation.RequiresRedeploy, InstalledAt: installation.InstalledAt, UninstalledAt: installation.UninstalledAt, UpdatedAt: installation.UpdatedAt}
if value.Distribution.ID != "" {
response.Distribution = &ClientManagerDistributionSummaryResponse{ID: value.Distribution.ID, ArtifactID: value.Distribution.ArtifactID, SourceRevision: value.Distribution.SourceRevision, TargetOS: value.Distribution.TargetOS, TargetArch: value.Distribution.TargetArch, Checksum: value.Distribution.Checksum, KeyGeneration: value.Distribution.KeyGeneration, Status: string(value.Distribution.Status)}
}
if value.Job.ID != "" {
response.Job = &ClientManagerLifecycleJobResponse{ID: value.Job.ID, State: value.Job.State, Progress: JobProgressBody{Percent: value.Job.Progress.Percent, Message: value.Job.Progress.Message}, Attempt: value.Job.Attempt, CreatedAt: value.Job.CreatedAt, UpdatedAt: value.Job.UpdatedAt}
}
response.Actions = make([]ClientManagerLifecycleActionResponse, len(value.Actions))
for i, action := range value.Actions {
response.Actions[i] = ClientManagerLifecycleActionResponse{Operation: string(action.Operation), Available: action.Available, Reason: action.Reason}
}
if response.Actions == nil {
response.Actions = []ClientManagerLifecycleActionResponse{}
}
return response
}
func ClientManagerLifecycleViewsFromDomain(values []domain.ClientManagerLifecycleView) ClientManagerInstallationListResponse {
items := make([]ClientManagerInstallationResponse, len(values))
for i, value := range values {
items[i] = ClientManagerLifecycleViewFromDomain(value)
}
return ClientManagerInstallationListResponse{Items: items, Count: len(items)}
}
@@ -1,69 +0,0 @@
package dto
import (
"encoding/json"
"strings"
"testing"
"browser.local/platform/domain"
)
func TestClientManagerLifecycleInputProjectsRunOnlyCompanionConfigWithoutRawMaterial(t *testing.T) {
response := ClientManagerLifecycleInputFromDomain(domain.ClientManagerLifecycleInput{
InstallationID: "installation-1",
ServerInstanceID: "server-1",
ProfileKey: "scum-client-manager",
Operation: domain.ClientManagerOperationDeploy,
DeploymentGeneration: 2,
CompanionConfig: &domain.ClientManagerCompanionConfigInput{
SchemaVersion: 1,
ConfigTemplateKey: "client-config",
ConfigTemplateRef: "config.yaml.example",
ConfigOutputRef: "config.yaml",
ConfigSchemaRef: "schemas/companion/config.schema.json",
ConfigFormat: "yaml",
PlatformBaseURLSource: "run-control",
InstallationID: "installation-1",
ServerInstanceID: "server-1",
PluginID: "game.scum",
ProfileKey: "scum-client-manager",
ArtifactID: "artifact-1",
Version: "1.0.0",
SourceRevision: "revision-1",
TargetOS: "windows",
TargetArch: "amd64",
KeyGeneration: 3,
DeploymentGeneration: 2,
Capabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"},
RegistrationProof: "hmac-sha256",
ProofMaterialSource: "component-package",
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
SessionMode: "component-session",
TLSPolicy: "verify-system-roots",
HeartbeatIntervalSeconds: 30,
CommandPollIntervalSeconds: 5,
RequestTimeoutSeconds: 15,
},
})
if response.CompanionConfig == nil || response.CompanionConfig.ProofMaterialEnv != "SCUM_COMPONENT_PROOF" {
t.Fatalf("expected run-only companion projection, got %+v", response.CompanionConfig)
}
encoded, err := json.Marshal(response)
if err != nil {
t.Fatalf("marshal lifecycle input: %v", err)
}
serialized := string(encoded)
for _, forbidden := range []string{`"proofMaterial":`, `"authKey":`, `"componentKey":`, `"sessionToken":`, `"secretRef":`, "/Users/", "run.socket"} {
if strings.Contains(serialized, forbidden) {
t.Fatalf("run lifecycle input leaked %q: %s", forbidden, serialized)
}
}
browserProjection, err := json.Marshal(ClientManagerLifecycleViewFromDomain(domain.ClientManagerLifecycleView{Installation: domain.ClientManagerInstallation{ID: "installation-1"}}))
if err != nil {
t.Fatalf("marshal browser lifecycle projection: %v", err)
}
if strings.Contains(string(browserProjection), "companionConfig") || strings.Contains(string(browserProjection), "proofMaterialEnv") {
t.Fatalf("browser lifecycle projection exposed run-only config: %s", browserProjection)
}
}
-107
View File
@@ -80,19 +80,6 @@ type LogBackfillRequest struct {
IdempotencyKey string `json:"idempotencyKey,omitempty"` IdempotencyKey string `json:"idempotencyKey,omitempty"`
} }
type ClientManagerDownloadRequest struct {
ProfileKey string `json:"profileKey,omitempty"`
}
type ClientManagerBuildRequest struct {
ProfileKey string `json:"profileKey"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
RepositoryURL string `json:"repositoryUrl"`
SourceRevision string `json:"sourceRevision,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
}
type ComponentKeyResetRequest struct { type ComponentKeyResetRequest struct {
ComponentKind string `json:"componentKind"` ComponentKind string `json:"componentKind"`
ComponentKey string `json:"componentKey,omitempty"` ComponentKey string `json:"componentKey,omitempty"`
@@ -145,26 +132,6 @@ type RunDistributionResponse struct {
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
type ClientManagerDistributionResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
ProfileKey string `json:"profileKey"`
Version string `json:"version,omitempty"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
RepositoryURL string `json:"repositoryUrl"`
SourceRevision string `json:"sourceRevision"`
BuildJobID string `json:"buildJobId"`
ArtifactID string `json:"artifactId"`
Checksum string `json:"checksum"`
KeyGeneration int `json:"keyGeneration"`
SecretRef string `json:"secretRef"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type DependencyStatusResponse struct { type DependencyStatusResponse struct {
ID string `json:"id"` ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"` ServerInstanceID string `json:"serverInstanceId"`
@@ -225,25 +192,6 @@ type DependencyCatalogResponse struct {
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
type ClientManagerBuildJobResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
ProfileKey string `json:"profileKey"`
Version string `json:"version,omitempty"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
RepositoryURL string `json:"repositoryUrl"`
SourceRevision string `json:"sourceRevision"`
ArtifactID string `json:"artifactId,omitempty"`
Checksum string `json:"checksum,omitempty"`
KeyGeneration int `json:"keyGeneration,omitempty"`
LogsRef string `json:"logsRef,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type RunUpdateJobResponse struct { type RunUpdateJobResponse struct {
ID string `json:"id"` ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"` ServerInstanceID string `json:"serverInstanceId"`
@@ -310,18 +258,6 @@ func (request LogBackfillRequest) ToDomain(serverInstanceID string) domain.LogBa
} }
} }
func (request ClientManagerBuildRequest) ToDomain(serverInstanceID string) domain.ClientManagerBuildRequest {
return domain.ClientManagerBuildRequest{
ServerInstanceID: serverInstanceID,
ProfileKey: request.ProfileKey,
TargetOS: request.TargetOS,
TargetArch: request.TargetArch,
RepositoryURL: request.RepositoryURL,
SourceRevision: request.SourceRevision,
IdempotencyKey: request.IdempotencyKey,
}
}
func (request ComponentKeyResetRequest) ToDomain(serverInstanceID string) domain.ComponentKeyResetRequest { func (request ComponentKeyResetRequest) ToDomain(serverInstanceID string) domain.ComponentKeyResetRequest {
return domain.ComponentKeyResetRequest{ return domain.ComponentKeyResetRequest{
ServerInstanceID: serverInstanceID, ServerInstanceID: serverInstanceID,
@@ -381,28 +317,6 @@ func RunDistributionFromDomain(distribution domain.RunDistribution) RunDistribut
} }
} }
func ClientManagerDistributionFromDomain(distribution domain.ClientManagerDistribution) ClientManagerDistributionResponse {
return ClientManagerDistributionResponse{
ID: distribution.ID,
ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID,
ProfileKey: distribution.ProfileKey,
Version: distribution.Version,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
RepositoryURL: distribution.RepositoryURL,
SourceRevision: distribution.SourceRevision,
BuildJobID: distribution.BuildJobID,
ArtifactID: distribution.ArtifactID,
Checksum: distribution.Checksum,
KeyGeneration: distribution.KeyGeneration,
SecretRef: distribution.SecretRef,
Status: string(distribution.Status),
CreatedAt: distribution.CreatedAt,
UpdatedAt: distribution.UpdatedAt,
}
}
func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatusResponse { func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatusResponse {
return DependencyStatusResponse{ return DependencyStatusResponse{
ID: status.ID, ID: status.ID,
@@ -441,27 +355,6 @@ func DependencyCatalogFromDomain(catalog domain.DependencyCatalog) DependencyCat
return DependencyCatalogResponse{ServerInstanceID: catalog.ServerInstanceID, PluginID: catalog.PluginID, PluginVersion: catalog.PluginVersion, ProfileKey: catalog.ProfileKey, TargetOS: catalog.TargetOS, TargetArch: catalog.TargetArch, Probes: probes, Plans: plans, UpdatedAt: catalog.UpdatedAt} return DependencyCatalogResponse{ServerInstanceID: catalog.ServerInstanceID, PluginID: catalog.PluginID, PluginVersion: catalog.PluginVersion, ProfileKey: catalog.ProfileKey, TargetOS: catalog.TargetOS, TargetArch: catalog.TargetArch, Probes: probes, Plans: plans, UpdatedAt: catalog.UpdatedAt}
} }
func ClientManagerBuildJobFromDomain(job domain.ClientManagerBuildJob) ClientManagerBuildJobResponse {
return ClientManagerBuildJobResponse{
ID: job.ID,
ServerInstanceID: job.ServerInstanceID,
PluginID: job.PluginID,
ProfileKey: job.ProfileKey,
Version: job.Version,
TargetOS: job.TargetOS,
TargetArch: job.TargetArch,
RepositoryURL: job.RepositoryURL,
SourceRevision: job.SourceRevision,
ArtifactID: job.ArtifactID,
Checksum: job.Checksum,
KeyGeneration: job.KeyGeneration,
LogsRef: job.LogsRef,
Status: string(job.Status),
CreatedAt: job.CreatedAt,
UpdatedAt: job.UpdatedAt,
}
}
func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJobResponse { func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJobResponse {
return RunUpdateJobResponse{ return RunUpdateJobResponse{
ID: job.ID, ID: job.ID,
+18 -73
View File
@@ -1,6 +1,8 @@
package dto package dto
import ( import (
"encoding/json"
"fmt"
"net/url" "net/url"
"path" "path"
@@ -27,7 +29,7 @@ type RuntimeLifecycleProfileBody struct {
Capabilities []string `json:"capabilities"` Capabilities []string `json:"capabilities"`
ActionRefs PluginLifecycleActionsBody `json:"actionRefs,omitempty"` ActionRefs PluginLifecycleActionsBody `json:"actionRefs,omitempty"`
TransportKeys []string `json:"transportKeys,omitempty"` TransportKeys []string `json:"transportKeys,omitempty"`
ClientManagerRef string `json:"clientManagerRef,omitempty"` ClientManagerRef json.RawMessage `json:"clientManagerRef,omitempty"`
DLLExtensionRefs []string `json:"dllExtensionRefs,omitempty"` DLLExtensionRefs []string `json:"dllExtensionRefs,omitempty"`
Platforms []string `json:"platforms,omitempty"` Platforms []string `json:"platforms,omitempty"`
} }
@@ -148,57 +150,6 @@ type RuntimeConfigTemplateBody struct {
OutputRef string `json:"outputRef"` OutputRef string `json:"outputRef"`
} }
type RuntimeClientManagerDeploymentBody struct {
Mode string `json:"mode"`
ExecutableRef string `json:"executableRef"`
Arguments []string `json:"arguments,omitempty"`
AutoStart bool `json:"autoStart,omitempty"`
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
}
type RuntimeClientManagerLifecycleBody struct {
Actions []string `json:"actions"`
StartupTimeoutSeconds int `json:"startupTimeoutSeconds"`
StopTimeoutSeconds int `json:"stopTimeoutSeconds"`
}
type RuntimeClientManagerHealthBody struct {
Mode string `json:"mode"`
IntervalSeconds int `json:"intervalSeconds"`
DegradedAfterSeconds int `json:"degradedAfterSeconds"`
OfflineAfterSeconds int `json:"offlineAfterSeconds"`
RequiredCapabilities []string `json:"requiredCapabilities"`
}
type RuntimeClientManagerCompatibilityBody struct {
MinimumVersion string `json:"minimumVersion,omitempty"`
MaximumVersion string `json:"maximumVersion,omitempty"`
AllowDowngrade bool `json:"allowDowngrade"`
}
type RuntimeClientManagerUpdatePolicyBody struct {
Strategy string `json:"strategy"`
RequireApproval bool `json:"requireApproval"`
HealthConfirmationSeconds int `json:"healthConfirmationSeconds"`
RetainPrevious bool `json:"retainPrevious"`
}
type RuntimeClientManagerProfileBody struct {
Key string `json:"key"`
DisplayName string `json:"displayName,omitempty"`
Version string `json:"version,omitempty"`
Repository RuntimeRepositoryBody `json:"repository"`
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
Build RuntimeBuildBody `json:"build"`
ConfigTemplates []RuntimeConfigTemplateBody `json:"configTemplates,omitempty"`
OutputArtifacts []string `json:"outputArtifacts"`
Deployment RuntimeClientManagerDeploymentBody `json:"deployment,omitempty"`
Lifecycle RuntimeClientManagerLifecycleBody `json:"lifecycle,omitempty"`
Health RuntimeClientManagerHealthBody `json:"health,omitempty"`
Compatibility RuntimeClientManagerCompatibilityBody `json:"compatibility,omitempty"`
UpdatePolicy RuntimeClientManagerUpdatePolicyBody `json:"updatePolicy,omitempty"`
}
type RuntimeDLLExtensionProfileBody struct { type RuntimeDLLExtensionProfileBody struct {
Key string `json:"key"` Key string `json:"key"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
@@ -264,10 +215,23 @@ type GamePluginRuntimeProfilesBody struct {
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"` TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"` DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"`
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"` ClientManagers *[]json.RawMessage `json:"clientManagers,omitempty"`
DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"` DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"`
} }
func (body GamePluginRuntimeProfilesBody) UnsupportedLegacyClientManagerViolations(prefix string) []string {
var violations []string
if body.ClientManagers != nil {
violations = append(violations, prefix+".clientManagers is no longer supported")
}
for i, profile := range body.LifecycleProfiles {
if len(profile.ClientManagerRef) > 0 {
violations = append(violations, fmt.Sprintf("%s.lifecycleProfiles[%d].clientManagerRef is no longer supported", prefix, i))
}
}
return violations
}
// GamePluginRuntimeProfilesResponseBody is intentionally distinct from the // GamePluginRuntimeProfilesResponseBody is intentionally distinct from the
// manifest input shape so browser responses cannot accidentally include // manifest input shape so browser responses cannot accidentally include
// machine-local DLL placement or RCON activation details. // machine-local DLL placement or RCON activation details.
@@ -289,7 +253,7 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
profiles.Discovery = append(profiles.Discovery, domain.RuntimeDiscoveryProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, Expected: item.Expected, Platforms: domain.CopyStringSlice(item.Platforms)}) profiles.Discovery = append(profiles.Discovery, domain.RuntimeDiscoveryProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, Expected: item.Expected, Platforms: domain.CopyStringSlice(item.Platforms)})
} }
for _, item := range body.LifecycleProfiles { for _, item := range body.LifecycleProfiles {
profiles.LifecycleProfiles = append(profiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: item.Key, Mode: item.Mode, Capabilities: domain.CopyStringSlice(item.Capabilities), ActionRefs: item.ActionRefs.ToDomain(), TransportKeys: domain.CopyStringSlice(item.TransportKeys), ClientManagerRef: item.ClientManagerRef, DLLExtensionRefs: domain.CopyStringSlice(item.DLLExtensionRefs), Platforms: domain.CopyStringSlice(item.Platforms)}) profiles.LifecycleProfiles = append(profiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: item.Key, Mode: item.Mode, Capabilities: domain.CopyStringSlice(item.Capabilities), ActionRefs: item.ActionRefs.ToDomain(), TransportKeys: domain.CopyStringSlice(item.TransportKeys), DLLExtensionRefs: domain.CopyStringSlice(item.DLLExtensionRefs), Platforms: domain.CopyStringSlice(item.Platforms)})
} }
for _, item := range body.DependencyProbes { for _, item := range body.DependencyProbes {
profiles.DependencyProbes = append(profiles.DependencyProbes, domain.RuntimeDependencyProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: domain.CopyStringSlice(item.Platforms)}) profiles.DependencyProbes = append(profiles.DependencyProbes, domain.RuntimeDependencyProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: domain.CopyStringSlice(item.Platforms)})
@@ -329,25 +293,6 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
for _, item := range body.DataTargets { for _, item := range body.DataTargets {
profiles.DataTargets = append(profiles.DataTargets, domain.RuntimeDataTarget{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: domain.CopyStringSlice(item.Platforms)}) profiles.DataTargets = append(profiles.DataTargets, domain.RuntimeDataTarget{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: domain.CopyStringSlice(item.Platforms)})
} }
for _, item := range body.ClientManagers {
manager := domain.RuntimeClientManagerProfile{
Key: item.Key, DisplayName: item.DisplayName, Version: item.Version,
RepositoryURL: item.Repository.URL, RevisionPolicy: item.Repository.RevisionPolicy, Branch: item.Repository.Branch, Tag: item.Repository.Tag, Revision: item.Repository.Revision,
BuildSystem: item.Build.System, WorkspaceRef: item.Build.WorkspaceRef, EntryRef: item.Build.EntryRef, OutputArtifacts: domain.CopyStringSlice(item.OutputArtifacts),
Deployment: domain.RuntimeClientManagerDeployment{Mode: item.Deployment.Mode, ExecutableRef: item.Deployment.ExecutableRef, Arguments: domain.CopyStringSlice(item.Deployment.Arguments), AutoStart: item.Deployment.AutoStart, RequiredRunCapabilities: domain.CopyStringSlice(item.Deployment.RequiredRunCapabilities)},
Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: domain.CopyStringSlice(item.Lifecycle.Actions), StartupTimeoutSeconds: item.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: item.Lifecycle.StopTimeoutSeconds},
Health: domain.RuntimeClientManagerHealth{Mode: item.Health.Mode, IntervalSeconds: item.Health.IntervalSeconds, DegradedAfterSeconds: item.Health.DegradedAfterSeconds, OfflineAfterSeconds: item.Health.OfflineAfterSeconds, RequiredCapabilities: domain.CopyStringSlice(item.Health.RequiredCapabilities)},
Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: item.Compatibility.MinimumVersion, MaximumVersion: item.Compatibility.MaximumVersion, AllowDowngrade: item.Compatibility.AllowDowngrade},
UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: item.UpdatePolicy.Strategy, RequireApproval: item.UpdatePolicy.RequireApproval, HealthConfirmationSeconds: item.UpdatePolicy.HealthConfirmationSeconds, RetainPrevious: item.UpdatePolicy.RetainPrevious},
}
for _, target := range item.SupportedTargets {
manager.SupportedTargets = append(manager.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch})
}
for _, config := range item.ConfigTemplates {
manager.ConfigTemplates = append(manager.ConfigTemplates, domain.RuntimeConfigTemplate{Key: config.Key, TemplateRef: config.TemplateRef, OutputRef: config.OutputRef})
}
profiles.ClientManagers = append(profiles.ClientManagers, manager)
}
for _, item := range body.DLLExtensions { for _, item := range body.DLLExtensions {
extension := domain.RuntimeDLLExtensionProfile{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, SCUMExecutableChecksum: item.SCUMExecutableChecksum, UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart, RCONPort: item.RCONPort} extension := domain.RuntimeDLLExtensionProfile{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, SCUMExecutableChecksum: item.SCUMExecutableChecksum, UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart, RCONPort: item.RCONPort}
for _, target := range item.SupportedTargets { for _, target := range item.SupportedTargets {
-114
View File
@@ -1,114 +0,0 @@
package model
import (
"time"
"browser.local/platform/domain"
)
// ClientManagerInstallation is the durable desired/observed lifecycle aggregate for one server/profile.
type ClientManagerInstallation struct {
// ID is the stable installation identifier.
ID string `json:"id" db:"id"`
// ServerInstanceID scopes the installation to one authorized server.
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
// PluginID identifies the installed game plugin declaration.
PluginID string `json:"pluginId" db:"plugin_id"`
// ProfileKey identifies the plugin client-manager profile.
ProfileKey string `json:"profileKey" db:"profile_key"`
// RunEndpointID is the fenced machine executor assignment.
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
// TargetOS is the package operating-system target.
TargetOS string `json:"targetOs" db:"target_os"`
// TargetArch is the package architecture target.
TargetArch string `json:"targetArch" db:"target_arch"`
// Status is the durable lifecycle state.
Status domain.ClientManagerLifecycleStatus `json:"status" db:"status"`
// Phase is a bounded safe execution phase.
Phase string `json:"phase" db:"phase"`
DesiredVersion string `json:"desiredVersion" db:"desired_version"`
ActiveVersion string `json:"activeVersion" db:"active_version"`
PreviousVersion string `json:"previousVersion" db:"previous_version"`
DesiredRevision string `json:"desiredRevision" db:"desired_revision"`
ActiveRevision string `json:"activeRevision" db:"active_revision"`
PreviousRevision string `json:"previousRevision" db:"previous_revision"`
DesiredArtifactID string `json:"desiredArtifactId" db:"desired_artifact_id"`
ActiveArtifactID string `json:"activeArtifactId" db:"active_artifact_id"`
PreviousArtifactID string `json:"previousArtifactId" db:"previous_artifact_id"`
Checksum string `json:"checksum" db:"checksum"`
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
DeploymentGeneration int `json:"deploymentGeneration" db:"deployment_generation"`
CurrentJobID string `json:"currentJobId" db:"current_job_id"`
LastSuccessfulJobID string `json:"lastSuccessfulJobId" db:"last_successful_job_id"`
LastOperation domain.ClientManagerLifecycleOperation `json:"lastOperation" db:"last_operation"`
Health domain.ClientManagerHealthStatus `json:"health" db:"health"`
HealthReason string `json:"healthReason" db:"health_reason"`
LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"`
LastHeartbeatSequence uint64 `json:"lastHeartbeatSequence" db:"last_heartbeat_sequence"`
Retryable bool `json:"retryable" db:"retryable"`
RequiresRedeploy bool `json:"requiresRedeploy" db:"requires_redeploy"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
InstalledAt time.Time `json:"installedAt" db:"installed_at"`
UninstalledAt time.Time `json:"uninstalledAt" db:"uninstalled_at"`
}
func (ClientManagerInstallation) TableName() string { return "client_manager_installations" }
// ClientManagerSession stores only the hash and fences for a short-lived component session.
type ClientManagerSession struct {
ID string `json:"id" db:"id"`
InstallationID string `json:"installationId" db:"installation_id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
ProfileKey string `json:"profileKey" db:"profile_key"`
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
ArtifactID string `json:"artifactId" db:"artifact_id"`
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
DeploymentGeneration int `json:"deploymentGeneration" db:"deployment_generation"`
TokenHash string `json:"tokenHash" db:"token_hash"`
Capabilities []string `json:"capabilities" db:"capabilities"`
Status domain.ClientManagerSessionStatus `json:"status" db:"status"`
LastHeartbeatSequence uint64 `json:"lastHeartbeatSequence" db:"last_heartbeat_sequence"`
LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"`
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
RevokedAt time.Time `json:"revokedAt" db:"revoked_at"`
}
func (ClientManagerSession) TableName() string { return "client_manager_sessions" }
// ClientManagerRegistrationNonce is a bounded replay fence for one signed registration request.
type ClientManagerRegistrationNonce struct {
ID string `json:"id" db:"id"`
InstallationID string `json:"installationId" db:"installation_id"`
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
}
func (ClientManagerRegistrationNonce) TableName() string { return "client_manager_registration_nonces" }
func ClientManagerInstallationFromDomain(value domain.ClientManagerInstallation) ClientManagerInstallation {
return ClientManagerInstallation(value)
}
func (value ClientManagerInstallation) ToDomain() domain.ClientManagerInstallation {
return domain.ClientManagerInstallation(value)
}
func ClientManagerSessionFromDomain(value domain.ClientManagerSession) ClientManagerSession {
value = domain.CopyClientManagerSession(value)
return ClientManagerSession(value)
}
func (value ClientManagerSession) ToDomain() domain.ClientManagerSession {
return domain.CopyClientManagerSession(domain.ClientManagerSession(value))
}
func ClientManagerRegistrationNonceFromDomain(value domain.ClientManagerRegistrationNonce) ClientManagerRegistrationNonce {
return ClientManagerRegistrationNonce(value)
}
func (value ClientManagerRegistrationNonce) ToDomain() domain.ClientManagerRegistrationNonce {
return domain.ClientManagerRegistrationNonce(value)
}
-59
View File
@@ -60,28 +60,6 @@ type RunDistribution struct {
func (RunDistribution) TableName() string { return "run_distributions" } func (RunDistribution) TableName() string { return "run_distributions" }
type ClientManagerDistribution struct {
ID string `json:"id" db:"id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
PluginID string `json:"pluginId" db:"plugin_id"`
ProfileKey string `json:"profileKey" db:"profile_key"`
Version string `json:"version" db:"version"`
TargetOS string `json:"targetOs" db:"target_os"`
TargetArch string `json:"targetArch" db:"target_arch"`
RepositoryURL string `json:"repositoryUrl" db:"repository_url"`
SourceRevision string `json:"sourceRevision" db:"source_revision"`
BuildJobID string `json:"buildJobId" db:"build_job_id"`
ArtifactID string `json:"artifactId" db:"artifact_id"`
Checksum string `json:"checksum" db:"checksum"`
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
SecretRef string `json:"secretRef" db:"secret_ref"`
Status domain.DistributionStatus `json:"status" db:"status"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (ClientManagerDistribution) TableName() string { return "client_manager_distributions" }
type DependencyStatus struct { type DependencyStatus struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
@@ -103,27 +81,6 @@ type DependencyStatus struct {
func (DependencyStatus) TableName() string { return "dependency_statuses" } func (DependencyStatus) TableName() string { return "dependency_statuses" }
type ClientManagerBuildJob struct {
ID string `json:"id" db:"id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
PluginID string `json:"pluginId" db:"plugin_id"`
ProfileKey string `json:"profileKey" db:"profile_key"`
Version string `json:"version" db:"version"`
TargetOS string `json:"targetOs" db:"target_os"`
TargetArch string `json:"targetArch" db:"target_arch"`
RepositoryURL string `json:"repositoryUrl" db:"repository_url"`
SourceRevision string `json:"sourceRevision" db:"source_revision"`
ArtifactID string `json:"artifactId" db:"artifact_id"`
Checksum string `json:"checksum" db:"checksum"`
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
LogsRef string `json:"logsRef,omitempty" db:"logs_ref"`
Status domain.DistributionJobStatus `json:"status" db:"status"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (ClientManagerBuildJob) TableName() string { return "client_manager_build_jobs" }
type RunUpdateJob struct { type RunUpdateJob struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
@@ -195,14 +152,6 @@ func (distribution RunDistribution) ToDomain() domain.RunDistribution {
return domain.RunDistribution(distribution) return domain.RunDistribution(distribution)
} }
func ClientManagerDistributionFromDomain(distribution domain.ClientManagerDistribution) ClientManagerDistribution {
return ClientManagerDistribution(distribution)
}
func (distribution ClientManagerDistribution) ToDomain() domain.ClientManagerDistribution {
return domain.ClientManagerDistribution(distribution)
}
func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatus { func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatus {
return DependencyStatus(status) return DependencyStatus(status)
} }
@@ -211,14 +160,6 @@ func (status DependencyStatus) ToDomain() domain.DependencyStatus {
return domain.DependencyStatus(status) return domain.DependencyStatus(status)
} }
func ClientManagerBuildJobFromDomain(job domain.ClientManagerBuildJob) ClientManagerBuildJob {
return ClientManagerBuildJob(job)
}
func (job ClientManagerBuildJob) ToDomain() domain.ClientManagerBuildJob {
return domain.ClientManagerBuildJob(job)
}
func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJob { func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJob {
return RunUpdateJob(job) return RunUpdateJob(job)
} }
+10 -13
View File
@@ -9,19 +9,16 @@ import (
func TestTableNames(t *testing.T) { func TestTableNames(t *testing.T) {
tests := map[string]string{ tests := map[string]string{
User{}.TableName(): "users", User{}.TableName(): "users",
AIProvider{}.TableName(): "ai_providers", AIProvider{}.TableName(): "ai_providers",
GamePlugin{}.TableName(): "game_plugins", GamePlugin{}.TableName(): "game_plugins",
ServerInstance{}.TableName(): "server_instances", ServerInstance{}.TableName(): "server_instances",
RunEndpoint{}.TableName(): "run_endpoints", RunEndpoint{}.TableName(): "run_endpoints",
Job{}.TableName(): "jobs", Job{}.TableName(): "jobs",
Artifact{}.TableName(): "artifacts", Artifact{}.TableName(): "artifacts",
LogStream{}.TableName(): "log_streams", LogStream{}.TableName(): "log_streams",
PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations", PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations",
AIConfigDiff{}.TableName(): "ai_config_diffs", AIConfigDiff{}.TableName(): "ai_config_diffs",
ClientManagerInstallation{}.TableName(): "client_manager_installations",
ClientManagerSession{}.TableName(): "client_manager_sessions",
ClientManagerRegistrationNonce{}.TableName(): "client_manager_registration_nonces",
} }
for got, want := range tests { for got, want := range tests {
@@ -16,4 +16,4 @@ Platform owns the reviewable dependency catalog, immutable plan digest, selected
3. The terminal staged result moves the safe phase to `restart-requested`. The local journal persists the activation manifest before helper launch. The helper backs up/replaces atomically, starts the new binary with helper environment removed, waits for health, and rolls back on timeout or identity failure. 3. The terminal staged result moves the safe phase to `restart-requested`. The local journal persists the activation manifest before helper launch. The helper backs up/replaces atomically, starts the new binary with helper environment removed, waits for health, and rolls back on timeout or identity failure.
4. The new Run reports success or rollback through signed `update-health` only after registration and job reconciliation. Platform then projects `succeeded` or `rolled-back`; a hello-only outcome is never treated as health confirmation. 4. The new Run reports success or rollback through signed `update-health` only after registration and job reconciliation. Platform then projects `succeeded` or `rolled-back`; a hello-only outcome is never treated as health confirmation.
Control heartbeat, job ack/result/cancel/reconcile, durable logs, and artifact upload use independent loops and deadlines. This contract does not include production code signing/KMS, rollout rings/fleet orchestration, client-manager lifecycle, plugin lifecycle, external mirrors/storage, or real AI-provider integration. Control heartbeat, job ack/result/cancel/reconcile, durable logs, and artifact upload use independent loops and deadlines. This contract does not include production code signing/KMS, rollout rings/fleet orchestration, plugin lifecycle, external mirrors/storage, or real AI-provider integration.
@@ -1,58 +0,0 @@
package repo
import (
"path/filepath"
"testing"
"time"
"browser.local/platform/domain"
)
func TestClientManagerLifecycleRepositoriesPersistAndFilter(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := NewFileStore(path)
if err != nil {
t.Fatalf("create file store: %v", err)
}
stamp := time.Date(2026, 7, 18, 3, 0, 0, 0, time.UTC)
installation := domain.ClientManagerInstallation{ID: "cm-install-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", TargetOS: "linux", TargetArch: "amd64", Status: domain.ClientManagerLifecycleOnline, Phase: "healthy", ActiveVersion: "1.0.0", ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3, Health: domain.ClientManagerHealthHealthy, LastSeenAt: stamp, CreatedAt: stamp, UpdatedAt: stamp}
if err := store.ClientManagerInstallations().Create(installation); err != nil {
t.Fatalf("persist installation: %v", err)
}
session := domain.ClientManagerSession{ID: "cm-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: 2, DeploymentGeneration: 3, TokenHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Capabilities: []string{"component.register", "component.heartbeat"}, Status: domain.ClientManagerSessionActive, LastSeenAt: stamp, ExpiresAt: stamp.Add(15 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp}
if err := store.ClientManagerSessions().Create(session); err != nil {
t.Fatalf("persist session: %v", err)
}
nonce := domain.ClientManagerRegistrationNonce{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", InstallationID: installation.ID, CreatedAt: stamp, ExpiresAt: stamp.Add(5 * time.Minute)}
if err := store.ClientManagerNonces().Create(nonce); err != nil {
t.Fatalf("persist nonce: %v", err)
}
restarted, err := NewFileStore(path)
if err != nil {
t.Fatalf("reload file store: %v", err)
}
installations, err := restarted.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: "server-1", ProfileKey: "scum-client-manager", Status: domain.ClientManagerLifecycleOnline})
if err != nil || len(installations) != 1 || installations[0].DeploymentGeneration != 3 {
t.Fatalf("unexpected persisted installations: items=%+v err=%v", installations, err)
}
sessions, err := restarted.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
if err != nil || len(sessions) != 1 {
t.Fatalf("unexpected persisted sessions: items=%+v err=%v", sessions, err)
}
sessions[0].Capabilities[0] = "mutated"
stored, _ := restarted.ClientManagerSessions().Get(session.ID)
if stored.Capabilities[0] != "component.register" {
t.Fatalf("repository returned shared capability storage: %+v", stored)
}
expired, err := restarted.ClientManagerNonces().List(domain.ClientManagerNonceFilter{ExpiresBefore: stamp.Add(6 * time.Minute)})
if err != nil || len(expired) != 1 {
t.Fatalf("unexpected nonce expiry filter: items=%+v err=%v", expired, err)
}
if err := restarted.ClientManagerNonces().Delete(nonce.ID); err != nil {
t.Fatalf("delete expired nonce: %v", err)
}
if _, err := restarted.ClientManagerNonces().Get(nonce.ID); err != ErrNotFound {
t.Fatalf("expected deleted nonce, got %v", err)
}
}
+46 -81
View File
@@ -13,34 +13,29 @@ import (
) )
type StoreSnapshot struct { type StoreSnapshot struct {
Users []domain.User `json:"users"` Users []domain.User `json:"users"`
AuthSessions []domain.AuthSessionRecord `json:"authSessions"` AuthSessions []domain.AuthSessionRecord `json:"authSessions"`
RunControlSessions []domain.RunControlSession `json:"runControlSessions"` RunControlSessions []domain.RunControlSession `json:"runControlSessions"`
AIProviders []domain.AIProvider `json:"aiProviders"` AIProviders []domain.AIProvider `json:"aiProviders"`
GamePlugins []domain.GamePlugin `json:"gamePlugins"` GamePlugins []domain.GamePlugin `json:"gamePlugins"`
ServerInstances []domain.ServerInstance `json:"serverInstances"` ServerInstances []domain.ServerInstance `json:"serverInstances"`
RunEndpoints []domain.RunEndpoint `json:"runEndpoints"` RunEndpoints []domain.RunEndpoint `json:"runEndpoints"`
Jobs []domain.Job `json:"jobs"` Jobs []domain.Job `json:"jobs"`
Artifacts []domain.Artifact `json:"artifacts"` Artifacts []domain.Artifact `json:"artifacts"`
RuntimeBindings []domain.RuntimeBinding `json:"runtimeBindings"` RuntimeBindings []domain.RuntimeBinding `json:"runtimeBindings"`
EncryptedComponentKeys []domain.EncryptedComponentKey `json:"encryptedComponentKeys"` EncryptedComponentKeys []domain.EncryptedComponentKey `json:"encryptedComponentKeys"`
RunDistributions []domain.RunDistribution `json:"runDistributions"` RunDistributions []domain.RunDistribution `json:"runDistributions"`
ClientManagerDistributions []domain.ClientManagerDistribution `json:"clientManagerDistributions"` DependencyStatuses []domain.DependencyStatus `json:"dependencyStatuses"`
ClientManagerInstallations []domain.ClientManagerInstallation `json:"clientManagerInstallations"` RunUpdateJobs []domain.RunUpdateJob `json:"runUpdateJobs"`
ClientManagerSessions []domain.ClientManagerSession `json:"clientManagerSessions"` LogStreams []domain.LogStream `json:"logStreams"`
ClientManagerNonces []domain.ClientManagerRegistrationNonce `json:"clientManagerNonces"` MetricSamples []domain.MetricSample `json:"metricSamples"`
DependencyStatuses []domain.DependencyStatus `json:"dependencyStatuses"` Backups []domain.BackupRecord `json:"backups"`
ClientManagerBuildJobs []domain.ClientManagerBuildJob `json:"clientManagerBuildJobs"` PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"`
RunUpdateJobs []domain.RunUpdateJob `json:"runUpdateJobs"` AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"`
LogStreams []domain.LogStream `json:"logStreams"` GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
MetricSamples []domain.MetricSample `json:"metricSamples"` GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
Backups []domain.BackupRecord `json:"backups"` GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"` PluginDataRecords []domain.PluginDataRecord `json:"pluginDataRecords"`
AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"`
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
PluginDataRecords []domain.PluginDataRecord `json:"pluginDataRecords"`
} }
type FileStore struct { type FileStore struct {
@@ -119,30 +114,10 @@ func (store *FileStore) RunDistributions() RunDistributionRepository {
return &persistentRepository[domain.RunDistribution, domain.RunDistributionFilter]{repository: store.MemoryStore.runDists, persist: store.persist} return &persistentRepository[domain.RunDistribution, domain.RunDistributionFilter]{repository: store.MemoryStore.runDists, persist: store.persist}
} }
func (store *FileStore) ClientManagerDistributions() ClientManagerDistributionRepository {
return &persistentRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]{repository: store.MemoryStore.clientDists, persist: store.persist}
}
func (store *FileStore) ClientManagerInstallations() ClientManagerInstallationRepository {
return &persistentRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]{repository: store.MemoryStore.clientInstalls, persist: store.persist}
}
func (store *FileStore) ClientManagerSessions() ClientManagerSessionRepository {
return &persistentRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]{repository: store.MemoryStore.clientSessions, persist: store.persist}
}
func (store *FileStore) ClientManagerNonces() ClientManagerNonceRepository {
return &persistentRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]{repository: store.MemoryStore.clientNonces, persist: store.persist}
}
func (store *FileStore) DependencyStatuses() DependencyStatusRepository { func (store *FileStore) DependencyStatuses() DependencyStatusRepository {
return &persistentRepository[domain.DependencyStatus, domain.DependencyStatusFilter]{repository: store.MemoryStore.dependencies, persist: store.persist} return &persistentRepository[domain.DependencyStatus, domain.DependencyStatusFilter]{repository: store.MemoryStore.dependencies, persist: store.persist}
} }
func (store *FileStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository {
return &persistentRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]{repository: store.MemoryStore.buildJobs, persist: store.persist}
}
func (store *FileStore) RunUpdateJobs() RunUpdateJobRepository { func (store *FileStore) RunUpdateJobs() RunUpdateJobRepository {
return &persistentRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]{repository: store.MemoryStore.updateJobs, persist: store.persist} return &persistentRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]{repository: store.MemoryStore.updateJobs, persist: store.persist}
} }
@@ -227,34 +202,29 @@ func (store *FileStore) persist() error {
func (store *FileStore) snapshot() StoreSnapshot { func (store *FileStore) snapshot() StoreSnapshot {
return StoreSnapshot{ return StoreSnapshot{
Users: snapshotRepository(store.MemoryStore.users), Users: snapshotRepository(store.MemoryStore.users),
AuthSessions: snapshotRepository(store.MemoryStore.authSessions), AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions), RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
AIProviders: snapshotRepository(store.MemoryStore.aiProviders), AIProviders: snapshotRepository(store.MemoryStore.aiProviders),
GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins),
ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), ServerInstances: snapshotRepository(store.MemoryStore.serverInstances),
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository),
Artifacts: snapshotRepository(store.MemoryStore.artifacts), Artifacts: snapshotRepository(store.MemoryStore.artifacts),
RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings), RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings),
EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys), EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys),
RunDistributions: snapshotRepository(store.MemoryStore.runDists), RunDistributions: snapshotRepository(store.MemoryStore.runDists),
ClientManagerDistributions: snapshotRepository(store.MemoryStore.clientDists), DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies),
ClientManagerInstallations: snapshotRepository(store.MemoryStore.clientInstalls), RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
ClientManagerSessions: snapshotRepository(store.MemoryStore.clientSessions), LogStreams: snapshotRepository(store.MemoryStore.logStreams),
ClientManagerNonces: snapshotRepository(store.MemoryStore.clientNonces), MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies), Backups: snapshotRepository(store.MemoryStore.backups),
ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs), PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs), AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
LogStreams: snapshotRepository(store.MemoryStore.logStreams), GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
Backups: snapshotRepository(store.MemoryStore.backups), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle), PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
} }
} }
@@ -271,12 +241,7 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.runtimeBindings, snapshot.RuntimeBindings) loadRepository(store.MemoryStore.runtimeBindings, snapshot.RuntimeBindings)
loadRepository(store.MemoryStore.componentKeys, snapshot.EncryptedComponentKeys) loadRepository(store.MemoryStore.componentKeys, snapshot.EncryptedComponentKeys)
loadRepository(store.MemoryStore.runDists, snapshot.RunDistributions) loadRepository(store.MemoryStore.runDists, snapshot.RunDistributions)
loadRepository(store.MemoryStore.clientDists, snapshot.ClientManagerDistributions)
loadRepository(store.MemoryStore.clientInstalls, snapshot.ClientManagerInstallations)
loadRepository(store.MemoryStore.clientSessions, snapshot.ClientManagerSessions)
loadRepository(store.MemoryStore.clientNonces, snapshot.ClientManagerNonces)
loadRepository(store.MemoryStore.dependencies, snapshot.DependencyStatuses) loadRepository(store.MemoryStore.dependencies, snapshot.DependencyStatuses)
loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs)
loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs) loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs)
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams) loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples) loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
+23 -53
View File
@@ -101,30 +101,10 @@ func (store *MySQLStore) RunDistributions() RunDistributionRepository {
return &persistentRepository[domain.RunDistribution, domain.RunDistributionFilter]{repository: store.MemoryStore.runDists, persist: store.persist} return &persistentRepository[domain.RunDistribution, domain.RunDistributionFilter]{repository: store.MemoryStore.runDists, persist: store.persist}
} }
func (store *MySQLStore) ClientManagerDistributions() ClientManagerDistributionRepository {
return &persistentRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]{repository: store.MemoryStore.clientDists, persist: store.persist}
}
func (store *MySQLStore) ClientManagerInstallations() ClientManagerInstallationRepository {
return &persistentRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]{repository: store.MemoryStore.clientInstalls, persist: store.persist}
}
func (store *MySQLStore) ClientManagerSessions() ClientManagerSessionRepository {
return &persistentRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]{repository: store.MemoryStore.clientSessions, persist: store.persist}
}
func (store *MySQLStore) ClientManagerNonces() ClientManagerNonceRepository {
return &persistentRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]{repository: store.MemoryStore.clientNonces, persist: store.persist}
}
func (store *MySQLStore) DependencyStatuses() DependencyStatusRepository { func (store *MySQLStore) DependencyStatuses() DependencyStatusRepository {
return &persistentRepository[domain.DependencyStatus, domain.DependencyStatusFilter]{repository: store.MemoryStore.dependencies, persist: store.persist} return &persistentRepository[domain.DependencyStatus, domain.DependencyStatusFilter]{repository: store.MemoryStore.dependencies, persist: store.persist}
} }
func (store *MySQLStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository {
return &persistentRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]{repository: store.MemoryStore.buildJobs, persist: store.persist}
}
func (store *MySQLStore) RunUpdateJobs() RunUpdateJobRepository { func (store *MySQLStore) RunUpdateJobs() RunUpdateJobRepository {
return &persistentRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]{repository: store.MemoryStore.updateJobs, persist: store.persist} return &persistentRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]{repository: store.MemoryStore.updateJobs, persist: store.persist}
} }
@@ -226,34 +206,29 @@ ON DUPLICATE KEY UPDATE snapshot_json = ?, updated_at = CURRENT_TIMESTAMP`, mysq
func (store *MySQLStore) snapshot() StoreSnapshot { func (store *MySQLStore) snapshot() StoreSnapshot {
return StoreSnapshot{ return StoreSnapshot{
Users: snapshotRepository(store.MemoryStore.users), Users: snapshotRepository(store.MemoryStore.users),
AuthSessions: snapshotRepository(store.MemoryStore.authSessions), AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions), RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
AIProviders: snapshotRepository(store.MemoryStore.aiProviders), AIProviders: snapshotRepository(store.MemoryStore.aiProviders),
GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins),
ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), ServerInstances: snapshotRepository(store.MemoryStore.serverInstances),
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository),
Artifacts: snapshotRepository(store.MemoryStore.artifacts), Artifacts: snapshotRepository(store.MemoryStore.artifacts),
RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings), RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings),
EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys), EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys),
RunDistributions: snapshotRepository(store.MemoryStore.runDists), RunDistributions: snapshotRepository(store.MemoryStore.runDists),
ClientManagerDistributions: snapshotRepository(store.MemoryStore.clientDists), DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies),
ClientManagerInstallations: snapshotRepository(store.MemoryStore.clientInstalls), RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
ClientManagerSessions: snapshotRepository(store.MemoryStore.clientSessions), LogStreams: snapshotRepository(store.MemoryStore.logStreams),
ClientManagerNonces: snapshotRepository(store.MemoryStore.clientNonces), MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies), Backups: snapshotRepository(store.MemoryStore.backups),
ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs), PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs), AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
LogStreams: snapshotRepository(store.MemoryStore.logStreams), GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
Backups: snapshotRepository(store.MemoryStore.backups), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle), PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
} }
} }
@@ -270,12 +245,7 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.runtimeBindings, snapshot.RuntimeBindings) loadRepository(store.MemoryStore.runtimeBindings, snapshot.RuntimeBindings)
loadRepository(store.MemoryStore.componentKeys, snapshot.EncryptedComponentKeys) loadRepository(store.MemoryStore.componentKeys, snapshot.EncryptedComponentKeys)
loadRepository(store.MemoryStore.runDists, snapshot.RunDistributions) loadRepository(store.MemoryStore.runDists, snapshot.RunDistributions)
loadRepository(store.MemoryStore.clientDists, snapshot.ClientManagerDistributions)
loadRepository(store.MemoryStore.clientInstalls, snapshot.ClientManagerInstallations)
loadRepository(store.MemoryStore.clientSessions, snapshot.ClientManagerSessions)
loadRepository(store.MemoryStore.clientNonces, snapshot.ClientManagerNonces)
loadRepository(store.MemoryStore.dependencies, snapshot.DependencyStatuses) loadRepository(store.MemoryStore.dependencies, snapshot.DependencyStatuses)
loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs)
loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs) loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs)
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams) loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples) loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
+5 -124
View File
@@ -99,35 +99,6 @@ type RunDistributionRepository interface {
Update(domain.RunDistribution) error Update(domain.RunDistribution) error
} }
type ClientManagerDistributionRepository interface {
Create(domain.ClientManagerDistribution) error
Get(id string) (domain.ClientManagerDistribution, error)
List(domain.ClientManagerDistributionFilter) ([]domain.ClientManagerDistribution, error)
Update(domain.ClientManagerDistribution) error
}
type ClientManagerInstallationRepository interface {
Create(domain.ClientManagerInstallation) error
Get(id string) (domain.ClientManagerInstallation, error)
List(domain.ClientManagerInstallationFilter) ([]domain.ClientManagerInstallation, error)
Update(domain.ClientManagerInstallation) error
}
type ClientManagerSessionRepository interface {
Create(domain.ClientManagerSession) error
Get(id string) (domain.ClientManagerSession, error)
List(domain.ClientManagerSessionFilter) ([]domain.ClientManagerSession, error)
Update(domain.ClientManagerSession) error
}
type ClientManagerNonceRepository interface {
Create(domain.ClientManagerRegistrationNonce) error
Get(id string) (domain.ClientManagerRegistrationNonce, error)
List(domain.ClientManagerNonceFilter) ([]domain.ClientManagerRegistrationNonce, error)
Update(domain.ClientManagerRegistrationNonce) error
Delete(id string) error
}
type DependencyStatusRepository interface { type DependencyStatusRepository interface {
Create(domain.DependencyStatus) error Create(domain.DependencyStatus) error
Get(id string) (domain.DependencyStatus, error) Get(id string) (domain.DependencyStatus, error)
@@ -135,13 +106,6 @@ type DependencyStatusRepository interface {
Update(domain.DependencyStatus) error Update(domain.DependencyStatus) error
} }
type ClientManagerBuildJobRepository interface {
Create(domain.ClientManagerBuildJob) error
Get(id string) (domain.ClientManagerBuildJob, error)
List(domain.ClientManagerBuildJobFilter) ([]domain.ClientManagerBuildJob, error)
Update(domain.ClientManagerBuildJob) error
}
type RunUpdateJobRepository interface { type RunUpdateJobRepository interface {
Create(domain.RunUpdateJob) error Create(domain.RunUpdateJob) error
Get(id string) (domain.RunUpdateJob, error) Get(id string) (domain.RunUpdateJob, error)
@@ -233,12 +197,7 @@ type Store interface {
RuntimeBindings() RuntimeBindingRepository RuntimeBindings() RuntimeBindingRepository
EncryptedComponentKeys() EncryptedComponentKeyRepository EncryptedComponentKeys() EncryptedComponentKeyRepository
RunDistributions() RunDistributionRepository RunDistributions() RunDistributionRepository
ClientManagerDistributions() ClientManagerDistributionRepository
ClientManagerInstallations() ClientManagerInstallationRepository
ClientManagerSessions() ClientManagerSessionRepository
ClientManagerNonces() ClientManagerNonceRepository
DependencyStatuses() DependencyStatusRepository DependencyStatuses() DependencyStatusRepository
ClientManagerBuildJobs() ClientManagerBuildJobRepository
RunUpdateJobs() RunUpdateJobRepository RunUpdateJobs() RunUpdateJobRepository
LogStreams() LogStreamRepository LogStreams() LogStreamRepository
MetricSamples() MetricSampleRepository MetricSamples() MetricSampleRepository
@@ -264,12 +223,7 @@ type MemoryStore struct {
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter] runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter] componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter] runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]
clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]
clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter] dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter] updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter] logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter] metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
@@ -340,36 +294,11 @@ func NewMemoryStore() *MemoryStore {
domain.CopyRunDistribution, domain.CopyRunDistribution,
matchRunDistribution, matchRunDistribution,
), ),
clientDists: newMemoryRepository(
func(distribution domain.ClientManagerDistribution) string { return distribution.ID },
domain.CopyClientManagerDistribution,
matchClientManagerDistribution,
),
clientInstalls: newMemoryRepository(
func(installation domain.ClientManagerInstallation) string { return installation.ID },
domain.CopyClientManagerInstallation,
matchClientManagerInstallation,
),
clientSessions: newMemoryRepository(
func(session domain.ClientManagerSession) string { return session.ID },
domain.CopyClientManagerSession,
matchClientManagerSession,
),
clientNonces: newMemoryRepository(
func(nonce domain.ClientManagerRegistrationNonce) string { return nonce.ID },
domain.CopyClientManagerRegistrationNonce,
matchClientManagerNonce,
),
dependencies: newMemoryRepository( dependencies: newMemoryRepository(
func(status domain.DependencyStatus) string { return status.ID }, func(status domain.DependencyStatus) string { return status.ID },
domain.CopyDependencyStatus, domain.CopyDependencyStatus,
matchDependencyStatus, matchDependencyStatus,
), ),
buildJobs: newMemoryRepository(
func(job domain.ClientManagerBuildJob) string { return job.ID },
domain.CopyClientManagerBuildJob,
matchClientManagerBuildJob,
),
updateJobs: newMemoryRepository( updateJobs: newMemoryRepository(
func(job domain.RunUpdateJob) string { return job.ID }, func(job domain.RunUpdateJob) string { return job.ID },
domain.CopyRunUpdateJob, domain.CopyRunUpdateJob,
@@ -424,27 +353,12 @@ func (store *MemoryStore) RuntimeBindings() RuntimeBindingRepository { ret
func (store *MemoryStore) EncryptedComponentKeys() EncryptedComponentKeyRepository { func (store *MemoryStore) EncryptedComponentKeys() EncryptedComponentKeyRepository {
return store.componentKeys return store.componentKeys
} }
func (store *MemoryStore) RunDistributions() RunDistributionRepository { return store.runDists } func (store *MemoryStore) RunDistributions() RunDistributionRepository { return store.runDists }
func (store *MemoryStore) ClientManagerDistributions() ClientManagerDistributionRepository {
return store.clientDists
}
func (store *MemoryStore) ClientManagerInstallations() ClientManagerInstallationRepository {
return store.clientInstalls
}
func (store *MemoryStore) ClientManagerSessions() ClientManagerSessionRepository {
return store.clientSessions
}
func (store *MemoryStore) ClientManagerNonces() ClientManagerNonceRepository {
return store.clientNonces
}
func (store *MemoryStore) DependencyStatuses() DependencyStatusRepository { return store.dependencies } func (store *MemoryStore) DependencyStatuses() DependencyStatusRepository { return store.dependencies }
func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository { func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs }
return store.buildJobs func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams }
} func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples }
func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs } func (store *MemoryStore) Backups() BackupRepository { return store.backups }
func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams }
func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples }
func (store *MemoryStore) Backups() BackupRepository { return store.backups }
func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository { func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository {
return store.pluginLifecycle return store.pluginLifecycle
} }
@@ -659,45 +573,12 @@ func matchRunDistribution(distribution domain.RunDistribution, filter domain.Run
(filter.Status == "" || distribution.Status == filter.Status) (filter.Status == "" || distribution.Status == filter.Status)
} }
func matchClientManagerDistribution(distribution domain.ClientManagerDistribution, filter domain.ClientManagerDistributionFilter) bool {
return (filter.ServerInstanceID == "" || distribution.ServerInstanceID == filter.ServerInstanceID) &&
(filter.ProfileKey == "" || distribution.ProfileKey == filter.ProfileKey) &&
(filter.TargetOS == "" || distribution.TargetOS == filter.TargetOS) &&
(filter.TargetArch == "" || distribution.TargetArch == filter.TargetArch) &&
(filter.Status == "" || distribution.Status == filter.Status)
}
func matchClientManagerInstallation(installation domain.ClientManagerInstallation, filter domain.ClientManagerInstallationFilter) bool {
return (filter.ServerInstanceID == "" || installation.ServerInstanceID == filter.ServerInstanceID) &&
(filter.ProfileKey == "" || installation.ProfileKey == filter.ProfileKey) &&
(filter.RunEndpointID == "" || installation.RunEndpointID == filter.RunEndpointID) &&
(filter.Status == "" || installation.Status == filter.Status)
}
func matchClientManagerSession(session domain.ClientManagerSession, filter domain.ClientManagerSessionFilter) bool {
return (filter.InstallationID == "" || session.InstallationID == filter.InstallationID) &&
(filter.ServerInstanceID == "" || session.ServerInstanceID == filter.ServerInstanceID) &&
(filter.ProfileKey == "" || session.ProfileKey == filter.ProfileKey) &&
(filter.Status == "" || session.Status == filter.Status)
}
func matchClientManagerNonce(nonce domain.ClientManagerRegistrationNonce, filter domain.ClientManagerNonceFilter) bool {
return (filter.InstallationID == "" || nonce.InstallationID == filter.InstallationID) &&
(filter.ExpiresBefore.IsZero() || nonce.ExpiresAt.Before(filter.ExpiresBefore) || nonce.ExpiresAt.Equal(filter.ExpiresBefore))
}
func matchDependencyStatus(status domain.DependencyStatus, filter domain.DependencyStatusFilter) bool { func matchDependencyStatus(status domain.DependencyStatus, filter domain.DependencyStatusFilter) bool {
return (filter.ServerInstanceID == "" || status.ServerInstanceID == filter.ServerInstanceID) && return (filter.ServerInstanceID == "" || status.ServerInstanceID == filter.ServerInstanceID) &&
(filter.ProbeKey == "" || status.ProbeKey == filter.ProbeKey) && (filter.ProbeKey == "" || status.ProbeKey == filter.ProbeKey) &&
(filter.State == "" || status.State == filter.State) (filter.State == "" || status.State == filter.State)
} }
func matchClientManagerBuildJob(job domain.ClientManagerBuildJob, filter domain.ClientManagerBuildJobFilter) bool {
return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) &&
(filter.ProfileKey == "" || job.ProfileKey == filter.ProfileKey) &&
(filter.Status == "" || job.Status == filter.Status)
}
func matchRunUpdateJob(job domain.RunUpdateJob, filter domain.RunUpdateJobFilter) bool { func matchRunUpdateJob(job domain.RunUpdateJob, filter domain.RunUpdateJobFilter) bool {
return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) && return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) &&
(filter.Status == "" || job.Status == filter.Status) (filter.Status == "" || job.Status == filter.Status)
-9
View File
@@ -282,15 +282,6 @@ func (svc *CoreService) distributionArtifactDownloadPresentation(artifactID stri
} }
} }
} }
clients, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{})
if err == nil {
for _, distribution := range clients {
if distribution.ArtifactID == artifactID {
format := packageFormatForTarget(distribution.TargetOS)
return distributionPackageFilename(distribution.ProfileKey, distribution.TargetOS, distribution.TargetArch, format), packageContentType(format), true
}
}
}
return "", "", false return "", "", false
} }
File diff suppressed because it is too large Load Diff
-5
View File
@@ -389,11 +389,6 @@ func TestCoreServiceGeneratedSCUMRunRegistrationDoesNotQueueGuidedStart(t *testi
domain.JobCapabilityFilesRead, domain.JobCapabilityFilesRead,
"files.patch", "files.patch",
"logs.read", "logs.read",
domain.JobCapabilityClientManagerDeploy,
domain.JobCapabilityClientManagerControl,
domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback,
domain.JobCapabilityClientManagerUninstall,
"artifacts.read", "artifacts.read",
"artifacts.write", "artifacts.write",
"ai.invoke", "ai.invoke",
@@ -169,45 +169,6 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
}, nil }, nil
} }
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return domain.DistributionBuildInput{}, err
}
for _, distribution := range clientDistributions {
if distribution.BuildJobID != job.ID {
continue
}
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
if err != nil {
return domain.DistributionBuildInput{}, err
}
if key.Generation != distribution.KeyGeneration {
return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current")
}
plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey)
if err != nil {
return domain.DistributionBuildInput{}, err
}
return domain.DistributionBuildInput{
JobID: job.ID,
ComponentKind: domain.DistributionComponentClientManager,
ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID,
RunEndpointID: job.RunEndpointID,
ProfileKey: distribution.ProfileKey,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
PlatformURL: runReleasePlatformURL(),
PackageFormat: packageFormatForTarget(distribution.TargetOS),
RepositoryURL: distribution.RepositoryURL,
SourceRevision: distribution.SourceRevision,
ArtifactID: distribution.ArtifactID,
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey,
}, nil
}
return domain.DistributionBuildInput{}, repo.ErrNotFound return domain.DistributionBuildInput{}, repo.ErrNotFound
} }
@@ -25,34 +25,6 @@ type progressDistributionBuilder struct {
payload []byte payload []byte
} }
type failingBuildProgressStore struct {
repo.Store
buildJobs repo.ClientManagerBuildJobRepository
}
func (store failingBuildProgressStore) ClientManagerBuildJobs() repo.ClientManagerBuildJobRepository {
return store.buildJobs
}
type failWhenDistributionJobRunningRepository struct {
repo.ClientManagerBuildJobRepository
jobs repo.JobRepository
err error
}
func (repository failWhenDistributionJobRunningRepository) List(filter domain.ClientManagerBuildJobFilter) ([]domain.ClientManagerBuildJob, error) {
jobs, err := repository.jobs.List(domain.JobFilter{ServerInstanceID: filter.ServerInstanceID})
if err != nil {
return nil, err
}
for _, job := range jobs {
if job.Capability == domain.JobCapabilityDistributionBuild && job.State == domain.JobStateRunning {
return nil, repository.err
}
}
return repository.ClientManagerBuildJobRepository.List(filter)
}
func (builder captureDistributionBuilder) Readiness() (bool, string) { func (builder captureDistributionBuilder) Readiness() (bool, string) {
return true, "" return true, ""
} }
@@ -421,49 +393,6 @@ func TestCoreServiceProjectsPlatformBuilderProgressBeforeCompletion(t *testing.T
completeDistributionBuild(t, svc, distribution, nil) completeDistributionBuild(t, svc, distribution, nil)
} }
func TestCoreServiceTerminalizesPlatformBuildWhenRunningProjectionFails(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
const leakedDetail = "sensitive-builder-token /private/platform/build/input/auth-key"
baseStore := svc.store
svc.store = failingBuildProgressStore{
Store: baseStore,
buildJobs: failWhenDistributionJobRunningRepository{
ClientManagerBuildJobRepository: baseStore.ClientManagerBuildJobs(),
jobs: baseStore.Jobs(),
err: errors.New(leakedDetail),
},
}
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "running-projection-failure-terminalizes",
})
if err != nil {
t.Fatalf("queue platform distribution: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
job, jobErr := svc.GetJob(distribution.BuildJobID)
updated, distributionErr := svc.store.RunDistributions().Get(distribution.ID)
if jobErr != nil || distributionErr != nil {
t.Fatalf("read failed build state: jobErr=%v distributionErr=%v", jobErr, distributionErr)
}
if isTerminalJobState(job.State) {
if job.State != domain.JobStateFailed || updated.Status != domain.DistributionStatusFailed {
t.Fatalf("projection failure did not end in failed state: job=%+v distribution=%+v", job, updated)
}
if strings.Contains(job.Progress.Message, "sensitive-builder-token") || strings.Contains(job.Progress.Message, "/private/") || strings.Contains(job.TerminalFingerprint, "sensitive-builder-token") {
t.Fatalf("terminal build state leaked internal failure details: %+v", job)
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("platform build remained non-terminal after running projection failure")
}
func TestCoreServiceDiscardsBuildCompletedAfterKeyReset(t *testing.T) { func TestCoreServiceDiscardsBuildCompletedAfterKeyReset(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
inputs := make(chan domain.DistributionBuildInput, 1) inputs := make(chan domain.DistributionBuildInput, 1)
-119
View File
@@ -1,7 +1,6 @@
package service package service
import ( import (
"errors"
"strings" "strings"
"time" "time"
@@ -77,44 +76,6 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
}, nil }, nil
} }
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return domain.DistributionBuildInput{}, err
}
for _, distribution := range clientDistributions {
if distribution.BuildJobID != job.ID {
continue
}
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
if err != nil {
return domain.DistributionBuildInput{}, err
}
if key.Generation != distribution.KeyGeneration {
return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current")
}
plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey)
if err != nil {
return domain.DistributionBuildInput{}, err
}
return domain.DistributionBuildInput{
JobID: job.ID,
ComponentKind: domain.DistributionComponentClientManager,
ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID,
RunEndpointID: job.RunEndpointID,
ProfileKey: distribution.ProfileKey,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
PackageFormat: packageFormatForTarget(distribution.TargetOS),
RepositoryURL: distribution.RepositoryURL,
SourceRevision: distribution.SourceRevision,
ArtifactID: distribution.ArtifactID,
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey,
}, nil
}
return domain.DistributionBuildInput{}, repo.ErrNotFound return domain.DistributionBuildInput{}, repo.ErrNotFound
} }
@@ -122,21 +83,6 @@ func (svc *CoreService) projectDistributionBuildProgress(job domain.Job, stamp t
if job.Capability != domain.JobCapabilityDistributionBuild { if job.Capability != domain.JobCapabilityDistributionBuild {
return nil return nil
} }
builds, err := svc.store.ClientManagerBuildJobs().List(domain.ClientManagerBuildJobFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, build := range builds {
if build.ID != job.ID || build.Status != domain.DistributionJobStatusQueued {
continue
}
build.Status = domain.DistributionJobStatusRunning
build.UpdatedAt = stamp
if err := validator.ValidateClientManagerBuildJob(build); err != nil {
return err
}
return svc.store.ClientManagerBuildJobs().Update(build)
}
return nil return nil
} }
@@ -148,7 +94,6 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim
return err return err
} }
status := domain.DistributionStatusFailed status := domain.DistributionStatusFailed
buildStatus := domain.DistributionJobStatusFailed
var artifact domain.Artifact var artifact domain.Artifact
if job.State == domain.JobStateSucceeded { if job.State == domain.JobStateSucceeded {
artifactID := strings.TrimPrefix(job.ResultRef, "artifact://") artifactID := strings.TrimPrefix(job.ResultRef, "artifact://")
@@ -164,7 +109,6 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim
return validationError("distribution build artifact is unavailable or outside the job scope") return validationError("distribution build artifact is unavailable or outside the job scope")
} }
status = domain.DistributionStatusAvailable status = domain.DistributionStatusAvailable
buildStatus = domain.DistributionJobStatusSucceeded
} }
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID}) runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID})
@@ -195,57 +139,6 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim
return svc.store.RunDistributions().Update(distribution) return svc.store.RunDistributions().Update(distribution)
} }
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, distribution := range clientDistributions {
if distribution.BuildJobID != job.ID {
continue
}
if distribution.Status == domain.DistributionStatusRevoked {
if artifact.ID != "" {
return svc.expireDistributionArtifact(artifact.ID)
}
return nil
}
if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID {
return validationError("client-manager build returned an unexpected artifact")
}
distribution.Status = status
if artifact.ID != "" {
distribution.Checksum = artifact.Checksum
}
distribution.UpdatedAt = stamp
if err := validator.ValidateClientManagerDistribution(distribution); err != nil {
return err
}
if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil {
return err
}
if err := svc.ProjectClientManagerDistribution(distribution); err != nil {
return err
}
build, err := svc.store.ClientManagerBuildJobs().Get(job.ID)
if err != nil && !errors.Is(err, repo.ErrNotFound) {
return err
}
if err == nil {
build.Status = buildStatus
if artifact.ID != "" {
build.ArtifactID = artifact.ID
build.Checksum = artifact.Checksum
}
build.UpdatedAt = stamp
if err := validator.ValidateClientManagerBuildJob(build); err != nil {
return err
}
if err := svc.store.ClientManagerBuildJobs().Update(build); err != nil {
return err
}
}
return nil
}
return repo.ErrNotFound return repo.ErrNotFound
} }
@@ -265,18 +158,6 @@ func (svc *CoreService) validateDistributionBuildResult(job domain.Job) error {
break break
} }
} }
if expectedArtifactID == "" {
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, distribution := range clientDistributions {
if distribution.BuildJobID == job.ID {
expectedArtifactID = distribution.ArtifactID
break
}
}
}
if expectedArtifactID == "" { if expectedArtifactID == "" {
return repo.ErrNotFound return repo.ErrNotFound
} }
+3 -105
View File
@@ -2,10 +2,8 @@ package service
import ( import (
"archive/tar" "archive/tar"
"archive/zip"
"bufio" "bufio"
"bytes" "bytes"
"compress/gzip"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
@@ -307,14 +305,7 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib
if len(binary) == 0 { if len(binary) == 0 {
return nil, validationError("platform builder produced an empty distribution executable") return nil, validationError("platform builder produced an empty distribution executable")
} }
if input.ComponentKind == domain.DistributionComponentRun { return binary, nil
return binary, nil
}
configPayload, err := os.ReadFile(filepath.Join(outputDir, "config.yaml"))
if err != nil {
return nil, validationError("platform builder did not produce client-manager configuration")
}
return packageClientManagerDistribution(input.PackageFormat, outputName, binary, configPayload)
} }
// prepareRunSource implements the source phase of a Jenkins-style build. A // prepareRunSource implements the source phase of a Jenkins-style build. A
@@ -519,36 +510,8 @@ EOF
exit 0 exit 0
fi fi
if [ "$COMPONENT_KIND" != "client-manager" ]; then printf 'unsupported component kind\n' >&2
printf 'unsupported component kind\n' >&2 exit 2
exit 2
fi
case "$REPOSITORY_URL" in
https://*) ;;
*) printf 'client-manager repository must use https\n' >&2; exit 2 ;;
esac
cd /workspace/build
progress 24 'git_sync: fetching client-manager source'
git init --quiet
git remote add origin "$REPOSITORY_URL"
git fetch --quiet --depth 1 origin "$SOURCE_REVISION"
git checkout --quiet --detach FETCH_HEAD
progress 36 'env_check: writing client-manager configuration'
{
printf 'server_url: "%s"\n' "$PLATFORM_URL"
printf 'server_instance_id: "%s"\n' "$SERVER_INSTANCE_ID"
printf 'scum_client_credential: "%s"\n' "$auth_key"
printf 'scum_client_name: "%s"\n' "$PROFILE_KEY"
printf 'scum_client_version: "platform-build"\n'
printf 'scum_client_machine_label: "managed-client"\n'
printf 'ftp_provider: 3\n'
} > config.yaml
progress 52 'deps_download: downloading Go modules'
go mod download
progress 76 'build_compile: compiling client-manager executable'
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" .
cp config.yaml /workspace/output/config.yaml
progress 88 'package_finalize: client-manager package inputs written'
` `
func (builder *DockerDistributionBuilder) containerArgs(input domain.DistributionBuildInput, sourceDir string, inputDir string, buildDir string, outputDir string, goBuildCacheDir string, goModCacheDir string, outputName string) []string { func (builder *DockerDistributionBuilder) containerArgs(input domain.DistributionBuildInput, sourceDir string, inputDir string, buildDir string, outputDir string, goBuildCacheDir string, goModCacheDir string, outputName string) []string {
@@ -588,71 +551,6 @@ func (builder *DockerDistributionBuilder) containerArgs(input domain.Distributio
} }
} }
func packageClientManagerDistribution(packageFormat string, outputName string, binary []byte, configPayload []byte) ([]byte, error) {
switch packageFormat {
case "zip":
return zipDistributionFiles(outputName, binary, configPayload)
case "tar.gz":
return tarGzipDistributionFiles(outputName, binary, configPayload)
default:
return nil, validationError("platform builder received an unsupported client-manager package format")
}
}
func zipDistributionFiles(outputName string, binary []byte, configPayload []byte) ([]byte, error) {
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
files := []struct {
name string
mode os.FileMode
payload []byte
}{{outputName, 0o755, binary}, {"config.yaml", 0o600, configPayload}}
for _, file := range files {
header := &zip.FileHeader{Name: file.name, Method: zip.Deflate}
header.SetMode(file.mode)
header.SetModTime(time.Date(1980, time.January, 1, 0, 0, 0, 0, time.UTC))
entry, err := writer.CreateHeader(header)
if err != nil {
return nil, err
}
if _, err := entry.Write(file.payload); err != nil {
return nil, err
}
}
if err := writer.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func tarGzipDistributionFiles(outputName string, binary []byte, configPayload []byte) ([]byte, error) {
var buffer bytes.Buffer
gzipWriter := gzip.NewWriter(&buffer)
gzipWriter.Header.ModTime = time.Unix(0, 0).UTC()
tarWriter := tar.NewWriter(gzipWriter)
files := []struct {
name string
mode int64
payload []byte
}{{outputName, 0o755, binary}, {"config.yaml", 0o600, configPayload}}
for _, file := range files {
header := &tar.Header{Name: file.name, Mode: file.mode, Size: int64(len(file.payload)), ModTime: time.Unix(0, 0).UTC()}
if err := tarWriter.WriteHeader(header); err != nil {
return nil, err
}
if _, err := tarWriter.Write(file.payload); err != nil {
return nil, err
}
}
if err := tarWriter.Close(); err != nil {
return nil, err
}
if err := gzipWriter.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
// safeBuilderFailure keeps host paths and secret values out of reported build // safeBuilderFailure keeps host paths and secret values out of reported build
// failures. // failures.
func safeBuilderFailure(output []byte, sensitiveValues ...string) string { func safeBuilderFailure(output []byte, sensitiveValues ...string) string {
@@ -2,12 +2,9 @@ package service
import ( import (
"archive/tar" "archive/tar"
"archive/zip"
"bytes" "bytes"
"compress/gzip"
"context" "context"
"errors" "errors"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -384,29 +381,6 @@ func TestBuilderJobFailureMessageRedactsSensitiveValuesAndHostPaths(t *testing.T
} }
} }
func TestPackageClientManagerDistributionProducesProtectedArchives(t *testing.T) {
for _, packageFormat := range []string{"zip", "tar.gz"} {
t.Run(packageFormat, func(t *testing.T) {
payload, err := packageClientManagerDistribution(packageFormat, "client-manager.exe", []byte("binary"), []byte("credential: protected"))
if err != nil {
t.Fatalf("package client manager: %v", err)
}
files := readDistributionArchive(t, packageFormat, payload)
if string(files["client-manager.exe"].payload) != "binary" || files["client-manager.exe"].mode.Perm() != 0o755 {
t.Fatalf("unexpected executable archive entry: %+v", files["client-manager.exe"])
}
if string(files["config.yaml"].payload) != "credential: protected" || files["config.yaml"].mode.Perm() != 0o600 {
t.Fatalf("unexpected config archive entry: %+v", files["config.yaml"])
}
})
}
}
type distributionArchiveFile struct {
mode os.FileMode
payload []byte
}
func createBuilderSource(t *testing.T) string { func createBuilderSource(t *testing.T) string {
t.Helper() t.Helper()
sourceDir := t.TempDir() sourceDir := t.TempDir()
@@ -443,55 +417,3 @@ func builderMountHostPath(t *testing.T, args []string, containerSuffix string) s
t.Fatalf("Docker arguments do not mount %s: %+v", containerSuffix, args) t.Fatalf("Docker arguments do not mount %s: %+v", containerSuffix, args)
return "" return ""
} }
func readDistributionArchive(t *testing.T, packageFormat string, payload []byte) map[string]distributionArchiveFile {
t.Helper()
files := map[string]distributionArchiveFile{}
switch packageFormat {
case "zip":
reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
if err != nil {
t.Fatalf("open zip: %v", err)
}
for _, entry := range reader.File {
body, err := entry.Open()
if err != nil {
t.Fatalf("open zip entry %s: %v", entry.Name, err)
}
content, err := io.ReadAll(body)
if closeErr := body.Close(); err == nil {
err = closeErr
}
if err != nil {
t.Fatalf("read zip entry %s: %v", entry.Name, err)
}
files[entry.Name] = distributionArchiveFile{mode: entry.Mode(), payload: content}
}
case "tar.gz":
gzipReader, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("open gzip: %v", err)
}
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("read tar header: %v", err)
}
content, err := io.ReadAll(tarReader)
if err != nil {
t.Fatalf("read tar entry %s: %v", header.Name, err)
}
files[header.Name] = distributionArchiveFile{mode: os.FileMode(header.Mode), payload: content}
}
if err := gzipReader.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
default:
t.Fatalf("unsupported archive format %q", packageFormat)
}
return files
}
-224
View File
@@ -115,158 +115,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
return domain.CopyRunDistribution(distribution), nil return domain.CopyRunDistribution(distribution), nil
} }
func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID string, request domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error) {
return domain.ClientManagerDistribution{}, legacyClientManagerUnsupported()
request = domain.CopyClientManagerBuildRequest(request)
if strings.TrimSpace(request.IdempotencyKey) == "" {
request.IdempotencyKey = "client-manager-" + request.ServerInstanceID + "-" + request.ProfileKey + "-" + request.TargetOS + "-" + request.TargetArch
}
if err := validator.ValidateClientManagerBuildRequest(request); err != nil {
return domain.ClientManagerDistribution{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
if err := svc.validateDistributionPluginPermission(user.ID, plugin, instance.ID, "server.client-manager.manage", "client-manager.build.denied"); err != nil {
return domain.ClientManagerDistribution{}, err
}
if err := validatePluginTarget(plugin, request.TargetOS); err != nil {
return domain.ClientManagerDistribution{}, err
}
profile, err := findRuntimeClientManagerProfile(plugin, request.ProfileKey)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
if strings.TrimSpace(request.SourceRevision) == "" {
request.SourceRevision = clientManagerProfileRevision(profile)
}
if !clientManagerProfileSupportsTarget(profile, request.TargetOS, request.TargetArch) || request.RepositoryURL != profile.RepositoryURL || !clientManagerProfileAllowsRevision(profile, request.SourceRevision) {
return domain.ClientManagerDistribution{}, validationError("client-manager build must match the declared profile repository, revision, and target")
}
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil {
return domain.ClientManagerDistribution{}, err
}
if ready, reason := svc.distributionBuilderReadiness(); !ready {
return domain.ClientManagerDistribution{}, validationError(reason)
}
key, _, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
clientDistributionID := distributionID("client-manager-dist", instance.ID, request.ProfileKey, request.TargetOS, request.TargetArch, key.Generation, request.IdempotencyKey)
if existing, err := svc.store.ClientManagerDistributions().Get(clientDistributionID); err == nil {
return domain.CopyClientManagerDistribution(existing), nil
} else if !errors.Is(err, repo.ErrNotFound) {
return domain.ClientManagerDistribution{}, err
}
artifactID := artifactIDForDistribution(clientDistributionID + "-binary")
stamp := svc.now()
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, clientDistributionID)
buildJob := domain.ClientManagerBuildJob{
ID: buildJobID,
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
ProfileKey: request.ProfileKey,
Version: profile.Version,
TargetOS: request.TargetOS,
TargetArch: request.TargetArch,
RepositoryURL: request.RepositoryURL,
SourceRevision: request.SourceRevision,
ArtifactID: artifactID,
KeyGeneration: key.Generation,
Status: domain.DistributionJobStatusQueued,
CreatedAt: stamp,
UpdatedAt: stamp,
}
if err := validator.ValidateClientManagerBuildJob(buildJob); err != nil {
return domain.ClientManagerDistribution{}, err
}
if err := svc.store.ClientManagerBuildJobs().Create(buildJob); err != nil {
if !errors.Is(err, repo.ErrDuplicate) {
return domain.ClientManagerDistribution{}, err
}
existing, getErr := svc.store.ClientManagerBuildJobs().Get(buildJob.ID)
if getErr != nil {
return domain.ClientManagerDistribution{}, getErr
}
if !sameClientManagerBuildJobArtifacts(existing, buildJob) {
return domain.ClientManagerDistribution{}, validationError("client-manager build job already exists with different artifacts")
}
buildJob = existing
}
distribution := domain.ClientManagerDistribution{
ID: clientDistributionID,
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
ProfileKey: request.ProfileKey,
Version: profile.Version,
TargetOS: request.TargetOS,
TargetArch: request.TargetArch,
RepositoryURL: request.RepositoryURL,
SourceRevision: request.SourceRevision,
BuildJobID: buildJob.ID,
ArtifactID: artifactID,
KeyGeneration: key.Generation,
SecretRef: key.SecretRef,
Status: domain.DistributionStatusBuilding,
CreatedAt: stamp,
UpdatedAt: stamp,
}
if err := validator.ValidateClientManagerDistribution(distribution); err != nil {
return domain.ClientManagerDistribution{}, err
}
if err := svc.store.ClientManagerDistributions().Create(distribution); err != nil {
if errors.Is(err, repo.ErrDuplicate) {
existing, getErr := svc.store.ClientManagerDistributions().Get(distribution.ID)
if getErr != nil {
return domain.ClientManagerDistribution{}, getErr
}
return domain.CopyClientManagerDistribution(existing), nil
}
return domain.ClientManagerDistribution{}, err
}
if err := svc.ProjectClientManagerDistribution(distribution); err != nil {
return domain.ClientManagerDistribution{}, err
}
job, err := svc.CreateJob(domain.Job{
ID: buildJobID,
ServerInstanceID: instance.ID,
RunEndpointID: platformDistributionBuilderEndpointID,
Capability: domain.JobCapabilityDistributionBuild,
TargetKey: "distribution/client-manager/" + request.ProfileKey,
InputRef: "input://distribution-build/" + distribution.ID,
IdempotencyKey: "distribution-build:" + distribution.ID,
Progress: domain.JobProgress{Percent: 0, Message: "build queued"},
})
if err != nil {
buildJob.Status = domain.DistributionJobStatusFailed
buildJob.UpdatedAt = svc.now()
distribution.Status = domain.DistributionStatusFailed
distribution.UpdatedAt = buildJob.UpdatedAt
_ = svc.store.ClientManagerBuildJobs().Update(buildJob)
_ = svc.store.ClientManagerDistributions().Update(distribution)
_ = svc.ProjectClientManagerDistribution(distribution)
return domain.ClientManagerDistribution{}, err
}
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job")
}
svc.enqueueDistributionBuild(job)
return domain.CopyClientManagerDistribution(distribution), nil
}
func (svc *CoreService) OpenLatestRunDistributionDownloadForSession(sessionID string, serverInstanceID string) (domain.ArtifactDownloadReference, error) { func (svc *CoreService) OpenLatestRunDistributionDownloadForSession(sessionID string, serverInstanceID string) (domain.ArtifactDownloadReference, error) {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil { if err != nil {
@@ -288,33 +136,6 @@ func (svc *CoreService) OpenLatestRunDistributionDownloadForSession(sessionID st
return svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: latest.ArtifactID}) return svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: latest.ArtifactID})
} }
func (svc *CoreService) OpenLatestClientManagerDistributionDownloadForSession(sessionID string, serverInstanceID string, profileKey string) (domain.ArtifactDownloadReference, error) {
return domain.ArtifactDownloadReference{}, legacyClientManagerUnsupported()
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.ArtifactDownloadReference{}, err
}
filter := domain.ClientManagerDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable}
if strings.TrimSpace(profileKey) != "" {
filter.ProfileKey = strings.TrimSpace(profileKey)
}
distributions, err := svc.store.ClientManagerDistributions().List(filter)
if err != nil {
return domain.ArtifactDownloadReference{}, err
}
var latest domain.ClientManagerDistribution
for _, distribution := range distributions {
if latest.ID == "" || distribution.KeyGeneration > latest.KeyGeneration || (distribution.KeyGeneration == latest.KeyGeneration && distribution.UpdatedAt.After(latest.UpdatedAt)) {
latest = distribution
}
}
if latest.ID == "" {
return domain.ArtifactDownloadReference{}, repo.ErrNotFound
}
return svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: latest.ArtifactID})
}
func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request domain.ComponentKeyResetRequest) (domain.EncryptedComponentKey, error) { func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request domain.ComponentKeyResetRequest) (domain.EncryptedComponentKey, error) {
request = domain.CopyComponentKeyResetRequest(request) request = domain.CopyComponentKeyResetRequest(request)
if err := validator.ValidateComponentKeyResetRequest(request); err != nil { if err := validator.ValidateComponentKeyResetRequest(request); err != nil {
@@ -810,27 +631,6 @@ func (svc *CoreService) revokeComponentDistributions(serverInstanceID string, ki
return err return err
} }
} }
case domain.DistributionComponentClientManager:
distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: serverInstanceID, ProfileKey: componentKey})
if err != nil {
return err
}
for _, distribution := range distributions {
if distribution.KeyGeneration >= latestGeneration || distribution.Status == domain.DistributionStatusRevoked {
continue
}
distribution.Status = domain.DistributionStatusRevoked
distribution.UpdatedAt = svc.now()
if err := validator.ValidateClientManagerDistribution(distribution); err != nil {
return err
}
if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil {
return err
}
if err := svc.expireDistributionArtifact(distribution.ArtifactID); err != nil {
return err
}
}
} }
return nil return nil
} }
@@ -929,22 +729,6 @@ func (svc *CoreService) ensureArtifactPayload(artifactID string, payload []byte,
return nil return nil
} }
func sameClientManagerBuildJobArtifacts(existing domain.ClientManagerBuildJob, expected domain.ClientManagerBuildJob) bool {
return existing.ServerInstanceID == expected.ServerInstanceID &&
existing.PluginID == expected.PluginID &&
existing.ProfileKey == expected.ProfileKey &&
existing.Version == expected.Version &&
existing.TargetOS == expected.TargetOS &&
existing.TargetArch == expected.TargetArch &&
existing.RepositoryURL == expected.RepositoryURL &&
existing.SourceRevision == expected.SourceRevision &&
existing.ArtifactID == expected.ArtifactID &&
existing.Checksum == expected.Checksum &&
existing.KeyGeneration == expected.KeyGeneration &&
existing.LogsRef == expected.LogsRef &&
existing.Status == expected.Status
}
func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, jobID string, required bool, state domain.DependencyState, message string) error { func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, jobID string, required bool, state domain.DependencyState, message string) error {
statusID := distributionID("dependency-status", instance.ID, request.ProbeKey) statusID := distributionID("dependency-status", instance.ID, request.ProbeKey)
stamp := svc.now() stamp := svc.now()
@@ -1070,14 +854,6 @@ func fingerprintForString(value string) string {
return hex.EncodeToString(sum[:])[:12] return hex.EncodeToString(sum[:])[:12]
} }
func clientManagerOutputName(profileKey string, targetOS string) string {
name := sanitizeIDPart(profileKey)
if targetOS == "windows" {
return name + ".exe"
}
return name
}
func minInt(a int, b int) int { func minInt(a int, b int) int {
if a < b { if a < b {
return a return a
+9 -21
View File
@@ -511,30 +511,18 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test
} }
} }
func TestCoreServiceRejectsClientManagerDistributionRequests(t *testing.T) { func TestCoreServiceRejectsClientManagerComponentRequests(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
_, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
ServerInstanceID: instance.ID,
ProfileKey: "scum-client-manager",
TargetOS: "windows",
TargetArch: "amd64",
RepositoryURL: "https://github.com/F88888/scum_client.git",
SourceRevision: "main",
IdempotencyKey: "idem-client-manager",
})
if err == nil {
t.Fatal("expected client-manager distribution request to be rejected")
}
if _, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{ if _, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentClientManager, ComponentKind: domain.DistributionComponentKind("client-manager"),
ComponentKey: "scum-client-manager", ComponentKey: "scum-client-manager",
}); err == nil || !strings.Contains(err.Error(), "componentKind") { }); err == nil || !strings.Contains(err.Error(), "componentKind") {
t.Fatalf("expected client-manager key reset to be rejected, got %v", err) t.Fatalf("expected client-manager key reset to be rejected, got %v", err)
} }
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{ auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentClientManager, ComponentKind: domain.DistributionComponentKind("client-manager"),
ComponentKey: "scum-client-manager", ComponentKey: "scum-client-manager",
Generation: 1, Generation: 1,
Key: "legacy-client-manager-key", Key: "legacy-client-manager-key",
@@ -555,9 +543,9 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
) )
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall, domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill, domain.JobCapabilityLogsBackfill,
) )
plugin.BridgeActions = append(plugin.BridgeActions, plugin.BridgeActions = append(plugin.BridgeActions,
string(domain.PluginBridgeActionRunDistribution), string(domain.PluginBridgeActionRunDistribution),
@@ -572,9 +560,9 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
} }
endpoint.Capabilities = append(endpoint.Capabilities, endpoint.Capabilities = append(endpoint.Capabilities,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall, domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill, domain.JobCapabilityLogsBackfill,
) )
endpoint.Platform = "linux" endpoint.Platform = "linux"
endpoint.Architecture = "amd64" endpoint.Architecture = "amd64"
-26
View File
@@ -256,9 +256,6 @@ func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (do
if err := svc.projectDependencyAndRunUpdateProgress(job, stamp); err != nil { if err := svc.projectDependencyAndRunUpdateProgress(job, stamp); err != nil {
return domain.RunJobProgressResult{}, err return domain.RunJobProgressResult{}, err
} }
if err := svc.projectClientManagerLifecycleProgress(job, stamp); err != nil {
return domain.RunJobProgressResult{}, err
}
return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, progress.LeaseToken), ServerTime: stamp}, nil return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, progress.LeaseToken), ServerTime: stamp}, nil
} }
@@ -347,9 +344,6 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
if err := svc.projectDependencyAndRunUpdateResult(job, stamp); err != nil { if err := svc.projectDependencyAndRunUpdateResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err return domain.RunJobResultResult{}, err
} }
if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil { if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err return domain.RunJobResultResult{}, err
} }
@@ -425,26 +419,6 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "run.update.staged" { if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "run.update.staged" {
return validationError("Run self-update result type is invalid") return validationError("Run self-update result type is invalid")
} }
case domain.JobCapabilityClientManagerDeploy:
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.deployed" {
return validationError("client-manager deploy result type is invalid")
}
case domain.JobCapabilityClientManagerControl:
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.control" {
return validationError("client-manager control result type is invalid")
}
case domain.JobCapabilityClientManagerUpdate:
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.updated" || result.State == domain.JobStateFailed && result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != "client-manager.rollback.restored" {
return validationError("client-manager update result type is invalid")
}
case domain.JobCapabilityClientManagerRollback:
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.rolled-back" {
return validationError("client-manager rollback result type is invalid")
}
case domain.JobCapabilityClientManagerUninstall:
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.uninstalled" {
return validationError("client-manager uninstall result type is invalid")
}
} }
return nil return nil
} }
-3
View File
@@ -157,9 +157,6 @@ func runtimeBindingKeys(profiles domain.GamePluginRuntimeProfiles, profile domai
} }
} }
} }
if profile.ClientManagerRef != "" {
add(profile.ClientManagerRef, true)
}
required := make([]string, 0, len(requiredSet)) required := make([]string, 0, len(requiredSet))
for key := range requiredSet { for key := range requiredSet {
required = append(required, key) required = append(required, key)
+1 -1
View File
@@ -93,7 +93,7 @@ func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedSupervisedStartAction(t
plugin.Version = "1.0.0" plugin.Version = "1.0.0"
plugin.ServerType = "scum" plugin.ServerType = "scum"
plugin.Status = domain.GamePluginStatusInstalled plugin.Status = domain.GamePluginStatusInstalled
plugin.RequiredRunCapabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "process.restart", "files.list", "files.patch", domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, "artifacts.read", "artifacts.write", "ai.invoke"} plugin.RequiredRunCapabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "process.restart", "files.list", "files.patch", "artifacts.read", "artifacts.write", "ai.invoke"}
plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"} plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"}
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}} plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}}
requireManualSCUMRuntimeBindings(&plugin, "local") requireManualSCUMRuntimeBindings(&plugin, "local")
@@ -1,293 +0,0 @@
package validator
import (
"regexp"
"strings"
"browser.local/platform/domain"
)
var clientManagerIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$`)
func ValidateClientManagerInstallation(value domain.ClientManagerInstallation) error {
var violations []string
violations = appendRequired(violations, "id", value.ID)
violations = appendRequired(violations, "serverInstanceId", value.ServerInstanceID)
violations = appendRequired(violations, "pluginId", value.PluginID)
violations = appendRequired(violations, "profileKey", value.ProfileKey)
violations = appendRequired(violations, "runEndpointId", value.RunEndpointID)
if !validDistributionLogicalKey(value.ProfileKey) {
violations = append(violations, "profileKey is invalid")
}
if value.TargetOS != "" || value.TargetArch != "" {
violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch)
}
if !validClientManagerLifecycleStatus(value.Status) {
violations = append(violations, "status is invalid")
}
if !validClientManagerHealth(value.Health) {
violations = append(violations, "health is invalid")
}
if value.KeyGeneration < 0 || value.DeploymentGeneration < 0 {
violations = append(violations, "keyGeneration and deploymentGeneration must not be negative")
}
if value.Checksum != "" && !validSHA256Checksum(value.Checksum) {
violations = append(violations, "checksum must be sha256:<hex>")
}
for field, content := range map[string]string{"phase": value.Phase, "healthReason": value.HealthReason} {
if len(content) > 240 || unsafeLifecycleText(content) {
violations = append(violations, field+" must be bounded and redacted")
}
}
if value.CreatedAt.IsZero() || value.UpdatedAt.IsZero() {
violations = append(violations, "createdAt and updatedAt are required")
}
return finish(violations)
}
func ValidateClientManagerLifecycleTransition(from, to domain.ClientManagerLifecycleStatus) error {
if from == to {
return nil
}
allowed := map[domain.ClientManagerLifecycleStatus][]domain.ClientManagerLifecycleStatus{
domain.ClientManagerLifecycleRequested: {domain.ClientManagerLifecycleBuilding, domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleBuilding: {domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleAvailable: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleDeploying: {domain.ClientManagerLifecycleInstalled, domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleFailed, domain.ClientManagerLifecycleUninstalled},
domain.ClientManagerLifecycleInstalled: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleRegistering: {domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleOnline: {domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleDegraded: {domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleOffline: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleUpdating: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleRollingBack: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleStopping: {domain.ClientManagerLifecycleInstalled, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleUninstalled: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed},
domain.ClientManagerLifecycleFailed: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled},
}
for _, candidate := range allowed[from] {
if candidate == to {
return nil
}
}
return finish([]string{"client-manager lifecycle transition is invalid"})
}
func ValidateClientManagerSession(value domain.ClientManagerSession) error {
var violations []string
for field, content := range map[string]string{"id": value.ID, "installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "runEndpointId": value.RunEndpointID, "artifactId": value.ArtifactID, "tokenHash": value.TokenHash} {
violations = appendRequired(violations, field, content)
}
if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 {
violations = append(violations, "keyGeneration and deploymentGeneration must be positive")
}
if len(value.TokenHash) != 64 || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(value.TokenHash) {
violations = append(violations, "tokenHash must be a SHA-256 digest")
}
if !oneOf(string(value.Status), string(domain.ClientManagerSessionActive), string(domain.ClientManagerSessionRevoked), string(domain.ClientManagerSessionExpired)) {
violations = append(violations, "status is invalid")
}
if value.ExpiresAt.IsZero() || value.CreatedAt.IsZero() || value.UpdatedAt.IsZero() {
violations = append(violations, "session timestamps are required")
}
return finish(violations)
}
func ValidateClientManagerNonce(value domain.ClientManagerRegistrationNonce) error {
var violations []string
violations = appendRequired(violations, "id", value.ID)
violations = appendRequired(violations, "installationId", value.InstallationID)
if len(value.ID) != 64 || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(value.ID) {
violations = append(violations, "id must be a nonce SHA-256 digest")
}
if value.ExpiresAt.IsZero() || value.CreatedAt.IsZero() || !value.ExpiresAt.After(value.CreatedAt) {
violations = append(violations, "nonce expiry must follow creation")
}
return finish(violations)
}
func ValidateClientManagerDeployRequest(value domain.ClientManagerDeployRequest) error {
return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, value.DistributionID, value.IdempotencyKey, value.ExpectedDeploymentGeneration, true)
}
func ValidateClientManagerControlRequest(value domain.ClientManagerControlRequest) error {
if !oneOf(string(value.Operation), "start", "stop", "restart", "status", "rollback") {
return finish([]string{"operation is invalid"})
}
return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false)
}
func ValidateClientManagerUpdateRequest(value domain.ClientManagerUpdateRequest) error {
var violations []string
if !value.Approved {
violations = append(violations, "approved must be true")
}
if err := validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, value.DistributionID, value.IdempotencyKey, value.ExpectedDeploymentGeneration, true); err != nil {
violations = append(violations, err.Error())
}
return finish(violations)
}
func ValidateClientManagerUninstallRequest(value domain.ClientManagerUninstallRequest) error {
var violations []string
if !value.Confirmed {
violations = append(violations, "confirmed must be true")
}
if err := validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false); err != nil {
violations = append(violations, err.Error())
}
return finish(violations)
}
func ValidateClientManagerRetryRequest(value domain.ClientManagerRetryRequest) error {
return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false)
}
func ValidateClientManagerLifecycleInputRequest(value domain.ClientManagerLifecycleInputRequest) error {
var violations []string
for field, content := range map[string]string{"runEndpointId": value.RunEndpointID, "sessionToken": value.SessionToken, "jobId": value.JobID, "leaseToken": value.LeaseToken} {
violations = appendRequired(violations, field, content)
}
if value.Attempt <= 0 {
violations = append(violations, "attempt must be positive")
}
return finish(violations)
}
func ValidateClientManagerCompanionConfigInput(value domain.ClientManagerCompanionConfigInput) error {
var violations []string
if value.SchemaVersion != domain.ClientManagerCompanionConfigSchemaVersion {
violations = append(violations, "schemaVersion is invalid")
}
for field, content := range map[string]string{
"configTemplateKey": value.ConfigTemplateKey, "configTemplateRef": value.ConfigTemplateRef, "configOutputRef": value.ConfigOutputRef,
"configSchemaRef": value.ConfigSchemaRef, "installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID,
"pluginId": value.PluginID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version,
"sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "proofMaterialEnv": value.ProofMaterialEnv,
} {
violations = appendRequired(violations, field, content)
}
if !clientManagerIdentifierPattern.MatchString(value.ConfigTemplateKey) {
violations = append(violations, "configTemplateKey is invalid")
}
violations = append(violations, validateSafeRelativeRuntimePath("configTemplateRef", value.ConfigTemplateRef)...)
if value.ConfigOutputRef != "config.yaml" {
violations = append(violations, "configOutputRef must be config.yaml")
}
if !safeRelativeJSONRef(value.ConfigSchemaRef) {
violations = append(violations, "configSchemaRef must be a safe relative JSON reference")
}
if value.ConfigFormat != "yaml" || value.PlatformBaseURLSource != "run-control" || value.RegistrationProof != "hmac-sha256" || value.ProofMaterialSource != "component-package" || value.SessionMode != "component-session" || value.TLSPolicy != "verify-system-roots" {
violations = append(violations, "companion bootstrap security policy is invalid")
}
if !validCompanionProofEnvironment(value.ProofMaterialEnv) {
violations = append(violations, "proofMaterialEnv is invalid")
}
for field, identifier := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "pluginId": value.PluginID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID} {
if !clientManagerIdentifierPattern.MatchString(identifier) {
violations = append(violations, field+" is invalid")
}
}
violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch)
if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 {
violations = append(violations, "keyGeneration and deploymentGeneration must be positive")
}
if len(value.Capabilities) == 0 || len(value.Capabilities) > 32 {
violations = append(violations, "capabilities must be bounded")
}
capabilities := make(map[string]struct{}, len(value.Capabilities))
for _, capability := range value.Capabilities {
if !clientManagerIdentifierPattern.MatchString(capability) {
violations = append(violations, "capability is invalid")
}
if _, exists := capabilities[capability]; exists {
violations = append(violations, "capabilities must be unique")
}
capabilities[capability] = struct{}{}
}
for _, required := range []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"} {
if _, exists := capabilities[required]; !exists {
violations = append(violations, "capabilities must include "+required)
}
}
if value.HeartbeatIntervalSeconds < 5 || value.HeartbeatIntervalSeconds > 300 || value.CommandPollIntervalSeconds < 1 || value.CommandPollIntervalSeconds > 60 || value.RequestTimeoutSeconds < 1 || value.RequestTimeoutSeconds > 60 {
violations = append(violations, "companion timing policy is invalid")
}
return finish(violations)
}
func ValidateClientManagerRegisterRequest(value domain.ClientManagerRegisterRequest) error {
var violations []string
for field, content := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version, "sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "nonce": value.Nonce, "signature": value.Signature} {
violations = appendRequired(violations, field, content)
}
violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch)
if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 {
violations = append(violations, "keyGeneration and deploymentGeneration must be positive")
}
if value.Timestamp.IsZero() {
violations = append(violations, "timestamp is required")
}
if !regexp.MustCompile(`^[A-Za-z0-9_-]{16,128}$`).MatchString(value.Nonce) {
violations = append(violations, "nonce is invalid")
}
if !regexp.MustCompile(`^sha256:[a-f0-9]{64}$`).MatchString(value.Signature) {
violations = append(violations, "signature is invalid")
}
if len(value.Capabilities) == 0 {
violations = append(violations, "capabilities must not be empty")
}
return finish(violations)
}
func ValidateClientManagerHeartbeat(value domain.ClientManagerHeartbeat) error {
var violations []string
violations = appendRequired(violations, "installationId", value.InstallationID)
violations = appendRequired(violations, "sessionToken", value.SessionToken)
if value.Sequence == 0 {
violations = append(violations, "sequence must be positive")
}
if !validClientManagerHealth(value.Health) || value.Health == domain.ClientManagerHealthUnknown {
violations = append(violations, "health is invalid")
}
if len(value.HealthReason) > 240 || unsafeLifecycleText(value.HealthReason) {
violations = append(violations, "healthReason must be bounded and redacted")
}
if value.SentAt.IsZero() {
violations = append(violations, "sentAt is required")
}
return finish(violations)
}
func validateClientManagerOperationRequest(serverID, profileKey, distributionID, idempotencyKey string, generation int, distributionRequired bool) error {
var violations []string
violations = appendRequired(violations, "serverInstanceId", serverID)
violations = appendRequired(violations, "profileKey", profileKey)
violations = appendRequired(violations, "idempotencyKey", idempotencyKey)
if distributionRequired {
violations = appendRequired(violations, "distributionId", distributionID)
}
if !validDistributionLogicalKey(profileKey) {
violations = append(violations, "profileKey is invalid")
}
if !clientManagerIdentifierPattern.MatchString(idempotencyKey) {
violations = append(violations, "idempotencyKey is invalid")
}
if generation < 0 {
violations = append(violations, "expectedDeploymentGeneration must not be negative")
}
return finish(violations)
}
func validClientManagerLifecycleStatus(value domain.ClientManagerLifecycleStatus) bool {
return oneOf(string(value), "requested", "building", "available", "deploying", "installed", "registering", "online", "degraded", "offline", "updating", "rolling_back", "stopping", "uninstalled", "failed")
}
func validClientManagerHealth(value domain.ClientManagerHealthStatus) bool {
return oneOf(string(value), "unknown", "healthy", "degraded", "unhealthy", "offline")
}
func unsafeLifecycleText(value string) bool {
lowered := strings.ToLower(strings.TrimSpace(value))
return strings.Contains(lowered, "secret://") || strings.Contains(lowered, "bearer ") || strings.Contains(lowered, "password=") || strings.Contains(lowered, "token=") || strings.Contains(lowered, "unix://") || strings.Contains(lowered, "tcp://") || looksLikeRawHostPath(value)
}
@@ -1,36 +0,0 @@
package validator
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
func TestValidateRuntimeProfilesRejectsLegacyClientManagers(t *testing.T) {
profile := domain.RuntimeClientManagerProfile{Key: "scum-client-manager", DisplayName: "SCUM Client Manager"}
err := ValidateGamePluginRuntimeProfiles(domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{profile}})
if err == nil || !strings.Contains(err.Error(), "runtimeProfiles.clientManagers is no longer supported") {
t.Fatalf("expected legacy client-manager profile rejection, got %v", err)
}
}
func TestValidateClientManagerPersistenceContractsFailClosed(t *testing.T) {
stamp := time.Date(2026, 7, 18, 4, 0, 0, 0, time.UTC)
installation := domain.ClientManagerInstallation{ID: "cm-install-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", TargetOS: "linux", TargetArch: "amd64", Status: domain.ClientManagerLifecycleOnline, Phase: "healthy", KeyGeneration: 1, DeploymentGeneration: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "ready", CreatedAt: stamp, UpdatedAt: stamp}
if err := ValidateClientManagerInstallation(installation); err != nil {
t.Fatalf("validate historical installation projection: %v", err)
}
installation.HealthReason = "Bearer stolen-session"
if err := ValidateClientManagerInstallation(installation); err == nil {
t.Fatal("expected session-bearing health reason rejection")
}
session := domain.ClientManagerSession{ID: "cm-session-1", InstallationID: "cm-install-1", ServerInstanceID: "server-1", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", ArtifactID: "artifact-1", KeyGeneration: 1, DeploymentGeneration: 1, TokenHash: strings.Repeat("a", 64), Capabilities: []string{"component.heartbeat"}, Status: domain.ClientManagerSessionActive, ExpiresAt: stamp.Add(time.Minute), CreatedAt: stamp, UpdatedAt: stamp}
if err := ValidateClientManagerSession(session); err != nil {
t.Fatalf("validate historical hashed session: %v", err)
}
if err := ValidateClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: "cm-install-1", SessionToken: "component-session", Sequence: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "password=leak", Capabilities: []string{"component.heartbeat"}, SentAt: stamp}); err == nil {
t.Fatal("expected unsafe heartbeat reason rejection")
}
}
+2 -114
View File
@@ -2,7 +2,6 @@ package validator
import ( import (
"fmt" "fmt"
"net/url"
"strings" "strings"
"browser.local/platform/domain" "browser.local/platform/domain"
@@ -57,9 +56,6 @@ func ValidateEncryptedComponentKey(key domain.EncryptedComponentKey) error {
if !validDistributionComponentKind(key.ComponentKind) { if !validDistributionComponentKind(key.ComponentKind) {
violations = append(violations, "componentKind is invalid") violations = append(violations, "componentKind is invalid")
} }
if key.ComponentKind == domain.DistributionComponentClientManager && strings.TrimSpace(key.ComponentKey) == "" {
violations = append(violations, "componentKey is required for client-manager keys")
}
if key.ComponentKey != "" && !validDistributionLogicalKey(key.ComponentKey) { if key.ComponentKey != "" && !validDistributionLogicalKey(key.ComponentKey) {
violations = append(violations, "componentKey is invalid") violations = append(violations, "componentKey is invalid")
} }
@@ -125,44 +121,6 @@ func ValidateRunDistribution(distribution domain.RunDistribution) error {
return finish(violations) return finish(violations)
} }
func ValidateClientManagerDistribution(distribution domain.ClientManagerDistribution) error {
var violations []string
violations = appendRequired(violations, "id", distribution.ID)
violations = appendRequired(violations, "serverInstanceId", distribution.ServerInstanceID)
violations = appendRequired(violations, "pluginId", distribution.PluginID)
violations = appendRequired(violations, "profileKey", distribution.ProfileKey)
violations = appendRequired(violations, "repositoryUrl", distribution.RepositoryURL)
violations = appendRequired(violations, "sourceRevision", distribution.SourceRevision)
violations = appendRequired(violations, "buildJobId", distribution.BuildJobID)
violations = appendRequired(violations, "artifactId", distribution.ArtifactID)
violations = appendRequired(violations, "secretRef", distribution.SecretRef)
violations = appendDistributionTargetViolations(violations, distribution.TargetOS, distribution.TargetArch)
violations = appendDistributionStatusViolations(violations, distribution.Status)
if !validDistributionLogicalKey(distribution.ProfileKey) {
violations = append(violations, "profileKey is invalid")
}
if distribution.Checksum != "" && !validSHA256Checksum(distribution.Checksum) {
violations = append(violations, "checksum must be sha256:<hex>")
}
if distribution.Status == domain.DistributionStatusAvailable && distribution.Checksum == "" {
violations = append(violations, "checksum is required when distribution is available")
}
if distribution.KeyGeneration <= 0 {
violations = append(violations, "keyGeneration must be positive")
}
violations = append(violations, validateRepositoryURL("repositoryUrl", distribution.RepositoryURL)...)
if !strings.HasPrefix(distribution.SecretRef, "secret://runtime-keys/") {
violations = append(violations, "secretRef must be redacted runtime key ref")
}
if distribution.CreatedAt.IsZero() {
violations = append(violations, "createdAt is required")
}
if distribution.UpdatedAt.IsZero() {
violations = append(violations, "updatedAt is required")
}
return finish(violations)
}
func ValidateDependencyStatus(status domain.DependencyStatus) error { func ValidateDependencyStatus(status domain.DependencyStatus) error {
var violations []string var violations []string
violations = appendRequired(violations, "id", status.ID) violations = appendRequired(violations, "id", status.ID)
@@ -205,40 +163,6 @@ func ValidateDependencyStatus(status domain.DependencyStatus) error {
return finish(violations) return finish(violations)
} }
func ValidateClientManagerBuildJob(job domain.ClientManagerBuildJob) error {
var violations []string
violations = appendRequired(violations, "id", job.ID)
violations = appendRequired(violations, "serverInstanceId", job.ServerInstanceID)
violations = appendRequired(violations, "pluginId", job.PluginID)
violations = appendRequired(violations, "profileKey", job.ProfileKey)
violations = appendRequired(violations, "repositoryUrl", job.RepositoryURL)
violations = appendRequired(violations, "sourceRevision", job.SourceRevision)
violations = appendDistributionTargetViolations(violations, job.TargetOS, job.TargetArch)
if !validDistributionLogicalKey(job.ProfileKey) {
violations = append(violations, "profileKey is invalid")
}
if job.Checksum != "" && !validSHA256Checksum(job.Checksum) {
violations = append(violations, "checksum must be sha256:<hex>")
}
if job.LogsRef != "" && !validScopedInputRef(job.LogsRef) {
violations = append(violations, "logsRef is not allowed")
}
if job.KeyGeneration < 0 {
violations = append(violations, "keyGeneration must not be negative")
}
if !validDistributionJobStatus(job.Status) {
violations = append(violations, "status is invalid")
}
violations = append(violations, validateRepositoryURL("repositoryUrl", job.RepositoryURL)...)
if job.CreatedAt.IsZero() {
violations = append(violations, "createdAt is required")
}
if job.UpdatedAt.IsZero() {
violations = append(violations, "updatedAt is required")
}
return finish(violations)
}
func ValidateRunUpdateJob(job domain.RunUpdateJob) error { func ValidateRunUpdateJob(job domain.RunUpdateJob) error {
var violations []string var violations []string
violations = appendRequired(violations, "id", job.ID) violations = appendRequired(violations, "id", job.ID)
@@ -297,28 +221,6 @@ func ValidateRunDistributionGenerateRequest(request domain.RunDistributionGenera
return finish(violations) return finish(violations)
} }
func ValidateClientManagerBuildRequest(request domain.ClientManagerBuildRequest) error {
var violations []string
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
violations = appendRequired(violations, "profileKey", request.ProfileKey)
violations = appendRequired(violations, "targetOs", request.TargetOS)
violations = appendRequired(violations, "targetArch", request.TargetArch)
violations = appendRequired(violations, "repositoryUrl", request.RepositoryURL)
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
violations = appendDistributionTargetViolations(violations, request.TargetOS, request.TargetArch)
if !validDistributionLogicalKey(request.ProfileKey) {
violations = append(violations, "profileKey is invalid")
}
if request.SourceRevision != "" && !validDistributionLogicalKey(request.SourceRevision) {
violations = append(violations, "sourceRevision is invalid")
}
if containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey) {
violations = append(violations, "idempotencyKey is unsafe")
}
violations = append(violations, validateRepositoryURL("repositoryUrl", request.RepositoryURL)...)
return finish(violations)
}
func ValidateComponentKeyResetRequest(request domain.ComponentKeyResetRequest) error { func ValidateComponentKeyResetRequest(request domain.ComponentKeyResetRequest) error {
var violations []string var violations []string
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID) violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
@@ -341,8 +243,8 @@ func ValidateComponentAuthenticationRequest(request domain.ComponentAuthenticati
if !validDistributionComponentKind(request.ComponentKind) { if !validDistributionComponentKind(request.ComponentKind) {
violations = append(violations, "componentKind is invalid") violations = append(violations, "componentKind is invalid")
} }
if request.ComponentKind == domain.DistributionComponentClientManager && strings.TrimSpace(request.ComponentKey) == "" { if request.ComponentKind != domain.DistributionComponentRun {
violations = append(violations, "componentKey is required for client-manager") violations = append(violations, "componentKind must be run")
} }
if request.ComponentKey != "" && !validDistributionLogicalKey(request.ComponentKey) { if request.ComponentKey != "" && !validDistributionLogicalKey(request.ComponentKey) {
violations = append(violations, "componentKey is invalid") violations = append(violations, "componentKey is invalid")
@@ -373,20 +275,6 @@ func appendDistributionStatusViolations(violations []string, status domain.Distr
return violations return violations
} }
func validateRepositoryURL(field string, value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parsed, err := url.ParseRequestURI(strings.TrimSpace(value))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || !strings.HasSuffix(parsed.Path, ".git") {
return []string{field + " must be an HTTPS git repository URL"}
}
for _, reason := range unsafePluginStringReasons(value) {
return []string{field + ": " + reason}
}
return nil
}
func validDistributionComponentKind(kind domain.DistributionComponentKind) bool { func validDistributionComponentKind(kind domain.DistributionComponentKind) bool {
switch kind { switch kind {
case domain.DistributionComponentRun: case domain.DistributionComponentRun:
+1 -1
View File
@@ -109,7 +109,7 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
} }
var violations []string var violations []string
for key, value := range inputs { for key, value := range inputs {
if !clientManagerIdentifierPattern.MatchString(key) || unsafeRemoteAdapterInputKey(key) { if !runtimeIdentifierPattern.MatchString(key) || unsafeRemoteAdapterInputKey(key) {
violations = append(violations, field+" key is invalid or unsafe") violations = append(violations, field+" key is invalid or unsafe")
} }
limit := 2048 limit := 2048
+11 -10
View File
@@ -33,6 +33,7 @@ var (
gameClientBridgeCollectionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,119}$`) gameClientBridgeCollectionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,119}$`)
gameClientBridgeFieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,79}$`) gameClientBridgeFieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,79}$`)
gameClientBridgeCaptureNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,79}$`) gameClientBridgeCaptureNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,79}$`)
runtimeIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$`)
) )
type ValidationError struct { type ValidationError struct {
@@ -487,7 +488,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
commandTypes := map[string]struct{}{} commandTypes := map[string]struct{}{}
for index, command := range bridge.Commands { for index, command := range bridge.Commands {
prefix := fmt.Sprintf("%s.commands[%d]", field, index) prefix := fmt.Sprintf("%s.commands[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) { if !runtimeIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) {
violations = append(violations, prefix+".type is invalid or unsafe") violations = append(violations, prefix+".type is invalid or unsafe")
} }
if _, exists := commandTypes[command.Type]; exists { if _, exists := commandTypes[command.Type]; exists {
@@ -514,7 +515,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
for index, snapshot := range bridge.Snapshots { for index, snapshot := range bridge.Snapshots {
prefix := fmt.Sprintf("%s.snapshots[%d]", field, index) prefix := fmt.Sprintf("%s.snapshots[%d]", field, index)
key := snapshot.Type + "\x00" + snapshot.SchemaVersion key := snapshot.Type + "\x00" + snapshot.SchemaVersion
if !clientManagerIdentifierPattern.MatchString(snapshot.Type) || !clientManagerIdentifierPattern.MatchString(snapshot.SchemaVersion) { if !runtimeIdentifierPattern.MatchString(snapshot.Type) || !runtimeIdentifierPattern.MatchString(snapshot.SchemaVersion) {
violations = append(violations, prefix+" type or schemaVersion is invalid") violations = append(violations, prefix+" type or schemaVersion is invalid")
} }
if _, exists := snapshotTypes[key]; exists { if _, exists := snapshotTypes[key]; exists {
@@ -531,7 +532,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
queryTemplates := map[string]domain.GameClientBridgeQueryTemplateDeclaration{} queryTemplates := map[string]domain.GameClientBridgeQueryTemplateDeclaration{}
for index, template := range bridge.QueryTemplates { for index, template := range bridge.QueryTemplates {
prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index) prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(template.Key) { if !runtimeIdentifierPattern.MatchString(template.Key) {
violations = append(violations, prefix+".key is invalid") violations = append(violations, prefix+".key is invalid")
} }
if _, exists := queryTemplates[template.Key]; exists { if _, exists := queryTemplates[template.Key]; exists {
@@ -583,7 +584,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
lifecycleProjectionKeys := map[string]struct{}{} lifecycleProjectionKeys := map[string]struct{}{}
for index, projection := range bridge.LifecycleProjections { for index, projection := range bridge.LifecycleProjections {
prefix := fmt.Sprintf("%s.lifecycleProjections[%d]", field, index) prefix := fmt.Sprintf("%s.lifecycleProjections[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(projection.Key) { if !runtimeIdentifierPattern.MatchString(projection.Key) {
violations = append(violations, prefix+".key is invalid") violations = append(violations, prefix+".key is invalid")
} }
if _, exists := lifecycleProjectionKeys[projection.Key]; exists { if _, exists := lifecycleProjectionKeys[projection.Key]; exists {
@@ -620,7 +621,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
features := map[string]domain.GameClientBridgeFeatureDeclaration{} features := map[string]domain.GameClientBridgeFeatureDeclaration{}
for index, feature := range bridge.Features { for index, feature := range bridge.Features {
prefix := fmt.Sprintf("%s.features[%d]", field, index) prefix := fmt.Sprintf("%s.features[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(feature.Key) || unsafeGameClientBridgeCommandType(feature.Key) { if !runtimeIdentifierPattern.MatchString(feature.Key) || unsafeGameClientBridgeCommandType(feature.Key) {
violations = append(violations, prefix+".key is invalid or unsafe") violations = append(violations, prefix+".key is invalid or unsafe")
} }
if _, exists := features[feature.Key]; exists { if _, exists := features[feature.Key]; exists {
@@ -637,12 +638,12 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, prefix+" must require a handler or event producer") violations = append(violations, prefix+" must require a handler or event producer")
} }
for _, handler := range feature.RequiredHandlers { for _, handler := range feature.RequiredHandlers {
if !clientManagerIdentifierPattern.MatchString(handler) { if !runtimeIdentifierPattern.MatchString(handler) {
violations = append(violations, prefix+".requiredHandlers contains an invalid handler") violations = append(violations, prefix+".requiredHandlers contains an invalid handler")
} }
} }
for _, producer := range feature.RequiredEventProducers { for _, producer := range feature.RequiredEventProducers {
if !clientManagerIdentifierPattern.MatchString(producer) { if !runtimeIdentifierPattern.MatchString(producer) {
violations = append(violations, prefix+".requiredEventProducers contains an invalid producer") violations = append(violations, prefix+".requiredEventProducers contains an invalid producer")
} }
} }
@@ -1916,7 +1917,7 @@ func validatePluginPages(pages []domain.GamePluginPage) []string {
violations = append(violations, duplicateViolations(prefix+".permissions", page.Permissions)...) violations = append(violations, duplicateViolations(prefix+".permissions", page.Permissions)...)
violations = append(violations, validateBridgeActions(prefix+".bridgeActions", page.BridgeActions)...) violations = append(violations, validateBridgeActions(prefix+".bridgeActions", page.BridgeActions)...)
for _, key := range page.FeatureKeys { for _, key := range page.FeatureKeys {
if !clientManagerIdentifierPattern.MatchString(key) { if !runtimeIdentifierPattern.MatchString(key) {
violations = append(violations, prefix+".featureKeys contains an invalid feature key") violations = append(violations, prefix+".featureKeys contains an invalid feature key")
} }
} }
@@ -2349,7 +2350,7 @@ func validPluginMarketplaceStateAction(action domain.PluginMarketplaceStateActio
func validPluginRunCapability(capability string) bool { func validPluginRunCapability(capability string) bool {
switch capability { switch capability {
case "process.install", "process.start", "process.stop", "process.restart", "process.status", case "process.install", "process.start", "process.stop", "process.restart", "process.status",
"config.write", "config.write",
"files.list", "files.read", "files.write", "files.patch", "files.list", "files.read", "files.write", "files.patch",
"file.list", "file.read", "file.write", "file.patch", "file.list", "file.read", "file.write", "file.patch",
@@ -2362,7 +2363,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
domain.JobCapabilityRemoteRunProgram, domain.JobCapabilityRemoteRunProgram,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd, domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write", "artifacts.read", "artifacts.write", "artifact.read", "artifact.write",
"ai.invoke": "ai.invoke":
return true return true
+3 -9
View File
@@ -47,7 +47,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
prefix := fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d]", i) prefix := fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", profile.Key)...) violations = append(violations, validateProfileKey(prefix+".key", profile.Key)...)
violations = append(violations, recordRuntimeProfileKey(lifecycleKeys, prefix+".key", profile.Key)...) violations = append(violations, recordRuntimeProfileKey(lifecycleKeys, prefix+".key", profile.Key)...)
if !oneOf(profile.Mode, "local-process", "hosted-ftp-rcon", "ftp-only", "custom-client") { if !oneOf(profile.Mode, "local-process", "hosted-ftp-rcon", "ftp-only") {
violations = append(violations, prefix+".mode is invalid") violations = append(violations, prefix+".mode is invalid")
} }
if len(profile.Capabilities) == 0 { if len(profile.Capabilities) == 0 {
@@ -64,9 +64,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
violations = append(violations, validateProfileKey(fmt.Sprintf("%s.transportKeys[%d]", prefix, j), key)...) violations = append(violations, validateProfileKey(fmt.Sprintf("%s.transportKeys[%d]", prefix, j), key)...)
} }
violations = append(violations, duplicateViolations(prefix+".transportKeys", profile.TransportKeys)...) violations = append(violations, duplicateViolations(prefix+".transportKeys", profile.TransportKeys)...)
if profile.ClientManagerRef != "" {
violations = append(violations, prefix+".clientManagerRef is no longer supported")
}
for j, key := range profile.DLLExtensionRefs { for j, key := range profile.DLLExtensionRefs {
violations = append(violations, validateProfileKey(fmt.Sprintf("%s.dllExtensionRefs[%d]", prefix, j), key)...) violations = append(violations, validateProfileKey(fmt.Sprintf("%s.dllExtensionRefs[%d]", prefix, j), key)...)
} }
@@ -298,9 +295,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
violations = append(violations, prefix+".transportKey must reference a declared SQLite query transport") violations = append(violations, prefix+".transportKey must reference a declared SQLite query transport")
} }
} }
if len(profiles.ClientManagers) > 0 {
violations = append(violations, "runtimeProfiles.clientManagers is no longer supported")
}
for i, extension := range profiles.DLLExtensions { for i, extension := range profiles.DLLExtensions {
prefix := fmt.Sprintf("runtimeProfiles.dllExtensions[%d]", i) prefix := fmt.Sprintf("runtimeProfiles.dllExtensions[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", extension.Key)...) violations = append(violations, validateProfileKey(prefix+".key", extension.Key)...)
@@ -495,8 +489,8 @@ func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRunt
for i, transport := range profiles.TransportProfiles { for i, transport := range profiles.TransportProfiles {
check(fmt.Sprintf("runtimeProfiles.transportProfiles[%d].capabilities", i), transport.Capabilities) check(fmt.Sprintf("runtimeProfiles.transportProfiles[%d].capabilities", i), transport.Capabilities)
} }
return violations return violations
} }
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string { func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
var violations []string var violations []string
+1 -1
View File
@@ -241,7 +241,7 @@ export interface RuntimeDiscoveryProbeResponse {
export interface RuntimeLifecycleProfileResponse { export interface RuntimeLifecycleProfileResponse {
key: string; key: string;
mode: "local-process" | "hosted-ftp-rcon" | "ftp-only" | "custom-client"; mode: "local-process" | "hosted-ftp-rcon" | "ftp-only";
capabilities: string[]; capabilities: string[];
actionRefs?: Record<string, string>; actionRefs?: Record<string, string>;
transportKeys?: string[]; transportKeys?: string[];
+5 -1
View File
@@ -1,5 +1,5 @@
import type { PluginBridgeExecuteEnvelope, PluginBridgeExecutionResult } from "./pluginBridge"; import type { PluginBridgeExecuteEnvelope, PluginBridgeExecutionResult } from "./pluginBridge";
import type { GameClientBridgeCommandFilterRequest, GameClientBridgeQueueRequest, GameClientBridgeSnapshotQuery } from "../api/types"; import type { GameClientBridgeCommandFilterRequest, GameClientBridgeQueueRequest, GameClientBridgeSnapshotQuery, LogStreamCursorRequest, LogStreamCursorResponse, LogStreamListResponse } from "../api/types";
export interface PluginDataMutation { export interface PluginDataMutation {
operation: "put" | "delete"; operation: "put" | "delete";
@@ -20,5 +20,9 @@ export interface PluginPageWorkspaceActions {
list: (filter?: GameClientBridgeCommandFilterRequest) => Promise<unknown>; list: (filter?: GameClientBridgeCommandFilterRequest) => Promise<unknown>;
snapshots: (query?: GameClientBridgeSnapshotQuery) => Promise<unknown>; snapshots: (query?: GameClientBridgeSnapshotQuery) => Promise<unknown>;
}; };
logs?: {
listStreams: () => Promise<LogStreamListResponse>;
query: (request: LogStreamCursorRequest) => Promise<LogStreamCursorResponse>;
};
dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>; dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>;
} }
+11 -2
View File
@@ -21,9 +21,11 @@ const apiMocks = vi.hoisted(() => ({
listGameClientBridgeCommands: vi.fn(), listGameClientBridgeCommands: vi.fn(),
listGameClientBridgeSnapshots: vi.fn(), listGameClientBridgeSnapshots: vi.fn(),
listGamePlugins: vi.fn(), listGamePlugins: vi.fn(),
listLogStreams: vi.fn(),
listPluginData: vi.fn(), listPluginData: vi.fn(),
putPluginData: vi.fn(), putPluginData: vi.fn(),
queueGameClientBridgeCommand: vi.fn(), queueGameClientBridgeCommand: vi.fn(),
queryLogStream: vi.fn(),
transactPluginData: vi.fn() transactPluginData: vi.fn()
})); }));
@@ -53,7 +55,7 @@ const plugin: GamePluginResponse = {
manifestRef: "artifact://manifests/game.scum/1.0.0", manifestRef: "artifact://manifests/game.scum/1.0.0",
createFormSchemaRef: "schemas/create-form.schema.json", createFormSchemaRef: "schemas/create-form.schema.json",
requiredRunCapabilities: [], requiredRunCapabilities: [],
declaredPermissions: ["server.read", "server.game-client.read", "server.game-client.command"], declaredPermissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command"],
permissions: { ai: true, logs: false, files: false, jobs: true, artifacts: false, remoteAccess: false }, permissions: { ai: true, logs: false, files: false, jobs: true, artifacts: false, remoteAccess: false },
lifecycleActions: {}, lifecycleActions: {},
bridgeActions: ["server.instances.read"], bridgeActions: ["server.instances.read"],
@@ -64,7 +66,7 @@ const plugin: GamePluginResponse = {
bundleKey: "scum-server-plugin", bundleKey: "scum-server-plugin",
bundleVersion: "1.0.3", bundleVersion: "1.0.3",
bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
permissions: ["server.read", "server.game-client.read", "server.game-client.command"], permissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command"],
bridgeActions: ["server.instances.read"] bridgeActions: ["server.instances.read"]
}], }],
tags: ["scum"], tags: ["scum"],
@@ -97,6 +99,8 @@ beforeEach(() => {
apiMocks.getGameClientBridgeCommand.mockResolvedValue({ id: "command-1" }); apiMocks.getGameClientBridgeCommand.mockResolvedValue({ id: "command-1" });
apiMocks.listGameClientBridgeCommands.mockResolvedValue({ items: [], count: 0 }); apiMocks.listGameClientBridgeCommands.mockResolvedValue({ items: [], count: 0 });
apiMocks.listGameClientBridgeSnapshots.mockResolvedValue({ items: [], count: 0 }); apiMocks.listGameClientBridgeSnapshots.mockResolvedValue({ items: [], count: 0 });
apiMocks.listLogStreams.mockResolvedValue({ items: [{ id: "stream-login", serverInstanceId: "server-1", source: "file", streamKey: "scum.login", latestSeq: 2, storageBackend: "local-segments", retentionPolicy: "default", createdAt: "2026-08-15T12:00:00Z", updatedAt: "2026-08-15T12:00:00Z" }], count: 1 });
apiMocks.queryLogStream.mockResolvedValue({ logStreamId: "stream-login", entries: [], nextSeq: 2, latestSeq: 2 });
apiMocks.executePluginBridge.mockResolvedValue({ requestId: "request-1", action: "server.instances.read", status: "ok", result: { state: "ready" } }); apiMocks.executePluginBridge.mockResolvedValue({ requestId: "request-1", action: "server.instances.read", status: "ok", result: { state: "ready" } });
bundleMocks.loadPluginPageBundle.mockResolvedValue(({ workspaceActions }: { workspaceActions?: PluginPageWorkspaceActions }) => { bundleMocks.loadPluginPageBundle.mockResolvedValue(({ workspaceActions }: { workspaceActions?: PluginPageWorkspaceActions }) => {
capturedWorkspaceActions = workspaceActions; capturedWorkspaceActions = workspaceActions;
@@ -186,6 +190,7 @@ describe("PluginPageHostPage", () => {
const actions = capturedWorkspaceActions; const actions = capturedWorkspaceActions;
expect(actions?.pluginData).toBeDefined(); expect(actions?.pluginData).toBeDefined();
expect(actions?.gameClient).toBeDefined(); expect(actions?.gameClient).toBeDefined();
expect(actions?.logs).toBeDefined();
expect(actions?.dispatch).toBeDefined(); expect(actions?.dispatch).toBeDefined();
const mutations = [ const mutations = [
@@ -211,6 +216,8 @@ describe("PluginPageHostPage", () => {
await actions?.gameClient?.get("command-1"); await actions?.gameClient?.get("command-1");
await actions?.gameClient?.list(commandFilter); await actions?.gameClient?.list(commandFilter);
await actions?.gameClient?.snapshots(snapshotQuery); await actions?.gameClient?.snapshots(snapshotQuery);
await actions?.logs?.listStreams();
await actions?.logs?.query({ logStreamId: "stream-login", afterSeq: 1, limit: 50 });
await expect(actions?.dispatch?.(dispatchEnvelope)).resolves.toMatchObject({ status: "ok", result: { state: "ready" } }); await expect(actions?.dispatch?.(dispatchEnvelope)).resolves.toMatchObject({ status: "ok", result: { state: "ready" } });
expect(apiMocks.listPluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-1"); expect(apiMocks.listPluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-1");
@@ -221,6 +228,8 @@ describe("PluginPageHostPage", () => {
expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", "command-1"); expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", "command-1");
expect(apiMocks.listGameClientBridgeCommands).toHaveBeenCalledWith("server-1", commandFilter); expect(apiMocks.listGameClientBridgeCommands).toHaveBeenCalledWith("server-1", commandFilter);
expect(apiMocks.listGameClientBridgeSnapshots).toHaveBeenCalledWith("server-1", snapshotQuery); expect(apiMocks.listGameClientBridgeSnapshots).toHaveBeenCalledWith("server-1", snapshotQuery);
expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1");
expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: "stream-login", afterSeq: 1, limit: 50 });
expect(apiMocks.executePluginBridge).toHaveBeenCalledWith({ expect(apiMocks.executePluginBridge).toHaveBeenCalledWith({
requestId: "request-1", requestId: "request-1",
pluginId: "game.scum", pluginId: "game.scum",
+12
View File
@@ -81,6 +81,18 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
list: (filter) => platformApiClient.listGameClientBridgeCommands(serverId, filter), list: (filter) => platformApiClient.listGameClientBridgeCommands(serverId, filter),
snapshots: (query) => platformApiClient.listGameClientBridgeSnapshots(serverId, query) snapshots: (query) => platformApiClient.listGameClientBridgeSnapshots(serverId, query)
}, },
logs: {
listStreams: () => {
const context = hostContextRef.current;
if (!serverId || !context?.permissions.includes("server.logs.read")) throw new Error("plugin page is missing server.logs.read");
return platformApiClient.listLogStreams(serverId);
},
query: (request) => {
const context = hostContextRef.current;
if (!serverId || !context?.permissions.includes("server.logs.read")) throw new Error("plugin page is missing server.logs.read");
return platformApiClient.queryLogStream(request);
}
},
dispatch: (envelope, signal) => { dispatch: (envelope, signal) => {
const context = hostContextRef.current; const context = hostContextRef.current;
if (!context) return Promise.resolve({ requestId: envelope.requestId, action: envelope.action, status: "error", error: { code: "host_unavailable", message: "plugin bridge host is unavailable" } }); if (!context) return Promise.resolve({ requestId: envelope.requestId, action: envelope.action, status: "error", error: { code: "host_unavailable", message: "plugin bridge host is unavailable" } });
+1 -1
View File
@@ -35,7 +35,7 @@ Remote access declarations describe whether a plugin can use `ftp`, `rsync`, or
Runtime profiles are declarative contracts, not executable scripts. A profile can declare: Runtime profiles are declarative contracts, not executable scripts. A profile can declare:
- discovery probes for logical targets such as Java, Steam app, service, file, or toolchain checks. - discovery probes for logical targets such as Java, Steam app, service, file, or toolchain checks.
- lifecycle modes such as `local-process`, `hosted-ftp-rcon`, `ftp-only`, or `custom-client`. - lifecycle modes such as `local-process`, `hosted-ftp-rcon`, or `ftp-only`.
- dependency probes and typed install plans for supported OS targets. - dependency probes and typed install plans for supported OS targets.
- log sources for stdout/stderr, file tailing, FTP polling, or plugin-declared typed channels. Log bodies remain opaque and verbatim to Run and Platform. - log sources for stdout/stderr, file tailing, FTP polling, or plugin-declared typed channels. Log bodies remain opaque and verbatim to Run and Platform.
- transport profiles for declared file, FTP/rsync, SQL, RCON, and run-mediated operations. - transport profiles for declared file, FTP/rsync, SQL, RCON, and run-mediated operations.
@@ -8,6 +8,11 @@ export type PluginDataActions = {
transact: (collection: string, mutations: PluginDataMutation[]) => Promise<unknown>; transact: (collection: string, mutations: PluginDataMutation[]) => Promise<unknown>;
}; };
export type LogActions = {
listStreams: () => Promise<unknown>;
query: (request: { logStreamId: string; afterSeq: number; limit: number }) => Promise<unknown>;
};
export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record<string, string> }; export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record<string, string> };
export type PluginBridgeExecutionResult = { status?: string; result?: Record<string, string>; error?: { message?: string } }; export type PluginBridgeExecutionResult = { status?: string; result?: Record<string, string>; error?: { message?: string } };
@@ -73,6 +78,7 @@ export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, p
export type SCUMWorkspaceActions = { export type SCUMWorkspaceActions = {
pluginData?: PluginDataActions; pluginData?: PluginDataActions;
logs?: LogActions;
dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>; dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>;
}; };
@@ -128,7 +134,8 @@ export const scumCollections = {
mapSettings: "scum_map_settings", mapSettings: "scum_map_settings",
vehicles: "scum_vehicles", vehicles: "scum_vehicles",
flags: "scum_flags", flags: "scum_flags",
trajectories: "scum_trajectories" trajectories: "scum_trajectories",
logCursors: "scum_log_cursors"
} as const; } as const;
type SurfaceKey = keyof SCUMSurfaceData; type SurfaceKey = keyof SCUMSurfaceData;
@@ -144,13 +151,56 @@ const pageCollections: Record<PageKey, SurfaceKey[]> = {
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> { export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。");
const canonical = canonicalPageKey(pageKey);
if (pageCollections[canonical].includes("players")) await projectSCUMLoginLogs(actions).catch(() => undefined);
const data: SCUMSurfaceData = { ...emptySCUMSurfaceData }; const data: SCUMSurfaceData = { ...emptySCUMSurfaceData };
const keys = pageCollections[canonicalPageKey(pageKey)]; const keys = pageCollections[canonical];
const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const)); const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const));
for (const [key, response] of records) data[key] = collectionRecords(response); for (const [key, response] of records) data[key] = collectionRecords(response);
return data; return data;
} }
export async function projectSCUMLoginLogs(actions: SCUMWorkspaceActions, limitPerStream = 200): Promise<number> {
if (!actions.pluginData || !actions.logs) return 0;
const streamResponse = await actions.logs.listStreams();
const streams = logStreams(streamResponse).filter((stream) => textValue(stream.streamKey) === "scum.login" && textValue(stream.id));
if (!streams.length) return 0;
const existingPlayers = collectionRecords(await actions.pluginData.list(scumCollections.players));
const playerByIdentity = playerIndex(existingPlayers);
const playerMutations = new Map<string, PluginDataMutation>();
const activityMutations: PluginDataMutation[] = [];
let projected = 0;
for (const stream of streams) {
const streamId = textValue(stream.id);
const cursorKey = `scum.login:${streamId}`;
const cursor = firstCollectionRecord(await actions.pluginData.list(scumCollections.logCursors, cursorKey));
const afterSeq = Math.max(0, numberValue(cursor?.nextSeq ?? cursor?.lastSeq));
const response = await actions.logs.query({ logStreamId: streamId, afterSeq, limit: limitPerStream });
const entries = logEntries(response);
let lastSeq = afterSeq;
for (const entry of entries) {
const seq = numberValue(entry.seq);
if (seq > lastSeq) lastSeq = seq;
const event = parseSCUMLoginLogEntry(entry, streamId);
if (!event) continue;
const match = findPlayer(playerByIdentity, event);
const playerKeyValue = match ? textValue(existingPlayers[match.index]._recordKey) || scumLogPlayerKey(event) : scumLogPlayerKey(event);
const existing = match ? existingPlayers[match.index] : {};
const player = scumLoginPlayerRecord(existing, event);
playerMutations.set(playerKeyValue, { operation: "put", key: playerKeyValue, value: player });
if (match) existingPlayers[match.index] = { ...player, _recordKey: playerKeyValue };
else { existingPlayers.push({ ...player, _recordKey: playerKeyValue }); addPlayerToIndex(playerByIdentity, player, existingPlayers.length - 1); }
activityMutations.push({ operation: "put", key: `${streamId}:${seq || stableTextHash(textValue(event.rawLine))}`, value: scumLoginActivityRecord(event) });
projected++;
}
const nextSeq = Math.max(lastSeq, numberValue((response as RecordMap).nextSeq), numberValue((response as RecordMap).latestSeq));
if (nextSeq > afterSeq) await actions.pluginData.put(scumCollections.logCursors, cursorKey, { streamId, streamKey: "scum.login", nextSeq, updatedAt: new Date().toISOString() });
}
if (playerMutations.size) await actions.pluginData.transact(scumCollections.players, [...playerMutations.values()]);
if (activityMutations.length) await actions.pluginData.transact(scumCollections.activityEvents, activityMutations);
return projected;
}
export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown): RecordMap[] { export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown): RecordMap[] {
const playerSnapshot = latestSnapshotPayload(playersResponse); const playerSnapshot = latestSnapshotPayload(playersResponse);
const merged = players.map((player) => ({ ...player })); const merged = players.map((player) => ({ ...player }));
@@ -322,6 +372,69 @@ function collectionRecords(response: unknown): RecordMap[] {
}); });
} }
function firstCollectionRecord(response: unknown): RecordMap | undefined { return collectionRecords(response)[0]; }
function logStreams(response: unknown): RecordMap[] { return isRecord(response) && Array.isArray(response.items) ? response.items.filter(isRecord) : []; }
function logEntries(response: unknown): RecordMap[] { return isRecord(response) && Array.isArray(response.entries) ? response.entries.filter(isRecord) : []; }
function parseSCUMLoginLogEntry(entry: RecordMap, streamId: string): RecordMap | null {
const line = textValue(entry.line);
if (!line.trim()) return null;
const fields = isRecord(entry.fields) ? entry.fields : {};
const explicitType = firstText(fields, "eventType", "type", "action").toLowerCase();
const lowered = line.toLowerCase();
const logout = /\b(logged\s*out|logout|disconnected|left)\b/.test(explicitType) || /(?:\blogged\s*out\b|\blogout\s*:|\bdisconnected\b|\bleft\s+the\s+server\b)/i.test(line);
const login = /\b(logged\s*in|login|connected|joined)\b/.test(explicitType) || /(?:\blogged\s*in\b|\blogin\s*:|\bconnected\b|\bjoined\s+the\s+server\b)/i.test(line);
if (!login && !logout) return null;
const steamId = firstText(fields, "steamId", "steamID", "userId", "userID", "playerId", "gamePlayerId") || firstRegex(line, /\b\d{17}\b/);
const displayName = firstText(fields, "displayName", "playerName", "name", "characterName") || extractSCUMPlayerName(line, steamId);
const ip = firstText(fields, "lastLoginIp", "loginIp", "ipAddress", "ip") || firstRegex(line, /\b(?:\d{1,3}\.){3}\d{1,3}\b/);
if (!steamId && !displayName && !ip) return null;
const occurredAt = textValue(entry.timestamp) || new Date().toISOString();
return { eventType: logout ? "logout" : "login", steamId, gamePlayerId: steamId, displayName, lastLoginIp: ip, loginIp: ip, occurredAt, observedAt: occurredAt, rawLine: line, streamId, seq: numberValue(entry.seq), source: "plugin.log.scum.login" };
}
function scumLoginPlayerRecord(existing: RecordMap, event: RecordMap): RecordMap {
const occurredAt = textValue(event.occurredAt);
const login = textValue(event.eventType) === "login";
const identity = { steamId: textValue(event.steamId), gamePlayerId: textValue(event.gamePlayerId), displayName: textValue(event.displayName) };
const network = textValue(event.lastLoginIp) ? { lastLoginIp: textValue(event.lastLoginIp), loginIp: textValue(event.loginIp) } : {};
const status = login
? { online: true, status: "online", lastSeenAt: occurredAt, lastLoginAt: occurredAt, lastLoginObservedAt: occurredAt, lastLoginRawLine: textValue(event.rawLine) }
: { online: false, status: "offline", lastSeenAt: occurredAt, lastLogoutAt: occurredAt, lastLogoutObservedAt: occurredAt, lastLogoutRawLine: textValue(event.rawLine) };
return { ...existing, ...withoutEmpty(identity), ...network, ...status, source: "plugin.log.scum.login", updatedAt: occurredAt };
}
function scumLoginActivityRecord(event: RecordMap): RecordMap {
return withoutEmpty({ id: `${textValue(event.streamId)}:${numberValue(event.seq) || stableTextHash(textValue(event.rawLine))}`, eventType: textValue(event.eventType), type: textValue(event.eventType), steamId: textValue(event.steamId), gamePlayerId: textValue(event.gamePlayerId), displayName: textValue(event.displayName), lastLoginIp: textValue(event.lastLoginIp), loginIp: textValue(event.loginIp), occurredAt: textValue(event.occurredAt), observedAt: textValue(event.observedAt), rawLine: textValue(event.rawLine), streamId: textValue(event.streamId), seq: numberValue(event.seq), source: "plugin.log.scum.login" });
}
function scumLogPlayerKey(event: RecordMap): string {
const identity = firstText(event, "steamId", "gamePlayerId");
if (identity) return identity;
const name = textValue(event.displayName).trim().toLowerCase();
if (name) return `name:${safeCommandId(name)}`;
return `log:${stableTextHash(textValue(event.rawLine))}`;
}
function extractSCUMPlayerName(line: string, steamId: string): string {
for (const pattern of [/\b(?:player|user|name|character)\s*[:=]\s*["']?([^"'(),;\[\]]{2,80})/i, /\b(?:player|user|character)\s+["']([^"']{2,80})["']/i, /["']([^"']{2,80})["']\s*(?:\(|\[|SteamID\s*)?\d{17}/i, /\d{17}\s*(?:\)|\])?\s*["']([^"']{2,80})["']/]) {
const match = pattern.exec(line);
if (match?.[1]) return match[1].trim();
}
if (steamId) {
const index = line.indexOf(steamId);
const before = index > 0 ? line.slice(0, index).replace(/.*\b(?:player|user|character)\b\s*/i, "").replace(/[\[(,:;-]+$/g, "").trim() : "";
if (before && before.length <= 80) return before;
}
return "";
}
function firstRegex(value: string, pattern: RegExp): string { return pattern.exec(value)?.[0] ?? ""; }
function numberValue(value: unknown): number { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; }
function withoutEmpty(value: RecordMap): RecordMap { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== "" && item !== undefined && item !== null)); }
function stableTextHash(value: string): string { let hash = 2166136261; for (let index = 0; index < value.length; index++) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); } return (hash >>> 0).toString(16); }
function latestSnapshotPayload(response: unknown): RecordMap | undefined { function latestSnapshotPayload(response: unknown): RecordMap | undefined {
if (!isRecord(response) || !Array.isArray(response.items)) return undefined; if (!isRecord(response) || !Array.isArray(response.items)) return undefined;
const snapshots = response.items.filter(isRecord).sort((left, right) => snapshotOrder(right) - snapshotOrder(left)); const snapshots = response.items.filter(isRecord).sort((left, right) => snapshotOrder(right) - snapshotOrder(left));
@@ -1064,6 +1064,7 @@
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [ "permissions": [
"server.read", "server.read",
"server.logs.read",
"server.remote.access", "server.remote.access",
"server.game-client.read", "server.game-client.read",
"server.game-client.command", "server.game-client.command",
@@ -521,7 +521,7 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"key": { "$ref": "#/$defs/logicalKey" }, "key": { "$ref": "#/$defs/logicalKey" },
"mode": { "enum": ["local-process", "hosted-ftp-rcon", "ftp-only", "custom-client"] }, "mode": { "enum": ["local-process", "hosted-ftp-rcon", "ftp-only"] },
"capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 }, "capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 },
"actionRefs": { "actionRefs": {
"type": "object", "type": "object",
+1 -1
View File
@@ -369,7 +369,7 @@ export interface RuntimeDiscoveryProbe {
export interface RuntimeLifecycleProfile { export interface RuntimeLifecycleProfile {
key: string; key: string;
mode: "local-process" | "hosted-ftp-rcon" | "ftp-only" | "custom-client"; mode: "local-process" | "hosted-ftp-rcon" | "ftp-only";
capabilities: RunCapability[]; capabilities: RunCapability[];
actionRefs?: Partial<Record<PluginLifecycleAction, string>>; actionRefs?: Partial<Record<PluginLifecycleAction, string>>;
transportKeys?: string[]; transportKeys?: string[];
+2 -2
View File
@@ -426,8 +426,8 @@ describe("plugin manifest validation", () => {
pages: Array<{ pageKey: string; commandTypes?: string[]; queryTemplateKeys?: string[] }>; pages: Array<{ pageKey: string; commandTypes?: string[]; queryTemplateKeys?: string[] }>;
}; };
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>; pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> }; runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }>; dataTargets?: unknown[] };
}; };
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.tasks", "scum.events", "scum.native-timed-gifts"]; const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.tasks", "scum.events", "scum.native-timed-gifts"];
const expectedColumnsByKey: Record<string, string[]> = { const expectedColumnsByKey: Record<string, string[]> = {
"scum.player.profile": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"], "scum.player.profile": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"],
+22 -1
View File
@@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
import { buildPlayerAttributeMutation, createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, playerAttributeDrafts, playerAttributeSqlPreview, queueGiftDelivery, queuePlayerAttributePatch, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; import { buildPlayerAttributeMutation, createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, playerAttributeDrafts, playerAttributeSqlPreview, projectSCUMLoginLogs, queueGiftDelivery, queuePlayerAttributePatch, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js";
import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js"; import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
@@ -103,6 +103,27 @@ describe("SCUM plugin feature module", () => {
expect(dataClientSource).not.toContain("scum-client-manager"); expect(dataClientSource).not.toContain("scum-client-manager");
}); });
it("projects SCUM login log lines inside the plugin-owned page module", async () => {
const pluginData = pluginDataActions({ list: async (collection, key) => {
if (collection === scumCollections.players) return { items: [{ key: "76561198000000001", value: { steamId: "76561198000000001", displayName: "Old", normalBalance: 100 } }] };
if (collection === scumCollections.logCursors && key) return { items: [{ key, value: { nextSeq: 1 } }] };
return { items: [], count: 0 };
} });
const logs = {
listStreams: vi.fn(async () => ({ items: [{ id: "stream-login", streamKey: "scum.login", latestSeq: 3 }], count: 1 })),
query: vi.fn(async () => ({ logStreamId: "stream-login", entries: [
{ seq: 2, timestamp: "2026-08-10T00:01:00Z", line: "Login: Player 'Mira' SteamID 76561198000000001 IP 203.0.113.7" },
{ seq: 3, timestamp: "2026-08-10T00:04:00Z", line: "Logout: Player 'Mira' SteamID 76561198000000001 IP 203.0.113.7" }
], nextSeq: 3, latestSeq: 3 }))
};
await expect(projectSCUMLoginLogs({ pluginData, logs })).resolves.toBe(2);
expect(logs.query).toHaveBeenCalledWith({ logStreamId: "stream-login", afterSeq: 1, limit: 200 });
expect(pluginData.transact).toHaveBeenCalledWith(scumCollections.players, [expect.objectContaining({ key: "76561198000000001", value: expect.objectContaining({ displayName: "Mira", online: false, status: "offline", normalBalance: 100, lastLoginIp: "203.0.113.7", lastLogoutObservedAt: "2026-08-10T00:04:00Z", source: "plugin.log.scum.login" }) })]);
expect(pluginData.transact).toHaveBeenCalledWith(scumCollections.activityEvents, expect.arrayContaining([expect.objectContaining({ key: "stream-login:2", value: expect.objectContaining({ eventType: "login", rawLine: expect.stringContaining("Login:") }) }), expect.objectContaining({ key: "stream-login:3", value: expect.objectContaining({ eventType: "logout", rawLine: expect.stringContaining("Logout:") }) })]));
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.logCursors, "scum.login:stream-login", expect.objectContaining({ nextSeq: 3 }));
expect(dataClientSource).toContain("projectSCUMLoginLogs");
});
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => { it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows"); await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows");
+1 -2
View File
@@ -1106,8 +1106,7 @@ const localRuntimeProfiles = {
}], }],
logSources: source.runtimeProfiles?.logSources ?? [], logSources: source.runtimeProfiles?.logSources ?? [],
transportProfiles: source.runtimeProfiles?.transportProfiles ?? [], transportProfiles: source.runtimeProfiles?.transportProfiles ?? [],
dataTargets: source.runtimeProfiles?.dataTargets ?? [], dataTargets: source.runtimeProfiles?.dataTargets ?? []
clientManagers: []
}; };
const localGameClientBridge = JSON.parse(JSON.stringify(source.gameClientBridge ?? {})); const localGameClientBridge = JSON.parse(JSON.stringify(source.gameClientBridge ?? {}));
delete localGameClientBridge.companion; delete localGameClientBridge.companion;