Complete platform management workflows

This commit is contained in:
npc0-hue
2026-07-14 16:39:37 +08:00
parent 7e05d0a4e7
commit 4f33f761a3
106 changed files with 11313 additions and 460 deletions
+7
View File
@@ -55,6 +55,9 @@ func (svc *CoreService) OpenArtifactDownloadForSession(sessionID string, request
if err := validator.ValidateArtifactDownloadReference(reference); err != nil {
return domain.ArtifactDownloadReference{}, err
}
if err := svc.auditArtifactDownload(sessionID, artifact); err != nil {
return domain.ArtifactDownloadReference{}, err
}
return domain.CopyArtifactDownloadReference(reference), nil
}
@@ -154,6 +157,10 @@ func (svc *CoreService) artifactPayload(artifactID string) ([]byte, error) {
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
if payload, exists := svc.artifactPayloads[artifactID]; exists {
return domain.CopyBytes(payload), nil
}
sessions := make([]domain.ArtifactTransferSession, 0, len(svc.artifactTransfers))
for _, session := range svc.artifactTransfers {
if session.ArtifactID == artifactID && session.Completed {
+25
View File
@@ -19,6 +19,27 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
if err := validator.ValidateRunControlHello(hello); err != nil {
return domain.RunControlHelloResult{}, err
}
if hasComponentAuthIdentity(hello) {
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: hello.ServerInstanceID,
ComponentKind: hello.ComponentKind,
ComponentKey: hello.ComponentKey,
Generation: hello.KeyGeneration,
Key: hello.RegistrationToken,
})
if err != nil {
return domain.RunControlHelloResult{}, err
}
if !auth.Allowed {
return domain.CopyRunControlHelloResult(domain.RunControlHelloResult{
Accepted: false,
RunEndpointID: hello.RunEndpointID,
ServerTime: svc.now(),
HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds,
FeatureFlags: []string{"runtime-key.auth.denied"},
}), nil
}
}
stamp := svc.now()
endpoint := domain.RunEndpoint{
@@ -60,6 +81,10 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
}), nil
}
func hasComponentAuthIdentity(hello domain.RunControlHello) bool {
return hello.ServerInstanceID != "" || hello.PluginID != "" || hello.ComponentKind != "" || hello.ComponentKey != "" || hello.KeyGeneration != 0
}
func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat) (domain.RunControlHeartbeatResult, error) {
heartbeat = domain.CopyRunControlHeartbeat(heartbeat)
if err := validator.ValidateRunControlHeartbeat(heartbeat); err != nil {
+43
View File
@@ -134,6 +134,49 @@ func TestCoreServiceRejectsInvalidRunControlHello(t *testing.T) {
}
}
func TestCoreServiceRunHelloRejectsStalePackageKeyAfterReset(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "idem-control-auth",
})
if err != nil {
t.Fatalf("generate run distribution: %v", err)
}
pkg := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
hello := validRunControlHello()
hello.RunEndpointID = instance.RunEndpointID
hello.RegistrationToken = pkg.AuthKey
hello.ServerInstanceID = instance.ID
hello.PluginID = instance.PluginID
hello.ComponentKind = domain.DistributionComponentRun
hello.KeyGeneration = pkg.KeyGeneration
result, err := svc.RegisterRunHello(hello)
if err != nil {
t.Fatalf("register current package hello: %v", err)
}
if !result.Accepted || result.SessionToken == "" {
t.Fatalf("expected current package hello to be accepted, got %+v", result)
}
if _, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun,
}); err != nil {
t.Fatalf("reset run key: %v", err)
}
result, err = svc.RegisterRunHello(hello)
if err != nil {
t.Fatalf("register stale package hello: %v", err)
}
if result.Accepted || result.SessionToken != "" {
t.Fatalf("expected stale package hello to be rejected, got %+v", result)
}
}
func TestCoreServiceRequestsCapabilityRefreshOnFingerprintDrift(t *testing.T) {
svc := newTestCoreService()
hello, err := svc.RegisterRunHello(validRunControlHello())
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
package service
import (
"encoding/json"
"errors"
"strings"
"testing"
"browser.local/platform/domain"
)
func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "idem-run-generate",
})
if err != nil {
t.Fatalf("generate run distribution: %v", err)
}
if distribution.KeyGeneration != 1 || distribution.SecretRef == "" || distribution.Status != domain.DistributionStatusAvailable {
t.Fatalf("unexpected run distribution: %+v", distribution)
}
keys, err := svc.store.EncryptedComponentKeys().List(domain.EncryptedComponentKeyFilter{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun,
Status: domain.ComponentKeyStatusActive,
})
if err != nil {
t.Fatalf("list component keys: %v", err)
}
if len(keys) != 1 || keys[0].Generation != 1 || !strings.HasPrefix(keys[0].EncryptedKey, "enc:v1:") {
t.Fatalf("expected one active encrypted run key, got %+v", keys)
}
config := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
if config.AuthKey == "" || config.AuthKey == keys[0].EncryptedKey || strings.Contains(distribution.SecretRef, config.AuthKey) {
t.Fatalf("run package key leaked through metadata or was not encrypted, config=%+v key=%+v distribution=%+v", config, keys[0], distribution)
}
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun,
Generation: config.KeyGeneration,
Key: config.AuthKey,
})
if err != nil {
t.Fatalf("authenticate run: %v", err)
}
if !auth.Allowed {
t.Fatalf("expected current run key to authenticate, got %+v", auth)
}
second, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "idem-run-generate-second",
})
if err != nil {
t.Fatalf("generate second run distribution: %v", err)
}
if second.KeyGeneration != 1 || second.SecretRef != distribution.SecretRef {
t.Fatalf("expected second package to reuse current singleton key, got first=%+v second=%+v", distribution, second)
}
keys, err = svc.store.EncryptedComponentKeys().List(domain.EncryptedComponentKeyFilter{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun,
Status: domain.ComponentKeyStatusActive,
})
if err != nil || len(keys) != 1 {
t.Fatalf("expected one active key after second generation, keys=%+v err=%v", keys, err)
}
}
func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "idem-run-before-reset",
})
if err != nil {
t.Fatalf("generate run distribution: %v", err)
}
oldConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
reset, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun,
})
if err != nil {
t.Fatalf("reset run key: %v", err)
}
if reset.Generation != 2 || reset.Status != domain.ComponentKeyStatusActive {
t.Fatalf("expected reset key generation 2, got %+v", reset)
}
oldDistribution, err := svc.store.RunDistributions().Get(distribution.ID)
if err != nil {
t.Fatalf("get old distribution: %v", err)
}
if oldDistribution.Status != domain.DistributionStatusRevoked {
t.Fatalf("expected old distribution revoked, got %+v", oldDistribution)
}
if _, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: distribution.ArtifactID}); err == nil || !strings.Contains(err.Error(), "available") {
t.Fatalf("expected old artifact download to be unavailable, got %v", err)
}
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun,
Generation: oldConfig.KeyGeneration,
Key: oldConfig.AuthKey,
})
if err != nil {
t.Fatalf("authenticate old key: %v", err)
}
if auth.Allowed || !strings.Contains(auth.Reason, "generation") {
t.Fatalf("expected old package authentication denial, got %+v", auth)
}
newDistribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "idem-run-after-reset",
})
if err != nil {
t.Fatalf("generate run distribution after reset: %v", err)
}
newConfig := readGeneratedPackageConfig(t, svc, session, newDistribution.ArtifactID)
if newDistribution.KeyGeneration != 2 || newConfig.AuthKey == oldConfig.AuthKey {
t.Fatalf("expected regenerated package with new generation/key, old=%+v new=%+v", oldConfig, newConfig)
}
auth, err = svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun,
Generation: newConfig.KeyGeneration,
Key: newConfig.AuthKey,
})
if err != nil {
t.Fatalf("authenticate new key: %v", err)
}
if !auth.Allowed {
t.Fatalf("expected regenerated package to authenticate, got %+v", auth)
}
}
func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperations(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
runDistribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "idem-run-for-client",
})
if err != nil {
t.Fatalf("generate run distribution: %v", err)
}
if _, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: runDistribution.ArtifactID}); err != nil {
t.Fatalf("open run download: %v", err)
}
runConfig := readGeneratedPackageConfig(t, svc, session, runDistribution.ArtifactID)
clientDistribution, 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.Fatalf("generate client-manager distribution: %v", err)
}
clientConfig := readGeneratedPackageConfig(t, svc, session, clientDistribution.ArtifactID)
if clientDistribution.KeyGeneration != 1 || clientDistribution.BuildJobID == "" || clientDistribution.Status != domain.DistributionStatusAvailable {
t.Fatalf("unexpected client-manager distribution: %+v", clientDistribution)
}
if clientConfig.AuthKey == runConfig.AuthKey || clientDistribution.SecretRef == runDistribution.SecretRef {
t.Fatalf("client-manager must use a distinct key/ref, run=%+v client=%+v", runConfig, clientConfig)
}
build, err := svc.store.ClientManagerBuildJobs().Get(clientDistribution.BuildJobID)
if err != nil {
t.Fatalf("get build job: %v", err)
}
if build.Status != domain.DistributionJobStatusSucceeded || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
t.Fatalf("unexpected build job: %+v", build)
}
if build.LogsRef == "" || !strings.HasPrefix(build.LogsRef, "artifact://") {
t.Fatalf("expected redacted build log artifact ref, got %+v", build)
}
packagePayload := readClientManagerPackage(t, svc, session, clientDistribution.ArtifactID)
if packagePayload.Checkout.CheckoutRef != "branch/main" || packagePayload.Config.AuthKey != clientConfig.AuthKey || packagePayload.KeyFingerprint == "" {
t.Fatalf("expected package checkout metadata and injected config, got %+v", packagePayload)
}
if len(packagePayload.OutputArtifacts) == 0 || packagePayload.BuildLogRef != build.LogsRef {
t.Fatalf("expected output artifacts and build log ref, got %+v build=%+v", packagePayload, build)
}
buildLog := readArtifactString(t, svc, session, strings.TrimPrefix(build.LogsRef, "artifact://"))
for _, expected := range []string{"client-manager checkout prepared", "checkoutRef=branch/main", "dependencyCheck=typed build profile accepted", "configInjection=secret ref"} {
if !strings.Contains(buildLog, expected) {
t.Fatalf("expected build log to contain %q, got %q", expected, buildLog)
}
}
for _, forbidden := range []string{runConfig.AuthKey, clientConfig.AuthKey, "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
if strings.Contains(buildLog, forbidden) {
t.Fatalf("build log leaked forbidden fragment %q: %s", forbidden, buildLog)
}
}
_, err = svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
ServerInstanceID: instance.ID,
ProfileKey: "scum-client-manager",
TargetOS: "darwin",
TargetArch: "amd64",
RepositoryURL: "https://github.com/F88888/scum_client.git",
IdempotencyKey: "idem-client-manager-denied",
})
if err == nil || !strings.Contains(err.Error(), "targetOs") {
t.Fatalf("expected unsupported target denial, got %v", err)
}
if _, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentClientManager,
ComponentKey: "scum-client-manager",
}); err != nil {
t.Fatalf("reset client-manager key: %v", err)
}
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentClientManager,
ComponentKey: "scum-client-manager",
Generation: clientConfig.KeyGeneration,
Key: clientConfig.AuthKey,
})
if err != nil {
t.Fatalf("authenticate old client key: %v", err)
}
if auth.Allowed {
t.Fatalf("expected old client-manager key to be denied after reset, got %+v", auth)
}
audits, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID})
if err != nil {
t.Fatalf("list audits: %v", err)
}
actions := map[string]bool{}
for _, audit := range audits {
actions[audit.Action] = true
for _, forbidden := range []string{runConfig.AuthKey, clientConfig.AuthKey, "password=", "unix://", "/Users/"} {
if strings.Contains(audit.Summary, forbidden) {
t.Fatalf("audit leaked forbidden fragment %q in %+v", forbidden, audit)
}
}
}
for _, action := range []string{"run.generate", "run.download", "client-manager.build", "client-manager.build.denied", "runtime-key.reset"} {
if !actions[action] {
t.Fatalf("expected audit action %q in %+v", action, audits)
}
}
}
func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
t.Helper()
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.SupportedOS = []string{"linux", "windows"}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions,
"server.run.distribution",
"server.client-manager.manage",
"server.dependencies.manage",
)
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities,
domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
)
plugin.BridgeActions = append(plugin.BridgeActions,
string(domain.PluginBridgeActionRunDistribution),
string(domain.PluginBridgeActionClientManager),
string(domain.PluginBridgeActionDependenciesRequest),
string(domain.PluginBridgeActionLogsBackfillRequest),
)
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin fixture: %v", err)
}
endpoint.Capabilities = append(endpoint.Capabilities,
domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update endpoint fixture: %v", err)
}
session := createServiceUserAndLogin(t, svc, domain.User{
ID: "user-distribution-owner",
DisplayName: "Distribution Owner",
Email: "distribution-owner@example.test",
Roles: []string{"server-owner"},
PasswordHash: "secret-password",
})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{
ID: "server-distribution",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "Distribution Server",
State: domain.ServerInstanceStateReady,
})
if err != nil {
t.Fatalf("create distribution server: %v", err)
}
return svc, session, instance
}
func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string, artifactID string) generatedPackageConfig {
t.Helper()
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
if err != nil {
t.Fatalf("read artifact content: %v", err)
}
var config generatedPackageConfig
if err := json.Unmarshal(content.Payload, &config); err != nil {
t.Fatalf("unmarshal generated config: %v", err)
}
if config.AuthKey == "" {
var packagePayload generatedClientManagerPackage
if err := json.Unmarshal(content.Payload, &packagePayload); err != nil {
t.Fatalf("unmarshal generated client-manager package: %v", err)
}
config = packagePayload.Config
}
if config.AuthKey == "" || config.SecretRef == "" || config.KeyGeneration <= 0 {
t.Fatalf("generated package config is incomplete: %+v", config)
}
return config
}
func readClientManagerPackage(t *testing.T, svc *CoreService, session string, artifactID string) generatedClientManagerPackage {
t.Helper()
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
if err != nil {
t.Fatalf("read client-manager package content: %v", err)
}
var packagePayload generatedClientManagerPackage
if err := json.Unmarshal(content.Payload, &packagePayload); err != nil {
t.Fatalf("unmarshal generated client-manager package: %v", err)
}
return packagePayload
}
func readArtifactString(t *testing.T, svc *CoreService, session string, artifactID string) string {
t.Helper()
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
if err != nil {
t.Fatalf("read artifact content: %v", err)
}
return string(content.Payload)
}
func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
session := createServiceUserAndLogin(t, svc, domain.User{
ID: "user-distribution-denied",
DisplayName: "Distribution Denied",
Email: "distribution-denied@example.test",
Roles: []string{"server-owner"},
PasswordHash: "secret-password",
})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{
ID: "server-distribution-denied",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "Distribution Denied Server",
State: domain.ServerInstanceStateReady,
})
if err != nil {
t.Fatalf("create denied server: %v", err)
}
_, err = svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "idem-run-denied",
})
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected plugin declaration denial, got %v", err)
}
}
+245 -2
View File
@@ -66,11 +66,13 @@ type Core interface {
StopServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
GetServerInstance(string) (domain.ServerInstance, error)
GetServerInstanceForSession(string, string) (domain.ServerInstance, error)
UpdateServerInstanceForSession(string, string, domain.ServerInstanceUpdate) (domain.ServerInstance, error)
ListServerInstances(domain.ServerInstanceFilter) ([]domain.ServerInstance, error)
ListServerInstancesForSession(string, domain.ServerInstanceFilter) ([]domain.ServerInstance, error)
ListServerAdministratorCandidates(string, string) ([]domain.User, error)
AddServerAdministrator(string, string, string) (domain.ServerInstance, error)
RemoveServerAdministrator(string, string, string) (domain.ServerInstance, error)
ArchiveServerInstanceForSession(string, string) (domain.ServerInstance, error)
GetPlatformResourceUsage() (domain.PlatformResourceUsage, error)
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
@@ -93,6 +95,16 @@ type Core interface {
GetArtifactForSession(string, string) (domain.Artifact, error)
OpenArtifactDownloadForSession(string, domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error)
ReadArtifactContentForSession(string, domain.ArtifactContentRequest) (domain.ArtifactContent, error)
GetServerRuntimeActionsForSession(string, string) (domain.ServerRuntimeActions, error)
GenerateRunDistributionForSession(string, domain.RunDistributionGenerateRequest) (domain.RunDistribution, error)
GenerateClientManagerDistributionForSession(string, domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error)
OpenLatestRunDistributionDownloadForSession(string, string) (domain.ArtifactDownloadReference, error)
OpenLatestClientManagerDistributionDownloadForSession(string, string, string) (domain.ArtifactDownloadReference, error)
ResetComponentKeyForSession(string, domain.ComponentKeyResetRequest) (domain.EncryptedComponentKey, error)
AuthenticateComponent(domain.ComponentAuthenticationRequest) (domain.ComponentAuthenticationResult, error)
PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
QueueDependencyJobForSession(string, domain.DependencyJobRequest) (domain.Job, error)
QueueLogBackfillForSession(string, domain.LogBackfillRequest) (domain.Job, error)
OpenArtifactTransfer(domain.ArtifactTransferOpen) (domain.ArtifactTransferOpenResult, error)
UploadArtifactChunk(domain.ArtifactChunkUpload) (domain.ArtifactChunkUploadResult, error)
QueryArtifactTransferStatus(domain.ArtifactTransferStatusQuery) (domain.ArtifactTransferStatusResult, error)
@@ -121,7 +133,10 @@ type CoreService struct {
logStore LogBodyStore
artifactMu sync.Mutex
artifactTransfers map[string]domain.ArtifactTransferSession
artifactPayloads map[string][]byte
artifactTransferSeq uint64
auditMu sync.Mutex
auditSeq uint64
aiProviderClient AIProviderClient
}
@@ -151,6 +166,7 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
jobLeases: map[string]domain.RunJobLease{},
logStore: logStore,
artifactTransfers: map[string]domain.ArtifactTransferSession{},
artifactPayloads: map[string][]byte{},
aiProviderClient: MockAIProviderClient{},
}
}
@@ -523,6 +539,7 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
Pages: manifest.Pages,
Tags: manifest.Tags,
AIPurposes: manifest.AI.Purposes,
RemoteAccess: manifest.RemoteAccess,
Status: domain.GamePluginStatusInstalled,
}
}
@@ -604,6 +621,16 @@ func (svc *CoreService) ExecutePluginBridgeAction(sessionID string, request doma
base = svc.executeBridgeLogsQuery(base, instance, request.Payload)
case domain.PluginBridgeActionFilesRequest:
base = svc.executeBridgeFileRequest(sessionID, base, request)
case domain.PluginBridgeActionRemoteAccessRequest:
base = svc.executeBridgeRemoteAccessRequest(base, plugin, instance, request.Payload)
case domain.PluginBridgeActionRunDistribution:
base = svc.executeBridgeRunDistribution(sessionID, base, request)
case domain.PluginBridgeActionDependenciesRequest:
base = svc.executeBridgeDependenciesRequest(base, plugin, instance, request.Payload)
case domain.PluginBridgeActionLogsBackfillRequest:
base = svc.executeBridgeLogsBackfillRequest(base, plugin, instance, request.Payload)
case domain.PluginBridgeActionClientManager:
base = svc.executeBridgeClientManager(sessionID, base, request)
case domain.PluginBridgeActionArtifactsOpen:
base = svc.executeBridgeArtifactOpen(sessionID, base, request)
case domain.PluginBridgeActionAIInvoke:
@@ -815,6 +842,146 @@ func (svc *CoreService) executeBridgeFileRequest(sessionID string, base domain.P
return base
}
func (svc *CoreService) executeBridgeRemoteAccessRequest(base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
capability := strings.TrimSpace(payload["capability"])
if capability == "" {
base.Status = "error"
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "capability is required"}
return base
}
if !containsString(plugin.RequiredRunCapabilities, capability) {
base.Status = "denied"
base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "requested remote capability is not declared by plugin"}
return base
}
job := domain.Job{
ID: jobIDFromParts("job-remote", base.RequestID, capability),
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: capability,
TargetKey: payload["targetKey"],
InputRef: payload["inputRef"],
IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID),
Progress: domain.JobProgress{Percent: 0, Message: "remote access job queued"},
}
created, err := svc.CreateJob(job)
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "queued"
base.Result = map[string]string{
"jobId": created.ID,
"state": string(created.State),
"capability": created.Capability,
"targetKey": created.TargetKey,
"serverInstanceId": created.ServerInstanceID,
}
return base
}
func (svc *CoreService) executeBridgeRunDistribution(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
distribution, err := svc.GenerateRunDistributionForSession(sessionID, domain.RunDistributionGenerateRequest{
ServerInstanceID: request.ServerInstanceID,
TargetOS: defaultBridgeValue(request.Payload["targetOs"], "linux"),
TargetArch: defaultBridgeValue(request.Payload["targetArch"], "amd64"),
IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID),
})
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "ok"
base.Result = map[string]string{
"distributionId": distribution.ID,
"artifactId": distribution.ArtifactID,
"checksum": distribution.Checksum,
"keyGeneration": strconv.Itoa(distribution.KeyGeneration),
"secretRef": distribution.SecretRef,
"status": string(distribution.Status),
}
return base
}
func (svc *CoreService) executeBridgeClientManager(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
distribution, err := svc.GenerateClientManagerDistributionForSession(sessionID, domain.ClientManagerBuildRequest{
ServerInstanceID: request.ServerInstanceID,
ProfileKey: request.Payload["profileKey"],
TargetOS: defaultBridgeValue(request.Payload["targetOs"], "windows"),
TargetArch: defaultBridgeValue(request.Payload["targetArch"], "amd64"),
RepositoryURL: request.Payload["repositoryUrl"],
SourceRevision: request.Payload["sourceRevision"],
IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID),
})
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "ok"
base.Result = map[string]string{
"distributionId": distribution.ID,
"buildJobId": distribution.BuildJobID,
"artifactId": distribution.ArtifactID,
"checksum": distribution.Checksum,
"keyGeneration": strconv.Itoa(distribution.KeyGeneration),
"secretRef": distribution.SecretRef,
"status": string(distribution.Status),
}
return base
}
func (svc *CoreService) executeBridgeDependenciesRequest(base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
action := defaultBridgeValue(payload["action"], "check")
capability := domain.JobCapabilityDependenciesCheck
message := "dependency check queued"
if action == "install" {
capability = domain.JobCapabilityDependenciesInstall
message = "dependency install queued"
}
if !containsString(plugin.RequiredRunCapabilities, capability) {
base.Status = "denied"
base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "dependency capability is not declared by plugin"}
return base
}
job, err := svc.CreateJob(domain.Job{
ID: jobIDFromParts("job-dependencies", base.RequestID, capability),
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: capability,
TargetKey: defaultBridgeValue(payload["probeKey"], "dependencies/default"),
InputRef: payload["inputRef"],
IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID),
Progress: domain.JobProgress{Percent: 0, Message: message},
})
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "queued"
base.Result = map[string]string{"jobId": job.ID, "state": string(job.State), "capability": job.Capability, "targetKey": job.TargetKey}
return base
}
func (svc *CoreService) executeBridgeLogsBackfillRequest(base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
if !containsString(plugin.RequiredRunCapabilities, domain.JobCapabilityLogsBackfill) {
base.Status = "denied"
base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "log backfill capability is not declared by plugin"}
return base
}
job, err := svc.CreateJob(domain.Job{
ID: jobIDFromParts("job-logs-backfill", base.RequestID, payload["sourceKey"]),
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: domain.JobCapabilityLogsBackfill,
TargetKey: defaultBridgeValue(payload["sourceKey"], "logs/default"),
InputRef: payload["checkpointRef"],
IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID),
Progress: domain.JobProgress{Percent: 0, Message: "historical log backfill queued"},
})
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "queued"
base.Result = map[string]string{"jobId": job.ID, "state": string(job.State), "capability": job.Capability, "sourceKey": job.TargetKey}
return base
}
func bridgeExecutionError(base domain.PluginBridgeExecuteResponse, err error) domain.PluginBridgeExecuteResponse {
base.Status = "error"
base.Error = &domain.PluginBridgeSafeError{Code: "execution_failed", Message: safeBridgeReason(err.Error())}
@@ -855,6 +1022,11 @@ func pluginPermissionsFromManifest(permissions []string) domain.PluginPermission
aggregate.Jobs = true
case "server.artifacts.read", "server.artifacts.write":
aggregate.Artifacts = true
case "server.remote.access":
aggregate.RemoteAccess = true
case "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage":
aggregate.Jobs = true
aggregate.Artifacts = true
}
}
return aggregate
@@ -962,6 +1134,7 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark
Pages: plugin.Pages,
Tags: plugin.Tags,
AIPurposes: plugin.AIPurposes,
RemoteAccess: plugin.RemoteAccess,
ValidationViolations: plugin.ValidationViolations,
Status: plugin.Status,
Source: "platform-registry",
@@ -1077,6 +1250,37 @@ func (svc *CoreService) GetServerInstanceForSession(sessionID string, id string)
return domain.CopyServerInstance(instance), nil
}
func (svc *CoreService) UpdateServerInstanceForSession(sessionID string, id string, update domain.ServerInstanceUpdate) (domain.ServerInstance, error) {
if err := validator.ValidateServerInstanceUpdate(update); err != nil {
return domain.ServerInstance{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.ServerInstance{}, err
}
instance, err := svc.store.ServerInstances().Get(id)
if err != nil {
return domain.ServerInstance{}, err
}
if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID {
return domain.ServerInstance{}, ErrForbidden
}
if instance.State == domain.ServerInstanceStateDeleted {
return domain.ServerInstance{}, validationError("deleted server instances cannot be edited")
}
if update.Name != nil {
instance.Name = *update.Name
}
instance.UpdatedAt = svc.now()
if err := validator.ValidateStoredServerInstance(instance); err != nil {
return domain.ServerInstance{}, err
}
if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.ServerInstance{}, err
}
return domain.CopyServerInstance(instance), nil
}
func (svc *CoreService) ListServerInstances(filter domain.ServerInstanceFilter) ([]domain.ServerInstance, error) {
return svc.store.ServerInstances().List(filter)
}
@@ -1487,6 +1691,35 @@ func (svc *CoreService) RemoveServerAdministrator(sessionID string, serverInstan
return domain.CopyServerInstance(instance), nil
}
func (svc *CoreService) ArchiveServerInstanceForSession(sessionID string, serverInstanceID string) (domain.ServerInstance, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.ServerInstance{}, err
}
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.ServerInstance{}, err
}
if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID {
return domain.ServerInstance{}, ErrForbidden
}
if instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateInstalling {
return domain.ServerInstance{}, validationError("running or installing server instances must be stopped before archive")
}
if instance.State == domain.ServerInstanceStateDeleted {
return domain.CopyServerInstance(instance), nil
}
instance.State = domain.ServerInstanceStateDeleted
instance.UpdatedAt = svc.now()
if err := validator.ValidateStoredServerInstance(instance); err != nil {
return domain.ServerInstance{}, err
}
if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.ServerInstance{}, err
}
return domain.CopyServerInstance(instance), nil
}
func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
if job.State == "" {
job.State = domain.JobStateQueued
@@ -1522,7 +1755,11 @@ func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
if err != nil {
return domain.Job{}, fmt.Errorf("get server instance dependency: %w", err)
}
if err := validateJobServerTarget(job, instance); err != nil {
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.Job{}, fmt.Errorf("get server plugin dependency: %w", err)
}
if err := validateJobServerTarget(job, instance, plugin); err != nil {
return domain.Job{}, err
}
}
@@ -1632,13 +1869,19 @@ func validateRunnableEndpoint(endpoint domain.RunEndpoint, capability string) er
return nil
}
func validateJobServerTarget(job domain.Job, instance domain.ServerInstance) error {
func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plugin domain.GamePlugin) error {
if instance.State == domain.ServerInstanceStateDeleted {
return validationError("server instance must not be deleted")
}
if instance.RunEndpointID != job.RunEndpointID {
return validationError("job runEndpointId must match server instance")
}
if plugin.ID != instance.PluginID {
return validationError("job plugin must match server instance")
}
if !containsString(plugin.RequiredRunCapabilities, job.Capability) {
return validationError("plugin missing required capability: " + job.Capability)
}
return nil
}
+100 -1
View File
@@ -876,6 +876,105 @@ func TestCoreServiceAuthorizesPluginBridgeActions(t *testing.T) {
}
}
func TestCoreServiceRemoteAccessRequiresPluginDeclaration(t *testing.T) {
svc := newTestCoreService()
registration := validPluginManifestRegistration()
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities,
domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunLogsTransfer,
domain.JobCapabilityRemoteRunRCONCommand,
)
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.remote.access")
registration.Manifest.Bridge.Actions = append(registration.Manifest.Bridge.Actions, string(domain.PluginBridgeActionRemoteAccessRequest))
registration.Manifest.Pages = append(registration.Manifest.Pages, domain.GamePluginPage{
Key: "remote",
Title: "Remote",
Path: "/remote",
Permissions: []string{"server.remote.access"},
BridgeActions: []string{string(domain.PluginBridgeActionRemoteAccessRequest)},
})
registration.Manifest.RemoteAccess = domain.GamePluginRemoteAccess{
Methods: []string{"run"},
RunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand},
DatabaseEngines: []string{"sqlite"},
RCON: true,
LogTransfer: true,
}
plugin, err := svc.RegisterGamePluginManifest(registration)
if err != nil {
t.Fatalf("register remote manifest: %v", err)
}
if !plugin.Permissions.RemoteAccess || !plugin.RemoteAccess.RCON || plugin.RemoteAccess.DatabaseEngines[0] != "sqlite" {
t.Fatalf("expected remote access metadata from manifest, got %+v", plugin)
}
marketplace, err := svc.GetMarketplacePlugin(plugin.ID)
if err != nil || !marketplace.RemoteAccess.LogTransfer || marketplace.RemoteAccess.Methods[0] != "run" {
t.Fatalf("expected marketplace remote access projection, got %+v err=%v", marketplace, err)
}
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{
ID: "run-remote",
DisplayName: "Remote Run",
Version: "0.1.0",
Capabilities: append([]string{"process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"}, plugin.RemoteAccess.RunCapabilities...),
Capacity: domain.RunCapacity{MaxJobs: 2},
})
if err != nil {
t.Fatalf("create remote endpoint: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
ID: "user-remote-owner",
DisplayName: "Remote Owner",
Email: "remote-owner@example.test",
Roles: []string{"server-owner"},
PasswordHash: "secret-password",
})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
ID: "server-remote",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "Remote Server",
State: domain.ServerInstanceStateRunning,
})
if err != nil {
t.Fatalf("create remote server: %v", err)
}
queued, err := svc.ExecutePluginBridgeAction(ownerSession, domain.PluginBridgeExecuteRequest{
RequestID: "remote-rcon-1",
PluginID: plugin.ID,
RouteKey: "remote",
ServerInstanceID: instance.ID,
Action: domain.PluginBridgeActionRemoteAccessRequest,
Payload: map[string]string{
"capability": domain.JobCapabilityRemoteRunRCONCommand,
"targetKey": "rcon/command",
"inputRef": "input://server-remote/rcon/command/1",
"idempotencyKey": "idem-remote-rcon",
},
})
if err != nil {
t.Fatalf("execute remote bridge action: %v", err)
}
if queued.Status != "queued" || queued.Result["capability"] != domain.JobCapabilityRemoteRunRCONCommand {
t.Fatalf("expected queued remote bridge job, got %+v", queued)
}
plainPlugin, plainEndpoint := createPluginAndRunEndpoint(t, svc)
plainEndpoint.Capabilities = append(plainEndpoint.Capabilities, domain.JobCapabilityRemoteRunRCONCommand)
if err := svc.store.RunEndpoints().Update(plainEndpoint); err != nil {
t.Fatalf("extend plain endpoint: %v", err)
}
plainInstance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-plain", PluginID: plainPlugin.ID, RunEndpointID: plainEndpoint.ID, Name: "Plain Server"})
if err != nil {
t.Fatalf("create plain server: %v", err)
}
_, err = svc.CreateJob(domain.Job{ID: "job-remote-denied", ServerInstanceID: plainInstance.ID, RunEndpointID: plainEndpoint.ID, Capability: domain.JobCapabilityRemoteRunRCONCommand, TargetKey: "rcon/command", InputRef: "input://plain/rcon/command/1", IdempotencyKey: "idem-denied"})
if err == nil || !strings.Contains(err.Error(), "plugin missing required capability") {
t.Fatalf("expected undeclared remote job denial, got %v", err)
}
}
func TestCoreServiceRejectsDuplicateGamePluginManifest(t *testing.T) {
svc := newTestCoreService()
registration := validPluginManifestRegistration()
@@ -1034,7 +1133,7 @@ func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlug
ServerType: "scum",
ManifestRef: "artifact://manifests/server.scum/1.0.0",
CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0",
RequiredRunCapabilities: []string{"process.install", "process.start", "process.stop", "logs.read"},
RequiredRunCapabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "config.write", "files.read", "files.write"},
DeclaredPermissions: []string{"server.files.read", "server.files.write"},
LifecycleActions: domain.PluginLifecycleActions{
Install: "actions/install.json",