Complete platform management workflows
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user