功能修改
This commit is contained in:
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
@@ -45,7 +46,8 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
if err := validator.ValidateAIInvocationRequest(request); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
if _, err := svc.GetCurrentUser(sessionID); err != nil {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
if request.ServerInstanceID != "" {
|
||||
@@ -56,6 +58,13 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
if request.PluginID != "" && instance.PluginID != request.PluginID {
|
||||
return safeAIDenial(request, "plugin scope does not match server instance"), nil
|
||||
}
|
||||
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||
config, err := svc.GetServerConfigForSession(sessionID, instance.ID)
|
||||
if err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
request.CurrentConfig = config.Content
|
||||
}
|
||||
}
|
||||
if request.PluginID != "" {
|
||||
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||
@@ -82,14 +91,24 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
}
|
||||
result, err := svc.aiProviderClient.Invoke(provider, request)
|
||||
if err != nil {
|
||||
auditID, auditErr := svc.recordAuditEventWithID(user.ID, "ai.provider.invoke.failed", "ai-provider", provider.ID, domain.AuditResultFailed, "AI provider invocation failed safely")
|
||||
if auditErr != nil {
|
||||
return domain.AIInvocationResponse{}, auditErr
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
_, alertErr := svc.upsertAlert(domain.AlertRecord{SourceKind: "ai-provider", SourceID: provider.ID, RuleKey: "ai.provider.failed", Severity: domain.AlertSeverityWarning, Title: "AI provider invocation failed", Message: "AI provider invocation failed safely", Retryable: false, LastAuditEventID: auditID})
|
||||
svc.productionMu.Unlock()
|
||||
if alertErr != nil {
|
||||
return domain.AIInvocationResponse{}, alertErr
|
||||
}
|
||||
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
|
||||
RequestID: request.RequestID,
|
||||
Purpose: request.Purpose,
|
||||
ProviderID: provider.ID,
|
||||
Model: safeModel(request.Model, provider),
|
||||
Status: "error",
|
||||
Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider), Mocked: true},
|
||||
Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: safeBridgeReason(err.Error())},
|
||||
Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider)},
|
||||
Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: "AI provider invocation failed safely"},
|
||||
}), nil
|
||||
}
|
||||
response := domain.AIInvocationResponse{
|
||||
@@ -102,7 +121,16 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
Usage: result.Usage,
|
||||
}
|
||||
if result.SuggestedConfig != "" {
|
||||
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: "server.properties", SuggestedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch"}
|
||||
if request.ServerInstanceID == "" {
|
||||
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
preview, persistErr := svc.persistAIConfigDiff(user.ID, provider, request, result)
|
||||
svc.productionMu.Unlock()
|
||||
if persistErr != nil {
|
||||
return domain.AIInvocationResponse{}, persistErr
|
||||
}
|
||||
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: preview.Key, SuggestedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, DiffID: preview.ID, ExpiresAt: preview.ExpiresAt.Format(time.RFC3339)}
|
||||
}
|
||||
if err := validator.ValidateAIInvocationResponse(response); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const maxAIProviderResponseBytes = 1024 * 1024
|
||||
|
||||
type AIProviderSecretResolver interface {
|
||||
Resolve(reference string) (string, error)
|
||||
}
|
||||
|
||||
type EnvironmentAIProviderSecretResolver struct{}
|
||||
|
||||
func (EnvironmentAIProviderSecretResolver) Resolve(reference string) (string, error) {
|
||||
reference = strings.TrimSpace(reference)
|
||||
if strings.HasPrefix(reference, "env://") {
|
||||
name := strings.TrimPrefix(reference, "env://")
|
||||
if !validEnvironmentName(name) {
|
||||
return "", errors.New("AI provider secret reference is invalid")
|
||||
}
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
return "", errors.New("AI provider secret is unavailable")
|
||||
}
|
||||
if strings.HasPrefix(reference, "secret://providers/") {
|
||||
name := strings.TrimPrefix(reference, "secret://providers/")
|
||||
name = strings.ToUpper(regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(name, "_"))
|
||||
name = strings.Trim(name, "_")
|
||||
if name == "" {
|
||||
return "", errors.New("AI provider secret reference is invalid")
|
||||
}
|
||||
if value := os.Getenv("PLATFORM_AI_PROVIDER_" + name + "_API_KEY"); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
return "", errors.New("AI provider secret is unavailable")
|
||||
}
|
||||
return "", errors.New("AI provider secret backend is unsupported")
|
||||
}
|
||||
|
||||
type HTTPAIProviderClient struct {
|
||||
HTTPClient *http.Client
|
||||
SecretResolver AIProviderSecretResolver
|
||||
}
|
||||
|
||||
type openAIChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openAIChatMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
}
|
||||
|
||||
type openAIChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type openAIChatResponse struct {
|
||||
Choices []struct {
|
||||
Message openAIChatMessage `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
type claudeRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Messages []openAIChatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type claudeResponse struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
type geminiRequest struct {
|
||||
Contents []struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"contents"`
|
||||
}
|
||||
|
||||
type geminiResponse struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
type structuredAIRecommendation struct {
|
||||
Recommendation string `json:"recommendation"`
|
||||
SuggestedConfig string `json:"suggestedConfig"`
|
||||
}
|
||||
|
||||
func (client HTTPAIProviderClient) Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) {
|
||||
model := safeModel(request.Model, provider)
|
||||
endpoint, err := providerEndpoint(provider, model)
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, err
|
||||
}
|
||||
secret := ""
|
||||
if provider.RelayMode != domain.AIRelayModeLocal {
|
||||
resolver := client.SecretResolver
|
||||
if resolver == nil {
|
||||
resolver = EnvironmentAIProviderSecretResolver{}
|
||||
}
|
||||
secret, err = resolver.Resolve(provider.APIKeyRef)
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("AI provider credential is unavailable")
|
||||
}
|
||||
}
|
||||
prompt := boundedProviderPrompt(request)
|
||||
body, err := providerRequestBody(provider.Kind, model, prompt)
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("AI provider request could not be created")
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
setProviderAuthorization(httpRequest, provider.Kind, secret)
|
||||
httpClient := client.HTTPClient
|
||||
if httpClient == nil {
|
||||
timeout := time.Duration(provider.TimeoutMS) * time.Millisecond
|
||||
if timeout <= 0 || timeout > 2*time.Minute {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
httpClient = &http.Client{Timeout: timeout}
|
||||
}
|
||||
response, err := httpClient.Do(httpRequest)
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("AI provider transport failed")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return domain.AIProviderInvocationResult{}, fmt.Errorf("AI provider returned status class %dxx", response.StatusCode/100)
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, maxAIProviderResponseBytes+1))
|
||||
if err != nil || len(payload) > maxAIProviderResponseBytes {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("AI provider response was invalid or too large")
|
||||
}
|
||||
result, err := parseProviderResponse(provider.Kind, payload)
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("AI provider response was invalid")
|
||||
}
|
||||
result.Usage.ProviderID = provider.ID
|
||||
result.Usage.Model = model
|
||||
result.Usage.Mocked = false
|
||||
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||
result = parseStructuredRecommendation(result)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ConfigureAIProviderMode(mode string) error {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "mock", "test", "local":
|
||||
svc.aiProviderClient = MockAIProviderClient{}
|
||||
case "", "live", "http":
|
||||
svc.aiProviderClient = HTTPAIProviderClient{SecretResolver: EnvironmentAIProviderSecretResolver{}}
|
||||
default:
|
||||
return validationError("AI provider mode must be live or mock")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func providerEndpoint(provider domain.AIProvider, model string) (string, error) {
|
||||
base, err := url.Parse(strings.TrimRight(provider.BaseURL, "/"))
|
||||
if err != nil || base.Host == "" {
|
||||
return "", errors.New("AI provider endpoint is invalid")
|
||||
}
|
||||
host := base.Hostname()
|
||||
if base.Scheme != "https" && !(base.Scheme == "http" && provider.RelayMode == domain.AIRelayModeLocal && isLoopbackHost(host)) {
|
||||
return "", errors.New("AI provider endpoint requires HTTPS")
|
||||
}
|
||||
switch provider.Kind {
|
||||
case domain.AIProviderKindClaude:
|
||||
base.Path = strings.TrimRight(base.Path, "/") + "/messages"
|
||||
case domain.AIProviderKindGemini:
|
||||
base.Path = strings.TrimRight(base.Path, "/") + "/models/" + url.PathEscape(model) + ":generateContent"
|
||||
default:
|
||||
base.Path = strings.TrimRight(base.Path, "/") + "/chat/completions"
|
||||
}
|
||||
base.RawQuery = ""
|
||||
base.Fragment = ""
|
||||
return base.String(), nil
|
||||
}
|
||||
|
||||
func providerRequestBody(kind domain.AIProviderKind, model, prompt string) ([]byte, error) {
|
||||
switch kind {
|
||||
case domain.AIProviderKindClaude:
|
||||
return json.Marshal(claudeRequest{Model: model, MaxTokens: 2048, Messages: []openAIChatMessage{{Role: "user", Content: prompt}}})
|
||||
case domain.AIProviderKindGemini:
|
||||
body := geminiRequest{}
|
||||
body.Contents = append(body.Contents, struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
}{Parts: []struct {
|
||||
Text string `json:"text"`
|
||||
}{{Text: prompt}}})
|
||||
return json.Marshal(body)
|
||||
default:
|
||||
return json.Marshal(openAIChatRequest{Model: model, Messages: []openAIChatMessage{{Role: "user", Content: prompt}}, Temperature: 0.2})
|
||||
}
|
||||
}
|
||||
|
||||
func parseProviderResponse(kind domain.AIProviderKind, payload []byte) (domain.AIProviderInvocationResult, error) {
|
||||
switch kind {
|
||||
case domain.AIProviderKindClaude:
|
||||
var response claudeResponse
|
||||
if err := json.Unmarshal(payload, &response); err != nil || len(response.Content) == 0 || strings.TrimSpace(response.Content[0].Text) == "" {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("invalid Claude response")
|
||||
}
|
||||
return domain.AIProviderInvocationResult{Recommendation: response.Content[0].Text, Usage: domain.AIInvocationUsage{InputTokens: response.Usage.InputTokens, OutputTokens: response.Usage.OutputTokens}}, nil
|
||||
case domain.AIProviderKindGemini:
|
||||
var response geminiResponse
|
||||
if err := json.Unmarshal(payload, &response); err != nil || len(response.Candidates) == 0 || len(response.Candidates[0].Content.Parts) == 0 || strings.TrimSpace(response.Candidates[0].Content.Parts[0].Text) == "" {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("invalid Gemini response")
|
||||
}
|
||||
return domain.AIProviderInvocationResult{Recommendation: response.Candidates[0].Content.Parts[0].Text, Usage: domain.AIInvocationUsage{InputTokens: response.UsageMetadata.PromptTokenCount, OutputTokens: response.UsageMetadata.CandidatesTokenCount}}, nil
|
||||
default:
|
||||
var response openAIChatResponse
|
||||
if err := json.Unmarshal(payload, &response); err != nil || len(response.Choices) == 0 || strings.TrimSpace(response.Choices[0].Message.Content) == "" {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("invalid chat completion response")
|
||||
}
|
||||
return domain.AIProviderInvocationResult{Recommendation: response.Choices[0].Message.Content, Usage: domain.AIInvocationUsage{InputTokens: response.Usage.PromptTokens, OutputTokens: response.Usage.CompletionTokens}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseStructuredRecommendation(result domain.AIProviderInvocationResult) domain.AIProviderInvocationResult {
|
||||
raw := strings.TrimSpace(result.Recommendation)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
var structured structuredAIRecommendation
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &structured) == nil && strings.TrimSpace(structured.SuggestedConfig) != "" {
|
||||
result.Recommendation = strings.TrimSpace(structured.Recommendation)
|
||||
result.SuggestedConfig = structured.SuggestedConfig
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func boundedProviderPrompt(request domain.AIInvocationRequest) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Purpose: ")
|
||||
builder.WriteString(request.Purpose)
|
||||
builder.WriteString("\nRequest: ")
|
||||
builder.WriteString(request.Prompt)
|
||||
if request.CurrentConfig != "" {
|
||||
builder.WriteString("\nCurrent configuration:\n")
|
||||
builder.WriteString(request.CurrentConfig)
|
||||
}
|
||||
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||
builder.WriteString("\nReturn JSON with recommendation and suggestedConfig. Configuration changes require separate operator approval.")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func setProviderAuthorization(request *http.Request, kind domain.AIProviderKind, secret string) {
|
||||
if secret == "" {
|
||||
return
|
||||
}
|
||||
switch kind {
|
||||
case domain.AIProviderKindClaude:
|
||||
request.Header.Set("x-api-key", secret)
|
||||
request.Header.Set("anthropic-version", "2023-06-01")
|
||||
case domain.AIProviderKindGemini:
|
||||
request.Header.Set("x-goog-api-key", secret)
|
||||
default:
|
||||
request.Header.Set("Authorization", "Bearer "+secret)
|
||||
}
|
||||
}
|
||||
|
||||
func validEnvironmentName(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestHTTPAIProviderClientReturnsBoundedStructuredRecommendation(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("unexpected provider path %q", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Fatal("local provider must not receive an authorization header")
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if strings.Contains(string(body), "apiKeyRef") {
|
||||
t.Fatal("provider request leaked secret reference metadata")
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"role":"assistant","content":"{\"recommendation\":\"Review PVP policy\",\"suggestedConfig\":\"pvp=false\\n\"}"}}],"usage":{"prompt_tokens":12,"completion_tokens":8}}`))}, nil
|
||||
})
|
||||
|
||||
client := HTTPAIProviderClient{HTTPClient: &http.Client{Transport: transport}}
|
||||
result, err := client.Invoke(domain.AIProvider{ID: "local", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:18000/v1", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, DefaultModel: "test-model"}, domain.AIInvocationRequest{RequestID: "http-ai-1", Purpose: "config.suggest", Prompt: "disable pvp", CurrentConfig: "pvp=true\n"})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke provider: %v", err)
|
||||
}
|
||||
if result.Recommendation != "Review PVP policy" || result.SuggestedConfig != "pvp=false\n" || result.Usage.Mocked {
|
||||
t.Fatalf("unexpected provider result %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAIProviderClientRedactsTransportFailure(t *testing.T) {
|
||||
client := HTTPAIProviderClient{HTTPClient: &http.Client{Transport: failingRoundTripper{}}}
|
||||
_, err := client.Invoke(domain.AIProvider{ID: "local", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:18000/v1", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, DefaultModel: "test-model"}, domain.AIInvocationRequest{RequestID: "http-ai-fail", Purpose: "logs.diagnose", Prompt: "inspect"})
|
||||
if err == nil || err.Error() != "AI provider transport failed" {
|
||||
t.Fatalf("expected redacted transport failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingRoundTripper struct{}
|
||||
|
||||
func (failingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return fn(request)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestClientManagerLifecycleInputIncludesGeneratedCompanionConfig(t *testing.T) {
|
||||
svc, ownerSession, instance := newDistributionTestFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
}
|
||||
capabilities := []string{"component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"}
|
||||
for index := range plugin.RuntimeProfiles.ClientManagers {
|
||||
manager := &plugin.RuntimeProfiles.ClientManagers[index]
|
||||
if manager.Key != "scum-client-manager" {
|
||||
continue
|
||||
}
|
||||
manager.ConfigTemplates = []domain.RuntimeConfigTemplate{{Key: "client-config", TemplateRef: "config.yaml.example", OutputRef: "config.yaml"}}
|
||||
manager.Health.IntervalSeconds = 30
|
||||
manager.Health.RequiredCapabilities = domain.CopyStringSlice(capabilities)
|
||||
}
|
||||
plugin.GameClientBridge.Companion = domain.GameClientBridgeCompanionDeclaration{
|
||||
ProfileKey: "scum-client-manager",
|
||||
ConfigTemplateKey: "client-config",
|
||||
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||
ConfigFormat: "yaml",
|
||||
PlatformBaseURLSource: "run-control",
|
||||
RegistrationProof: "hmac-sha256",
|
||||
ProofMaterialSource: "component-package",
|
||||
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
SessionMode: "component-session",
|
||||
TLSPolicy: "verify-system-roots",
|
||||
HeartbeatIntervalSeconds: 30,
|
||||
CommandPollIntervalSeconds: 5,
|
||||
RequestTimeoutSeconds: 15,
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update companion declaration: %v", err)
|
||||
}
|
||||
|
||||
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "companion-config-build-v1")
|
||||
view, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "companion-config-deploy-v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("queue companion deployment: %v", err)
|
||||
}
|
||||
runSession := registerClientManagerRun(t, svc)
|
||||
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
|
||||
request := domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
|
||||
input, err := svc.GetClientManagerLifecycleInput(request)
|
||||
if err != nil {
|
||||
t.Fatalf("get companion lifecycle input: %v", err)
|
||||
}
|
||||
config := input.CompanionConfig
|
||||
if config == nil {
|
||||
t.Fatal("expected generated companion config input")
|
||||
}
|
||||
if config.SchemaVersion != 1 || config.ConfigTemplateRef != "config.yaml.example" || config.ConfigOutputRef != "config.yaml" || config.ConfigSchemaRef != "schemas/companion/config.schema.json" {
|
||||
t.Fatalf("unexpected companion template contract: %+v", config)
|
||||
}
|
||||
if config.InstallationID != view.Installation.ID || config.ServerInstanceID != instance.ID || config.PluginID != plugin.ID || config.ProfileKey != "scum-client-manager" || config.ArtifactID != distribution.ArtifactID || config.KeyGeneration != distribution.KeyGeneration || config.DeploymentGeneration != view.Installation.DeploymentGeneration {
|
||||
t.Fatalf("unexpected companion identity fence: %+v", config)
|
||||
}
|
||||
if strings.Join(config.Capabilities, ",") != strings.Join(capabilities, ",") || config.PlatformBaseURLSource != "run-control" || config.RegistrationProof != "hmac-sha256" || config.ProofMaterialEnv != "SCUM_COMPONENT_PROOF" || config.SessionMode != "component-session" || config.TLSPolicy != "verify-system-roots" {
|
||||
t.Fatalf("unexpected companion registration policy: %+v", config)
|
||||
}
|
||||
if config.HeartbeatIntervalSeconds != 30 || config.CommandPollIntervalSeconds != 5 || config.RequestTimeoutSeconds != 15 {
|
||||
t.Fatalf("unexpected companion timing policy: %+v", config)
|
||||
}
|
||||
|
||||
plugin, err = svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload plugin: %v", err)
|
||||
}
|
||||
plugin.GameClientBridge.Companion.TLSPolicy = "skip-verification"
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("persist unsafe companion policy: %v", err)
|
||||
}
|
||||
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "security policy") {
|
||||
t.Fatalf("expected unsafe persisted policy to fail closed, got %v", err)
|
||||
}
|
||||
|
||||
plugin.GameClientBridge.Companion.TLSPolicy = "verify-system-roots"
|
||||
plugin.GameClientBridge.Companion.ProofMaterialEnv = "LD_PRELOAD"
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("persist reserved proof environment: %v", err)
|
||||
}
|
||||
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "proofMaterialEnv") {
|
||||
t.Fatalf("expected reserved proof environment to fail closed, got %v", err)
|
||||
}
|
||||
|
||||
plugin.GameClientBridge.Companion.ProofMaterialEnv = "SCUM_COMPONENT_PROOF"
|
||||
for index := range plugin.RuntimeProfiles.ClientManagers {
|
||||
plugin.RuntimeProfiles.ClientManagers[index].ConfigTemplates = nil
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("remove config template: %v", err)
|
||||
}
|
||||
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "config template") {
|
||||
t.Fatalf("expected missing template to fail safely, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientManagerCompanionConfigInputIsOptionalForOtherProfiles(t *testing.T) {
|
||||
config, err := clientManagerCompanionConfigInput(
|
||||
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ProfileKey: "bridge-client"}}},
|
||||
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
|
||||
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
|
||||
"artifact-1",
|
||||
"1.0.0",
|
||||
"revision-1",
|
||||
)
|
||||
if err != nil || config != nil {
|
||||
t.Fatalf("expected no companion config for another profile, config=%+v err=%v", config, err)
|
||||
}
|
||||
|
||||
config, err = clientManagerCompanionConfigInput(
|
||||
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ConfigFormat: "yaml"}}},
|
||||
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
|
||||
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
|
||||
"artifact-1",
|
||||
"1.0.0",
|
||||
"revision-1",
|
||||
)
|
||||
if err == nil || config != nil || !strings.Contains(err.Error(), "incomplete") {
|
||||
t.Fatalf("expected partial companion declaration to fail closed, config=%+v err=%v", config, err)
|
||||
}
|
||||
}
|
||||
@@ -525,6 +525,64 @@ func findRuntimeClientManagerProfile(plugin domain.GamePlugin, profileKey string
|
||||
return domain.RuntimeClientManagerProfile{}, repo.ErrNotFound
|
||||
}
|
||||
|
||||
func clientManagerCompanionConfigInput(plugin domain.GamePlugin, profile domain.RuntimeClientManagerProfile, installation domain.ClientManagerInstallation, artifactID, version, revision string) (*domain.ClientManagerCompanionConfigInput, error) {
|
||||
companion := plugin.GameClientBridge.Companion
|
||||
if companion == (domain.GameClientBridgeCompanionDeclaration{}) {
|
||||
return nil, nil
|
||||
}
|
||||
if companion.ProfileKey == "" {
|
||||
return nil, validationError("client-manager companion declaration is incomplete")
|
||||
}
|
||||
if companion.ProfileKey != installation.ProfileKey {
|
||||
return nil, nil
|
||||
}
|
||||
var configTemplate domain.RuntimeConfigTemplate
|
||||
found := false
|
||||
for _, candidate := range profile.ConfigTemplates {
|
||||
if candidate.Key == companion.ConfigTemplateKey {
|
||||
configTemplate = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found || strings.TrimSpace(configTemplate.TemplateRef) == "" || configTemplate.OutputRef != "config.yaml" {
|
||||
return nil, validationError("client-manager companion config template is unavailable")
|
||||
}
|
||||
config := domain.ClientManagerCompanionConfigInput{
|
||||
SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion,
|
||||
ConfigTemplateKey: companion.ConfigTemplateKey,
|
||||
ConfigTemplateRef: configTemplate.TemplateRef,
|
||||
ConfigOutputRef: configTemplate.OutputRef,
|
||||
ConfigSchemaRef: companion.ConfigSchemaRef,
|
||||
ConfigFormat: companion.ConfigFormat,
|
||||
PlatformBaseURLSource: companion.PlatformBaseURLSource,
|
||||
InstallationID: installation.ID,
|
||||
ServerInstanceID: installation.ServerInstanceID,
|
||||
PluginID: plugin.ID,
|
||||
ProfileKey: installation.ProfileKey,
|
||||
ArtifactID: artifactID,
|
||||
Version: version,
|
||||
SourceRevision: revision,
|
||||
TargetOS: installation.TargetOS,
|
||||
TargetArch: installation.TargetArch,
|
||||
KeyGeneration: installation.KeyGeneration,
|
||||
DeploymentGeneration: installation.DeploymentGeneration,
|
||||
Capabilities: domain.CopyStringSlice(profile.Health.RequiredCapabilities),
|
||||
RegistrationProof: companion.RegistrationProof,
|
||||
ProofMaterialSource: companion.ProofMaterialSource,
|
||||
ProofMaterialEnv: companion.ProofMaterialEnv,
|
||||
SessionMode: companion.SessionMode,
|
||||
TLSPolicy: companion.TLSPolicy,
|
||||
HeartbeatIntervalSeconds: companion.HeartbeatIntervalSeconds,
|
||||
CommandPollIntervalSeconds: companion.CommandPollIntervalSeconds,
|
||||
RequestTimeoutSeconds: companion.RequestTimeoutSeconds,
|
||||
}
|
||||
if err := validator.ValidateClientManagerCompanionConfigInput(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func clientManagerProfileSupportsTarget(profile domain.RuntimeClientManagerProfile, targetOS, targetArch string) bool {
|
||||
for _, target := range profile.SupportedTargets {
|
||||
if target.OS == targetOS && target.Arch == targetArch {
|
||||
@@ -755,7 +813,11 @@ func (svc *CoreService) GetClientManagerLifecycleInput(request domain.ClientMana
|
||||
revision = installation.ActiveRevision
|
||||
checksum = ""
|
||||
}
|
||||
return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey}), nil
|
||||
companionConfig, err := clientManagerCompanionConfigInput(plugin, profile, installation, artifactID, version, revision)
|
||||
if err != nil {
|
||||
return domain.ClientManagerLifecycleInput{}, err
|
||||
}
|
||||
return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey, CompanionConfig: companionConfig}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ReadClientManagerLifecycleChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) {
|
||||
|
||||
@@ -23,9 +23,17 @@ type dependencyResolution struct {
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetDependencyCatalogForSession(sessionID, serverInstanceID string) (domain.DependencyCatalog, error) {
|
||||
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.DependencyCatalog{}, err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return domain.DependencyCatalog{}, err
|
||||
}
|
||||
if !pluginDeclares(plugin, "server.dependencies.manage") {
|
||||
return domain.DependencyCatalog{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage")
|
||||
}
|
||||
resolution, err := svc.resolveDependencyContext(serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.DependencyCatalog{}, err
|
||||
|
||||
@@ -76,6 +76,29 @@ func TestDependencyCatalogRequiresCurrentReviewedDigest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyCatalogNamesMissingPluginPermission(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
}
|
||||
permissions := plugin.DeclaredPermissions[:0]
|
||||
for _, permission := range plugin.DeclaredPermissions {
|
||||
if permission != "server.dependencies.manage" {
|
||||
permissions = append(permissions, permission)
|
||||
}
|
||||
}
|
||||
plugin.DeclaredPermissions = permissions
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.GetDependencyCatalogForSession(session, instance.ID)
|
||||
if !errors.Is(err, ErrForbidden) || !strings.Contains(err.Error(), "server.dependencies.manage") {
|
||||
t.Fatalf("expected named dependency permission denial, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyInputFencingCancellationAndTerminalProjection(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
|
||||
|
||||
@@ -455,6 +455,7 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
||||
}
|
||||
}
|
||||
bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID)
|
||||
dependencyPermissionDeclared := pluginDeclares(plugin, "server.dependencies.manage")
|
||||
actions := domain.ServerRuntimeActions{
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
@@ -468,8 +469,8 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
||||
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
|
||||
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
|
||||
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
|
||||
runtimeAction("dependencies-check", "Check dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)),
|
||||
runtimeAction("dependencies-install", "Install dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason)),
|
||||
runtimeAction("dependencies-check", "Check dependencies", dependencyPermissionDeclared && endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason))),
|
||||
runtimeAction("dependencies-install", "Install dependencies", dependencyPermissionDeclared && endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason))),
|
||||
runtimeAction("live-logs", "Live logs", pluginSupports(plugin, "logs.read"), "plugin does not declare live logs"),
|
||||
runtimeAction("historical-logs", "Historical logs", endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason)),
|
||||
},
|
||||
@@ -608,7 +609,7 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d
|
||||
}
|
||||
if !pluginDeclares(plugin, "server.dependencies.manage") {
|
||||
_ = svc.recordAuditEvent(user.ID, "dependency.install.denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency operation denied: plugin permission is not declared")
|
||||
return domain.Job{}, ErrForbidden
|
||||
return domain.Job{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage")
|
||||
}
|
||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "dependency.install.denied"); err != nil {
|
||||
return domain.Job{}, err
|
||||
@@ -726,11 +727,11 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
|
||||
func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error {
|
||||
if plugin.Status != domain.GamePluginStatusInstalled {
|
||||
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin is not installed")
|
||||
return ErrForbidden
|
||||
return forbiddenError("plugin is not installed")
|
||||
}
|
||||
if !containsString(plugin.DeclaredPermissions, permission) {
|
||||
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin permission is not declared")
|
||||
return ErrForbidden
|
||||
return forbiddenError("plugin does not declare required permission: " + permission)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -96,6 +96,42 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRuntimeActionsGateDependenciesOnPluginPermission(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
}
|
||||
permissions := plugin.DeclaredPermissions[:0]
|
||||
for _, permission := range plugin.DeclaredPermissions {
|
||||
if permission != "server.dependencies.manage" {
|
||||
permissions = append(permissions, permission)
|
||||
}
|
||||
}
|
||||
plugin.DeclaredPermissions = permissions
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin: %v", err)
|
||||
}
|
||||
|
||||
actions, err := svc.GetServerRuntimeActionsForSession(session, instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get runtime actions: %v", err)
|
||||
}
|
||||
seen := 0
|
||||
for _, action := range actions.Actions {
|
||||
if action.Key != "dependencies-check" && action.Key != "dependencies-install" {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if action.Available || action.Reason != "plugin permission is not declared" {
|
||||
t.Fatalf("expected dependency action gated by plugin permission, got %+v", action)
|
||||
}
|
||||
}
|
||||
if seen != 2 {
|
||||
t.Fatalf("expected both dependency actions in %+v", actions.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUpload(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const defaultGameClientBridgeLeaseDuration = 60 * time.Second
|
||||
|
||||
const defaultGameClientBridgeCommandRetention = 30 * 24 * time.Hour
|
||||
|
||||
type gameClientBridgeComponentSession struct {
|
||||
Session domain.ClientManagerSession
|
||||
Installation domain.ClientManagerInstallation
|
||||
}
|
||||
|
||||
func (svc *CoreService) QueueGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if !canAccessServer(user, instance) {
|
||||
return domain.GameClientBridgeCommand{}, ErrForbidden
|
||||
}
|
||||
if instance.PluginID != request.PluginID {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command plugin must match server instance")
|
||||
}
|
||||
return svc.queueGameClientBridgeCommand(user.ID, request)
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverInstanceID string) (domain.GameClientBridgeStatus, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, serverInstanceID); err != nil {
|
||||
return domain.GameClientBridgeStatus{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeStatus{}, err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeStatus{}, err
|
||||
}
|
||||
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}}
|
||||
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeStatus{}, err
|
||||
}
|
||||
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
||||
if !containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
|
||||
continue
|
||||
}
|
||||
declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: profile.Key, Reason: "compatible companion session is offline", CommandTypes: gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands), SnapshotTypes: gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots), QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates)}
|
||||
for _, installation := range installations {
|
||||
if installation.ProfileKey != profile.Key || (installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded) || installation.RequiresRedeploy {
|
||||
continue
|
||||
}
|
||||
sessions, listErr := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
|
||||
if listErr != nil {
|
||||
return domain.GameClientBridgeStatus{}, listErr
|
||||
}
|
||||
for _, session := range sessions {
|
||||
if svc.now().Before(session.ExpiresAt) && containsString(session.Capabilities, gameClientBridgeCapability) && session.KeyGeneration == installation.KeyGeneration && session.DeploymentGeneration == installation.DeploymentGeneration && session.ArtifactID == installation.ActiveArtifactID {
|
||||
declaration.Available = true
|
||||
declaration.Reason = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
status.Profiles = append(status.Profiles, declaration)
|
||||
status.Available = status.Available || declaration.Available
|
||||
}
|
||||
if len(status.Profiles) > 0 {
|
||||
status.Reason = "no compatible companion session is online"
|
||||
}
|
||||
if status.Available {
|
||||
status.Reason = ""
|
||||
}
|
||||
return domain.CopyGameClientBridgeStatus(status), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListGameClientBridgeCommandsForSession(sessionID string, filter domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(filter.ServerInstanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter.PluginID = instance.PluginID
|
||||
commands, err := svc.store.GameClientBridgeCommands().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]domain.GameClientBridgeCommand, len(commands))
|
||||
for index, command := range commands {
|
||||
result[index] = domain.CopyGameClientBridgeCommand(command)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetGameClientBridgeCommandForSession(sessionID, commandID string) (domain.GameClientBridgeCommand, error) {
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) QueryGameClientBridgeSnapshotsForSession(sessionID string, query domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error) {
|
||||
if err := validator.ValidateGameClientBridgeSnapshotQuery(query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := svc.authorizeServerLifecycle(sessionID, query.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(query.ServerInstanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if query.PluginID != instance.PluginID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
limit := query.Limit
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: query.ServerInstanceID, PluginID: query.PluginID, ProfileKey: query.ProfileKey, Type: query.Type, StreamKey: query.StreamKey, ObservedAfter: query.ObservedAfter, Limit: limit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]domain.GameClientBridgeSnapshot, len(snapshots))
|
||||
for index, snapshot := range snapshots {
|
||||
result[index] = domain.CopyGameClientBridgeSnapshot(snapshot)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func gameClientBridgeCommandDeclaration(plugin domain.GamePlugin, profileKey, commandType string) (domain.GameClientBridgeCommandDeclaration, bool) {
|
||||
profileDeclared := false
|
||||
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
||||
if profile.Key == profileKey && containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
|
||||
profileDeclared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !profileDeclared {
|
||||
return domain.GameClientBridgeCommandDeclaration{}, false
|
||||
}
|
||||
for _, declaration := range plugin.GameClientBridge.Commands {
|
||||
if declaration.Type == commandType {
|
||||
return declaration, true
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeCommandDeclaration{}, false
|
||||
}
|
||||
|
||||
func gameClientBridgeCommandTypes(declarations []domain.GameClientBridgeCommandDeclaration) []string {
|
||||
values := make([]string, len(declarations))
|
||||
for index, declaration := range declarations {
|
||||
values[index] = declaration.Type
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func gameClientBridgeSnapshotTypes(declarations []domain.GameClientBridgeSnapshotDeclaration) []string {
|
||||
seen := map[string]struct{}{}
|
||||
values := make([]string, 0, len(declarations))
|
||||
for _, declaration := range declarations {
|
||||
if _, exists := seen[declaration.Type]; exists {
|
||||
continue
|
||||
}
|
||||
seen[declaration.Type] = struct{}{}
|
||||
values = append(values, declaration.Type)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQueryTemplateDeclaration) []string {
|
||||
values := make([]string, len(declarations))
|
||||
for index, declaration := range declarations {
|
||||
values[index] = declaration.Key
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
declaration, declared := gameClientBridgeCommandDeclaration(plugin, request.ProfileKey, request.CommandType)
|
||||
if !declared {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command type is not declared for the profile")
|
||||
}
|
||||
payload, err := json.Marshal(request.Payload)
|
||||
if err != nil || len(payload) > declaration.MaxPayloadBytes {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command payload exceeds declaration")
|
||||
}
|
||||
if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout")
|
||||
}
|
||||
|
||||
existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
|
||||
if err == nil {
|
||||
return domain.CopyGameClientBridgeCommand(existing), nil
|
||||
}
|
||||
if err != nil && err != repo.ErrNotFound {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if !request.ExpiresAt.After(stamp) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expiresAt must be in the future")
|
||||
}
|
||||
approvalState := domain.GameClientBridgeApprovalNotRequired
|
||||
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelOperator {
|
||||
approvalState = domain.GameClientBridgeApprovalApproved
|
||||
}
|
||||
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
||||
approvalState = domain.GameClientBridgeApprovalPending
|
||||
if requester, requesterErr := svc.store.Users().Get(requesterID); requesterErr == nil && isPlatformAdmin(requester) {
|
||||
approvalState = domain.GameClientBridgeApprovalApproved
|
||||
}
|
||||
}
|
||||
svc.bridgeSeq++
|
||||
command := domain.GameClientBridgeCommand{
|
||||
ID: fmt.Sprintf("bridge-command-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
PluginID: request.PluginID,
|
||||
ProfileKey: request.ProfileKey,
|
||||
CommandType: request.CommandType,
|
||||
Payload: domain.CopyGameClientBridgePayload(request.Payload),
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
Priority: request.Priority,
|
||||
State: domain.GameClientBridgeCommandPending,
|
||||
ApprovalState: approvalState,
|
||||
RequesterID: requesterID,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, "queued declared game client bridge command")
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
command.AuditReferences = []string{auditID}
|
||||
if err := svc.store.GameClientBridgeCommands().Create(command); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridgeComponentSession, limit int) ([]domain.GameClientBridgeCommand, error) {
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit < 1 || limit > 50 {
|
||||
return nil, validationError("bridge claim limit must be between 1 and 50")
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, State: domain.GameClientBridgeCommandPending})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(commands, func(left, right int) bool {
|
||||
if commands[left].Priority != commands[right].Priority {
|
||||
return commands[left].Priority > commands[right].Priority
|
||||
}
|
||||
if !commands[left].CreatedAt.Equal(commands[right].CreatedAt) {
|
||||
return commands[left].CreatedAt.Before(commands[right].CreatedAt)
|
||||
}
|
||||
return commands[left].ID < commands[right].ID
|
||||
})
|
||||
claimed := make([]domain.GameClientBridgeCommand, 0, limit)
|
||||
for _, command := range commands {
|
||||
if len(claimed) == limit {
|
||||
break
|
||||
}
|
||||
if command.ApprovalState != domain.GameClientBridgeApprovalNotRequired && command.ApprovalState != domain.GameClientBridgeApprovalApproved {
|
||||
continue
|
||||
}
|
||||
fencingToken := command.Claim.FencingToken + 1
|
||||
command.State = domain.GameClientBridgeCommandClaimed
|
||||
command.Claim = domain.GameClientBridgeClaim{SessionID: component.Session.ID, InstallationID: component.Installation.ID, DeploymentGeneration: component.Session.DeploymentGeneration, FencingToken: fencingToken, LeaseExpiresAt: gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt), ClaimedAt: stamp}
|
||||
command.UpdatedAt = stamp
|
||||
auditID, auditErr := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.claim", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion claimed bridge command")
|
||||
if auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claimed = append(claimed, domain.CopyGameClientBridgeCommand(command))
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ackGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
command, err := svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
command.Claim.AcknowledgedAt = stamp
|
||||
command.Claim.LeaseExpiresAt = gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt)
|
||||
command.UpdatedAt = stamp
|
||||
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.ack", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion acknowledged bridge command")
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||
if command.Result.CompletedBy == component.Session.ID && gameClientBridgeClaimMatches(command, component, request.FencingToken) && command.Result.Status == request.Status && command.Result.Summary == request.Summary && reflect.DeepEqual(command.Result.Payload, request.Payload) {
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command already has a terminal result")
|
||||
}
|
||||
command, err = svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
switch request.Status {
|
||||
case domain.GameClientBridgeResultSucceeded:
|
||||
command.State = domain.GameClientBridgeCommandSucceeded
|
||||
case domain.GameClientBridgeResultFailed:
|
||||
command.State = domain.GameClientBridgeCommandFailed
|
||||
case domain.GameClientBridgeResultCancelled:
|
||||
command.State = domain.GameClientBridgeCommandCancelled
|
||||
}
|
||||
command.Result = domain.GameClientBridgeResult{Status: request.Status, Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload), CompletedBy: component.Session.ID, CompletedAt: stamp}
|
||||
command.CompletedAt = stamp
|
||||
command.UpdatedAt = stamp
|
||||
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.result", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion recorded terminal bridge command result")
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeCancelRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if !canAccessServer(user, instance) {
|
||||
return domain.GameClientBridgeCommand{}, ErrForbidden
|
||||
}
|
||||
if !isTerminalGameClientBridgeCommandState(command.State) && !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
||||
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
|
||||
}
|
||||
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||
if command.State == domain.GameClientBridgeCommandCancelled {
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command is already terminal")
|
||||
}
|
||||
command.State = domain.GameClientBridgeCommandCancelled
|
||||
command.Cancellation = domain.GameClientBridgeCancellation{RequestedBy: user.ID, Reason: request.Reason, CancelledAt: stamp}
|
||||
command.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled by operator", CompletedAt: stamp}
|
||||
command.CompletedAt = stamp
|
||||
command.UpdatedAt = stamp
|
||||
auditID, err := svc.recordAuditEventWithID(user.ID, "game-client-bridge.command.cancel", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "operator cancelled bridge command")
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ReconcileGameClientBridgeCommands() error {
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.pruneGameClientBridgeCommandsLocked(stamp); err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.pruneGameClientBridgeSnapshotsLocked(stamp)
|
||||
}
|
||||
|
||||
func (svc *CoreService) pruneGameClientBridgeCommandsLocked(stamp time.Time) error {
|
||||
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := map[string][]domain.GameClientBridgeCommand{}
|
||||
for _, command := range commands {
|
||||
if !isTerminalGameClientBridgeCommandState(command.State) || command.CompletedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
retention := defaultGameClientBridgeCommandRetention
|
||||
maxRecords := 0
|
||||
if plugin, pluginErr := svc.store.GamePlugins().Get(command.PluginID); pluginErr == nil {
|
||||
if plugin.GameClientBridge.Retention.KeepForSeconds > 0 {
|
||||
retention = time.Duration(plugin.GameClientBridge.Retention.KeepForSeconds) * time.Second
|
||||
}
|
||||
maxRecords = plugin.GameClientBridge.Retention.MaxRecords
|
||||
}
|
||||
if !command.CompletedAt.After(stamp.Add(-retention)) {
|
||||
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if maxRecords > 0 {
|
||||
key := command.ServerInstanceID + "\x00" + command.PluginID
|
||||
groups[key] = append(groups[key], command)
|
||||
}
|
||||
}
|
||||
for _, group := range groups {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(group[0].PluginID)
|
||||
if err != nil || plugin.GameClientBridge.Retention.MaxRecords <= 0 || len(group) <= plugin.GameClientBridge.Retention.MaxRecords {
|
||||
continue
|
||||
}
|
||||
sort.SliceStable(group, func(left, right int) bool {
|
||||
if !group[left].CompletedAt.Equal(group[right].CompletedAt) {
|
||||
return group[left].CompletedAt.After(group[right].CompletedAt)
|
||||
}
|
||||
return group[left].ID < group[right].ID
|
||||
})
|
||||
for _, command := range group[plugin.GameClientBridge.Retention.MaxRecords:] {
|
||||
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) pruneGameClientBridgeSnapshotsLocked(stamp time.Time) error {
|
||||
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := map[string][]domain.GameClientBridgeSnapshot{}
|
||||
for _, snapshot := range snapshots {
|
||||
if !snapshot.ExpiresAt.IsZero() && !snapshot.ExpiresAt.After(stamp) {
|
||||
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
key := snapshot.ServerInstanceID + "\x00" + snapshot.PluginID + "\x00" + snapshot.ProfileKey + "\x00" + snapshot.Type + "\x00" + snapshot.StreamKey
|
||||
groups[key] = append(groups[key], snapshot)
|
||||
}
|
||||
for _, group := range groups {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
maxRecords := group[0].Retention.MaxRecords
|
||||
if plugin, pluginErr := svc.store.GamePlugins().Get(group[0].PluginID); pluginErr == nil {
|
||||
if declaration, ok := gameClientBridgeSnapshotDeclaration(plugin, group[0].Type, group[0].SchemaVersion); ok {
|
||||
maxRecords = declaration.Retention.MaxRecords
|
||||
}
|
||||
}
|
||||
if maxRecords <= 0 || len(group) <= maxRecords {
|
||||
continue
|
||||
}
|
||||
sort.SliceStable(group, func(left, right int) bool {
|
||||
if group[left].Sequence != group[right].Sequence {
|
||||
return group[left].Sequence > group[right].Sequence
|
||||
}
|
||||
return group[left].ID < group[right].ID
|
||||
})
|
||||
for _, snapshot := range group[maxRecords:] {
|
||||
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) error {
|
||||
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, command := range commands {
|
||||
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||
continue
|
||||
}
|
||||
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
||||
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if command.State == domain.GameClientBridgeCommandClaimed && !command.Claim.LeaseExpiresAt.IsZero() && !command.Claim.LeaseExpiresAt.After(stamp) {
|
||||
fencingToken := command.Claim.FencingToken
|
||||
command.State = domain.GameClientBridgeCommandPending
|
||||
command.Claim = domain.GameClientBridgeClaim{FencingToken: fencingToken}
|
||||
command.UpdatedAt = stamp
|
||||
auditID, auditErr := svc.recordAuditEventWithID("platform", "game-client-bridge.command.lease-expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "expired bridge claim returned to pending")
|
||||
if auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) fencedGameClientBridgeCommand(component gameClientBridgeComponentSession, commandID string, fencingToken uint64, stamp time.Time) (domain.GameClientBridgeCommand, error) {
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if command.State != domain.GameClientBridgeCommandClaimed || !gameClientBridgeClaimMatches(command, component, fencingToken) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command claim is stale")
|
||||
}
|
||||
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
||||
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
|
||||
}
|
||||
if !command.Claim.LeaseExpiresAt.After(stamp) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command claim lease expired")
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameClientBridgeCommand, stamp time.Time) error {
|
||||
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||
return nil
|
||||
}
|
||||
command.State = domain.GameClientBridgeCommandExpired
|
||||
command.CompletedAt = stamp
|
||||
command.UpdatedAt = stamp
|
||||
auditID, err := svc.recordAuditEventWithID("platform", "game-client-bridge.command.expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "bridge command expired before completion")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||
return svc.store.GameClientBridgeCommands().Update(command)
|
||||
}
|
||||
|
||||
func gameClientBridgeClaimLeaseExpiry(stamp, commandExpiry time.Time) time.Time {
|
||||
leaseExpiry := stamp.Add(defaultGameClientBridgeLeaseDuration)
|
||||
if !commandExpiry.IsZero() && commandExpiry.Before(leaseExpiry) {
|
||||
return commandExpiry
|
||||
}
|
||||
return leaseExpiry
|
||||
}
|
||||
|
||||
func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, component gameClientBridgeComponentSession, fencingToken uint64) bool {
|
||||
return command.Claim.SessionID == component.Session.ID && command.Claim.InstallationID == component.Installation.ID && command.Claim.DeploymentGeneration == component.Session.DeploymentGeneration && command.Claim.FencingToken == fencingToken
|
||||
}
|
||||
|
||||
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
|
||||
switch state {
|
||||
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const gameClientBridgeCapability = "game-client.bridge"
|
||||
|
||||
func (svc *CoreService) ClaimGameClientBridgeCommands(request domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeClaimRequest(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.claimGameClientBridgeCommands(component, request.Limit)
|
||||
}
|
||||
|
||||
func (svc *CoreService) AckGameClientBridgeCommand(request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return svc.ackGameClientBridgeCommand(component, request)
|
||||
}
|
||||
|
||||
func (svc *CoreService) CompleteGameClientBridgeCommand(request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return svc.completeGameClientBridgeCommand(component, request)
|
||||
}
|
||||
|
||||
func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeSnapshotIngestRequest(request); err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
if request.ObservedAt.After(stamp.Add(5 * time.Minute)) {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("snapshot observedAt is too far in the future")
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(component.Installation.PluginID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
declaration, declared := gameClientBridgeSnapshotDeclaration(plugin, request.Type, request.SchemaVersion)
|
||||
if !declared {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot type and schema version are not declared")
|
||||
}
|
||||
if request.Retention != declaration.Retention {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot retention does not match declaration")
|
||||
}
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
|
||||
streamID := gameClientBridgeStreamID(component.Session.ServerInstanceID, component.Installation.PluginID, component.Session.ProfileKey, request.Type, request.StreamKey)
|
||||
latestSequence := uint64(0)
|
||||
stream, streamErr := svc.store.GameClientBridgeSnapshotStreams().Get(streamID)
|
||||
if streamErr == nil {
|
||||
latestSequence = stream.LatestSequence
|
||||
} else if streamErr != repo.ErrNotFound {
|
||||
return domain.GameClientBridgeSnapshot{}, streamErr
|
||||
}
|
||||
existing, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, Type: request.Type, StreamKey: request.StreamKey})
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
for _, snapshot := range existing {
|
||||
if snapshot.Sequence > latestSequence {
|
||||
latestSequence = snapshot.Sequence
|
||||
}
|
||||
}
|
||||
if request.Sequence <= latestSequence {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("snapshot sequence is stale")
|
||||
}
|
||||
svc.bridgeSeq++
|
||||
snapshot := domain.GameClientBridgeSnapshot{
|
||||
ID: fmt.Sprintf("bridge-snapshot-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
|
||||
ServerInstanceID: component.Session.ServerInstanceID,
|
||||
PluginID: component.Installation.PluginID,
|
||||
ProfileKey: component.Session.ProfileKey,
|
||||
Type: request.Type,
|
||||
SchemaVersion: request.SchemaVersion,
|
||||
StreamKey: request.StreamKey,
|
||||
Sequence: request.Sequence,
|
||||
SourceSessionID: component.Session.ID,
|
||||
ObservedAt: request.ObservedAt,
|
||||
Payload: domain.CopyGameClientBridgePayload(request.Payload),
|
||||
Retention: request.Retention,
|
||||
CreatedAt: stamp,
|
||||
ExpiresAt: stamp.Add(time.Duration(request.Retention.KeepForSeconds) * time.Second),
|
||||
}
|
||||
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.snapshot.ingest", "game-client-bridge-snapshot", snapshot.ID, domain.AuditResultSuccess, "companion uploaded typed bridge snapshot")
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
snapshot.AuditReferences = []string{auditID}
|
||||
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
stream = domain.GameClientBridgeSnapshotStream{ID: streamID, ServerInstanceID: snapshot.ServerInstanceID, PluginID: snapshot.PluginID, ProfileKey: snapshot.ProfileKey, Type: snapshot.Type, StreamKey: snapshot.StreamKey, LatestSequence: snapshot.Sequence, UpdatedAt: stamp}
|
||||
if streamErr == repo.ErrNotFound {
|
||||
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
} else {
|
||||
if err := svc.store.GameClientBridgeSnapshotStreams().Update(stream); err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
}
|
||||
return domain.CopyGameClientBridgeSnapshot(snapshot), nil
|
||||
}
|
||||
|
||||
func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) {
|
||||
for _, declaration := range plugin.GameClientBridge.Snapshots {
|
||||
if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion {
|
||||
return declaration, true
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeSnapshotDeclaration{}, false
|
||||
}
|
||||
|
||||
func (svc *CoreService) authorizeGameClientBridgeSession(sessionToken string) (gameClientBridgeComponentSession, error) {
|
||||
presentedHash := tokenHash(sessionToken)
|
||||
sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{Status: domain.ClientManagerSessionActive})
|
||||
if err != nil {
|
||||
return gameClientBridgeComponentSession{}, err
|
||||
}
|
||||
var session domain.ClientManagerSession
|
||||
for _, candidate := range sessions {
|
||||
if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(presentedHash)) == 1 {
|
||||
session = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
stamp := svc.now()
|
||||
if session.ID == "" || !stamp.Before(session.ExpiresAt) {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
if !containsString(session.Capabilities, gameClientBridgeCapability) {
|
||||
return gameClientBridgeComponentSession{}, ErrForbidden
|
||||
}
|
||||
installation, err := svc.store.ClientManagerInstallations().Get(session.InstallationID)
|
||||
if err != nil {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
if installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
if installation.ServerInstanceID != session.ServerInstanceID || installation.ProfileKey != session.ProfileKey || installation.RunEndpointID != session.RunEndpointID || installation.ActiveArtifactID != session.ArtifactID || installation.KeyGeneration != session.KeyGeneration || installation.DeploymentGeneration != session.DeploymentGeneration || installation.RequiresRedeploy {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
key, err := svc.activeComponentKey(session.ServerInstanceID, domain.DistributionComponentClientManager, session.ProfileKey)
|
||||
if err != nil || key.Generation != session.KeyGeneration {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
return gameClientBridgeComponentSession{Session: domain.CopyClientManagerSession(session), Installation: domain.CopyClientManagerInstallation(installation)}, nil
|
||||
}
|
||||
|
||||
func gameClientBridgeStreamID(serverInstanceID, pluginID, profileKey, snapshotType, streamKey string) string {
|
||||
digest := sha256.Sum256([]byte(serverInstanceID + "\x00" + pluginID + "\x00" + profileKey + "\x00" + snapshotType + "\x00" + streamKey))
|
||||
return "bridge-stream-" + hex.EncodeToString(digest[:16])
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func seedGameClientBridgeComponentSession(t *testing.T, svc *CoreService, now time.Time, token string) (domain.ClientManagerInstallation, domain.ClientManagerSession) {
|
||||
t.Helper()
|
||||
installation := domain.ClientManagerInstallation{ID: "installation-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", RunEndpointID: "run-1", Status: domain.ClientManagerLifecycleOnline, ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3}
|
||||
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||
key := domain.EncryptedComponentKey{ID: "key-1", ServerInstanceID: installation.ServerInstanceID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: installation.ProfileKey, Generation: installation.KeyGeneration, Status: domain.ComponentKeyStatusActive}
|
||||
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.ClientManagerSessions().Create(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.EncryptedComponentKeys().Create(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return installation, session
|
||||
}
|
||||
|
||||
func TestGameClientBridgeComponentSessionAuthorizesCommandsAndSnapshots(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const token = "component-session-token"
|
||||
_, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authorized-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token, Limit: 5})
|
||||
if err != nil || len(claimed) != 1 || claimed[0].ID != command.ID || claimed[0].Claim.SessionID != session.ID {
|
||||
t.Fatalf("authorized claim: %#v err=%v", claimed, err)
|
||||
}
|
||||
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
|
||||
t.Fatalf("authorized ack: %v", err)
|
||||
}
|
||||
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered"}); err != nil {
|
||||
t.Fatalf("authorized result: %v", err)
|
||||
}
|
||||
|
||||
snapshotRequest := domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"players": []any{}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}
|
||||
snapshot, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest)
|
||||
if err != nil || snapshot.SourceSessionID != session.ID || snapshot.Sequence != 1 {
|
||||
t.Fatalf("authorized snapshot: %#v err=%v", snapshot, err)
|
||||
}
|
||||
if snapshot.SourceSessionID == token {
|
||||
t.Fatal("raw component token persisted in snapshot")
|
||||
}
|
||||
higherSnapshotRequest := snapshotRequest
|
||||
higherSnapshotRequest.Sequence = 2
|
||||
higherSnapshot, err := svc.UploadGameClientBridgeSnapshot(higherSnapshotRequest)
|
||||
if err != nil || higherSnapshot.Sequence != 2 {
|
||||
t.Fatalf("higher snapshot sequence was not accepted: %#v err=%v", higherSnapshot, err)
|
||||
}
|
||||
equalSnapshotRequest := higherSnapshotRequest
|
||||
if _, err := svc.UploadGameClientBridgeSnapshot(equalSnapshotRequest); err == nil {
|
||||
t.Fatal("expected latest snapshot sequence to be rejected")
|
||||
}
|
||||
if _, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest); err == nil {
|
||||
t.Fatal("expected lower snapshot sequence rejection")
|
||||
}
|
||||
currentSnapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current"})
|
||||
if err != nil || len(currentSnapshots) != 2 {
|
||||
t.Fatalf("stale snapshot attempts changed current stream records: %#v err=%v", currentSnapshots, err)
|
||||
}
|
||||
isolatedStreamRequest := snapshotRequest
|
||||
isolatedStreamRequest.StreamKey = "secondary"
|
||||
if snapshot, err := svc.UploadGameClientBridgeSnapshot(isolatedStreamRequest); err != nil || snapshot.Sequence != 1 {
|
||||
t.Fatalf("independent snapshot stream did not start at sequence one: %#v err=%v", snapshot, err)
|
||||
}
|
||||
stream, err := svc.store.GameClientBridgeSnapshotStreams().Get(gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"))
|
||||
if err != nil || stream.LatestSequence != 2 {
|
||||
t.Fatalf("snapshot stream projection: %#v err=%v", stream, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeClaimCannotBeCompletedByAnotherCurrentSession(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const firstToken = "component-session-token-one"
|
||||
_, firstSession := seedGameClientBridgeComponentSession(t, svc, *clock, firstToken)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "session-owner-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: firstToken, Limit: 1})
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("first session claim: %#v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
const secondToken = "component-session-token-two"
|
||||
secondSession := firstSession
|
||||
secondSession.ID = "component-session-2"
|
||||
secondSession.TokenHash = tokenHash(secondToken)
|
||||
if err := svc.store.ClientManagerSessions().Create(secondSession); err != nil {
|
||||
t.Fatalf("create second current session: %v", err)
|
||||
}
|
||||
request := domain.GameClientBridgeAckRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}
|
||||
if _, err := svc.AckGameClientBridgeCommand(request); err == nil {
|
||||
t.Fatal("expected second session ack to be rejected")
|
||||
}
|
||||
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected second session result to be rejected")
|
||||
}
|
||||
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: firstToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
|
||||
t.Fatalf("claim owner could not ack after rejected second session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeComponentSessionRejectsMissingCapabilityAndStaleFences(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const token = "component-session-token"
|
||||
installation, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authz-1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
session.Capabilities = []string{"component.heartbeat"}
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected missing capability rejection, got %v", err)
|
||||
}
|
||||
session.Capabilities = []string{"component.heartbeat", gameClientBridgeCapability}
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installation.DeploymentGeneration++
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected deployment fence rejection, got %v", err)
|
||||
}
|
||||
installation.DeploymentGeneration = session.DeploymentGeneration
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installation.ActiveArtifactID = "artifact-2"
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected active artifact fence rejection, got %v", err)
|
||||
}
|
||||
installation.ActiveArtifactID = session.ArtifactID
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installation.KeyGeneration++
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected installation key-generation fence rejection, got %v", err)
|
||||
}
|
||||
installation.KeyGeneration = session.KeyGeneration
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key, err := svc.store.EncryptedComponentKeys().Get("key-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key.Generation++
|
||||
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected active component key-generation rejection, got %v", err)
|
||||
}
|
||||
key.Generation = session.KeyGeneration
|
||||
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
session.ExpiresAt = *clock
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"healthy": true}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected expired session rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
store := repo.NewMemoryStore()
|
||||
plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||
if err := store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("seed bridge plugin: %v", err)
|
||||
}
|
||||
svc := newCoreService(store, func() time.Time { return now })
|
||||
return svc, &now
|
||||
}
|
||||
|
||||
func bridgeQueueRequest(now time.Time, key string) domain.GameClientBridgeQueueRequest {
|
||||
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)}
|
||||
}
|
||||
|
||||
func bridgeComponent() gameClientBridgeComponentSession {
|
||||
return gameClientBridgeComponentSession{
|
||||
Session: domain.ClientManagerSession{ID: "component-session-1", ServerInstanceID: "server-1", ProfileKey: "scum-client", DeploymentGeneration: 3},
|
||||
Installation: domain.ClientManagerInstallation{ID: "installation-1", PluginID: "game.scum"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
request := bridgeQueueRequest(*clock, "announce-1")
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatalf("queue bridge command: %v", err)
|
||||
}
|
||||
duplicate, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil || duplicate.ID != command.ID {
|
||||
t.Fatalf("idempotency reuse: command=%#v err=%v", duplicate, err)
|
||||
}
|
||||
commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||
if len(commands) != 1 || len(command.AuditReferences) != 1 {
|
||||
t.Fatalf("expected one durable audited command: %#v", commands)
|
||||
}
|
||||
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 10)
|
||||
if err != nil || len(claimed) != 1 || claimed[0].State != domain.GameClientBridgeCommandClaimed || claimed[0].Claim.FencingToken != 1 {
|
||||
t.Fatalf("claim bridge command: %#v err=%v", claimed, err)
|
||||
}
|
||||
initialLeaseExpiry := claimed[0].Claim.LeaseExpiresAt
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 2}); err == nil {
|
||||
t.Fatal("expected stale fencing token rejection")
|
||||
}
|
||||
*clock = clock.Add(10 * time.Second)
|
||||
acked, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1})
|
||||
if err != nil || acked.Claim.AcknowledgedAt.IsZero() || !acked.Claim.LeaseExpiresAt.Equal(clock.Add(defaultGameClientBridgeLeaseDuration)) || !acked.Claim.LeaseExpiresAt.After(initialLeaseExpiry) {
|
||||
t.Fatalf("ack bridge command: %#v err=%v", acked, err)
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Payload: map[string]any{"sessionToken": "must-not-persist"}}); err == nil {
|
||||
t.Fatal("expected unsafe result material to be rejected")
|
||||
}
|
||||
resultRequest := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered", Payload: map[string]any{"delivered": true}}
|
||||
completed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
|
||||
if err != nil || completed.State != domain.GameClientBridgeCommandSucceeded || completed.Result.Status != domain.GameClientBridgeResultSucceeded || completed.CompletedAt.IsZero() {
|
||||
t.Fatalf("complete bridge command: %#v err=%v", completed, err)
|
||||
}
|
||||
if len(completed.AuditReferences) < 3 {
|
||||
t.Fatalf("expected queue, claim, and result audit references: %#v", completed.AuditReferences)
|
||||
}
|
||||
auditReferenceCount := len(completed.AuditReferences)
|
||||
replayed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
|
||||
if err != nil || replayed.ID != completed.ID || replayed.State != completed.State || !replayed.CompletedAt.Equal(completed.CompletedAt) || len(replayed.AuditReferences) != auditReferenceCount {
|
||||
t.Fatalf("exact terminal result retry was not idempotent: replayed=%#v err=%v", replayed, err)
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "conflict"}); err == nil {
|
||||
t.Fatal("expected conflicting terminal result rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
request := bridgeQueueRequest(*clock, "scope-key")
|
||||
first, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Payload = map[string]any{"message": "changed but same idempotency scope"}
|
||||
reused, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil || reused.ID != first.ID {
|
||||
t.Fatalf("same service idempotency scope was not reused: first=%#v reused=%#v err=%v", first, reused, err)
|
||||
}
|
||||
otherRequester, err := svc.queueGameClientBridgeCommand("user-2", request)
|
||||
if err != nil || otherRequester.ID == first.ID {
|
||||
t.Fatalf("requester was omitted from idempotency scope: %#v err=%v", otherRequester, err)
|
||||
}
|
||||
request.IdempotencyKey = "scope-key-2"
|
||||
otherKey, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil || otherKey.ID == first.ID {
|
||||
t.Fatalf("idempotency key was omitted from service scope: %#v err=%v", otherKey, err)
|
||||
}
|
||||
request.ServerInstanceID = "server-2"
|
||||
request.IdempotencyKey = "scope-key"
|
||||
otherServer, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil || otherServer.ID == first.ID {
|
||||
t.Fatalf("server was omitted from service idempotency scope: %#v err=%v", otherServer, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeLeaseReclaimAndExpiry(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "lease-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
first, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(first) != 1 {
|
||||
t.Fatalf("first claim: %#v err=%v", first, err)
|
||||
}
|
||||
*clock = clock.Add(defaultGameClientBridgeLeaseDuration + time.Second)
|
||||
second, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(second) != 1 || second[0].Claim.FencingToken != 2 {
|
||||
t.Fatalf("reclaim expired lease: %#v err=%v", second, err)
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected old claim fencing rejection")
|
||||
}
|
||||
*clock = command.ExpiresAt.Add(time.Second)
|
||||
if err := svc.ReconcileGameClientBridgeCommands(); err != nil {
|
||||
t.Fatalf("reconcile expired command: %v", err)
|
||||
}
|
||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired {
|
||||
t.Fatalf("expected expired command: %#v err=%v", expired, err)
|
||||
}
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(claimed) != 0 {
|
||||
t.Fatalf("expired command was claimable: %#v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgePendingCommandExpiresBeforeFirstClaim(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
request := bridgeQueueRequest(*clock, "pending-expiry")
|
||||
request.ExpiresAt = clock.Add(30 * time.Second)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
*clock = request.ExpiresAt
|
||||
claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
|
||||
if err != nil || len(claimed) != 0 {
|
||||
t.Fatalf("expired pending command was claimable: %#v err=%v", claimed, err)
|
||||
}
|
||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Claim.FencingToken != 0 || len(expired.AuditReferences) != 2 {
|
||||
t.Fatalf("first claim did not persist pending command expiry: %#v err=%v", expired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeExpiredLeaseRejectsMutationsBeforeReclaim(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
for _, key := range []string{"expired-lease-ack", "expired-lease-result"} {
|
||||
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, key)); err != nil {
|
||||
t.Fatalf("queue %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||
if err != nil || len(claimed) != 2 {
|
||||
t.Fatalf("claim lease-expiry commands: %#v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
*clock = claimed[0].Claim.LeaseExpiresAt
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||
t.Fatal("expected ack at claim lease expiry to be rejected")
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected result at claim lease expiry to be rejected")
|
||||
}
|
||||
for _, command := range claimed {
|
||||
protected, getErr := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if getErr != nil || protected.State != domain.GameClientBridgeCommandClaimed || !protected.Claim.AcknowledgedAt.IsZero() || protected.Result.Status != "" || !protected.CompletedAt.IsZero() || protected.Claim.FencingToken != command.Claim.FencingToken || len(protected.AuditReferences) != 2 {
|
||||
t.Fatalf("expired lease mutation changed protected command: %#v err=%v", protected, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
reclaimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||
if err != nil || len(reclaimed) != 2 {
|
||||
t.Fatalf("reclaim protected commands after lease sweep: %#v err=%v", reclaimed, err)
|
||||
}
|
||||
for _, command := range reclaimed {
|
||||
if command.Claim.FencingToken != 2 {
|
||||
t.Fatalf("reclaimed command did not advance fencing token: %#v", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeClaimMutationsExpireAtCommandDeadline(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
deadline := clock.Add(30 * time.Second)
|
||||
commands := make([]domain.GameClientBridgeCommand, 0, 2)
|
||||
for _, key := range []string{"deadline-ack", "deadline-result"} {
|
||||
request := bridgeQueueRequest(*clock, key)
|
||||
request.ExpiresAt = deadline
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatalf("queue deadline command: %v", err)
|
||||
}
|
||||
commands = append(commands, command)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||
if err != nil || len(claimed) != 2 {
|
||||
t.Fatalf("claim deadline commands: %#v err=%v", claimed, err)
|
||||
}
|
||||
for _, command := range claimed {
|
||||
if !command.Claim.LeaseExpiresAt.Equal(deadline) {
|
||||
t.Fatalf("claim lease exceeded command deadline: %#v", command.Claim)
|
||||
}
|
||||
}
|
||||
*clock = deadline
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||
t.Fatal("expected ack at command deadline to be rejected")
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected result at command deadline to be rejected")
|
||||
}
|
||||
for _, command := range commands {
|
||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || len(expired.AuditReferences) < 3 {
|
||||
t.Fatalf("deadline mutation did not persist audited expiry: %#v err=%v", expired, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeFailedResultIsPersisted(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "failed-result"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim failed-result command: %#v err=%v", claimed, err)
|
||||
}
|
||||
request := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "game window unavailable", Payload: map[string]any{"retryable": true}}
|
||||
failed, err := svc.completeGameClientBridgeCommand(component, request)
|
||||
if err != nil || failed.State != domain.GameClientBridgeCommandFailed || failed.Result.Status != domain.GameClientBridgeResultFailed || failed.Result.Summary != request.Summary || failed.Result.CompletedBy != component.Session.ID || failed.CompletedAt.IsZero() || len(failed.AuditReferences) != 3 {
|
||||
t.Fatalf("record failed result: %#v err=%v", failed, err)
|
||||
}
|
||||
persisted, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || persisted.State != domain.GameClientBridgeCommandFailed || persisted.Result.Status != domain.GameClientBridgeResultFailed || persisted.Result.Payload["retryable"] != true || !persisted.CompletedAt.Equal(failed.CompletedAt) || len(persisted.AuditReferences) != len(failed.AuditReferences) {
|
||||
t.Fatalf("failed result was not persisted: %#v err=%v", persisted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeOperatorCancellationExpiresAtCommandDeadline(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
user := domain.User{ID: "user-1", DisplayName: "Owner", Roles: []string{"server-owner"}, Status: domain.UserStatusActive, CreatedAt: *clock, UpdatedAt: *clock}
|
||||
if err := svc.store.Users().Create(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := svc.issueAuthSession(user, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := bridgeQueueRequest(*clock, "cancel-deadline")
|
||||
request.ExpiresAt = clock.Add(30 * time.Second)
|
||||
command, err := svc.queueGameClientBridgeCommand(user.ID, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*clock = request.ExpiresAt
|
||||
if _, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "too late"}); err == nil {
|
||||
t.Fatal("expected cancellation at command deadline to be rejected")
|
||||
}
|
||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Cancellation.RequestedBy != "" || len(expired.AuditReferences) != 2 {
|
||||
t.Fatalf("deadline cancellation did not preserve audited expiry: %#v err=%v", expired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
user := domain.User{ID: "user-1", DisplayName: "Owner", Roles: []string{"server-owner"}, Status: domain.UserStatusActive, CreatedAt: *clock, UpdatedAt: *clock}
|
||||
if err := svc.store.Users().Create(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := svc.issueAuthSession(user, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command, err := svc.queueGameClientBridgeCommand(user.ID, bridgeQueueRequest(*clock, "cancel-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim: %#v err=%v", claimed, err)
|
||||
}
|
||||
cancelled, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
|
||||
if err != nil || cancelled.State != domain.GameClientBridgeCommandCancelled || cancelled.Cancellation.RequestedBy != user.ID {
|
||||
t.Fatalf("cancel bridge command: %#v err=%v", cancelled, err)
|
||||
}
|
||||
auditReferenceCount := len(cancelled.AuditReferences)
|
||||
repeated, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
|
||||
if err != nil || repeated.State != domain.GameClientBridgeCommandCancelled || !repeated.Cancellation.CancelledAt.Equal(cancelled.Cancellation.CancelledAt) || len(repeated.AuditReferences) != auditReferenceCount {
|
||||
t.Fatalf("repeated cancellation was not idempotent: %#v err=%v", repeated, err)
|
||||
}
|
||||
remaining, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("cancelled command remained claimable: %#v err=%v", remaining, err)
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "late success"}); err == nil {
|
||||
t.Fatal("expected late success after cancellation rejection")
|
||||
}
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||
t.Fatal("expected late ack after cancellation rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequence(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
plugin, err := svc.store.GamePlugins().Get("game.scum")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}
|
||||
plugin.GameClientBridge.Snapshots[0].Retention = domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldCommand := domain.GameClientBridgeCommand{ID: "old-command", ServerInstanceID: "server-1", PluginID: "game.scum", State: domain.GameClientBridgeCommandSucceeded, CompletedAt: clock.Add(-2 * time.Hour)}
|
||||
if err := svc.store.GameClientBridgeCommands().Create(oldCommand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for sequence := uint64(1); sequence <= 4; sequence++ {
|
||||
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-" + string(rune('0'+sequence)), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}, ExpiresAt: clock.Add(time.Hour)}
|
||||
if sequence == 1 {
|
||||
snapshot.ExpiresAt = clock.Add(-time.Second)
|
||||
}
|
||||
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stream := domain.GameClientBridgeSnapshotStream{ID: gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", LatestSequence: 4}
|
||||
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.ReconcileGameClientBridgeCommands(); err != nil {
|
||||
t.Fatalf("reconcile bridge retention: %v", err)
|
||||
}
|
||||
if _, err := svc.store.GameClientBridgeCommands().Get(oldCommand.ID); err == nil {
|
||||
t.Fatal("old terminal command was not pruned")
|
||||
}
|
||||
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", Type: "players"})
|
||||
if err != nil || len(snapshots) != 2 || snapshots[0].Sequence != 4 || snapshots[1].Sequence != 3 {
|
||||
t.Fatalf("snapshot retention projection: %#v err=%v", snapshots, err)
|
||||
}
|
||||
retainedStream, err := svc.store.GameClientBridgeSnapshotStreams().Get(stream.ID)
|
||||
if err != nil || retainedStream.LatestSequence != 4 {
|
||||
t.Fatalf("stream sequence was reset by retention: %#v err=%v", retainedStream, err)
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectProductionOpsJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
|
||||
}
|
||||
|
||||
@@ -597,7 +600,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
||||
State: job.State,
|
||||
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
|
||||
ResultRef: job.ResultRef,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs)},
|
||||
LeaseToken: leaseToken,
|
||||
Attempt: job.Attempt,
|
||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||
|
||||
@@ -0,0 +1,992 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const (
|
||||
capacityHeartbeatStaleAfter = 2 * time.Minute
|
||||
capacityRetryAfterSeconds = 30
|
||||
capacityLogBacklogLimit = 256
|
||||
capacityArtifactBacklogLimit = 128
|
||||
aiConfigDiffTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
func (svc *CoreService) GetProductionCapacityForSession(sessionID string) (domain.ProductionCapacitySummary, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.ProductionCapacitySummary{}, err
|
||||
}
|
||||
endpoints, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{})
|
||||
if err != nil {
|
||||
return domain.ProductionCapacitySummary{}, err
|
||||
}
|
||||
visibleEndpointIDs, err := svc.visibleEndpointIDs(user)
|
||||
if err != nil {
|
||||
return domain.ProductionCapacitySummary{}, err
|
||||
}
|
||||
alerts, err := svc.store.Alerts().List(domain.AlertFilter{})
|
||||
if err != nil {
|
||||
return domain.ProductionCapacitySummary{}, err
|
||||
}
|
||||
|
||||
summary := domain.ProductionCapacitySummary{GeneratedAt: svc.now()}
|
||||
for _, endpoint := range endpoints {
|
||||
if !isPlatformAdmin(user) {
|
||||
if _, visible := visibleEndpointIDs[endpoint.ID]; !visible {
|
||||
continue
|
||||
}
|
||||
}
|
||||
running, queued, err := svc.capacityJobCounts(endpoint.ID)
|
||||
if err != nil {
|
||||
return domain.ProductionCapacitySummary{}, err
|
||||
}
|
||||
projection := svc.capacityProjection(endpoint, running, queued)
|
||||
for _, alert := range alerts {
|
||||
if alert.SourceKind == "run-endpoint" && alert.SourceID == endpoint.ID && alert.RuleKey == "capacity.pressure" && alert.State != domain.AlertStateResolved {
|
||||
projection.LastAdmissionDecision = domain.CapacityAdmissionDeferred
|
||||
projection.LastAdmissionReason = alert.Message
|
||||
projection.LastAdmissionCheckedAt = alert.LastSeenAt
|
||||
}
|
||||
}
|
||||
summary.Endpoints = append(summary.Endpoints, projection)
|
||||
summary.TotalMaxJobs += projection.MaxJobs
|
||||
summary.TotalRunningJobs += projection.RunningJobs
|
||||
summary.TotalQueuedJobs += projection.QueuedJobs
|
||||
}
|
||||
for _, alert := range alerts {
|
||||
if alert.State != domain.AlertStateResolved && svc.canAccessAlert(user, alert) {
|
||||
summary.ActiveAlerts++
|
||||
}
|
||||
}
|
||||
sort.Slice(summary.Endpoints, func(i, j int) bool { return summary.Endpoints[i].RunEndpointID < summary.Endpoints[j].RunEndpointID })
|
||||
return domain.CopyProductionCapacitySummary(summary), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) CheckCapacityAdmissionForSession(sessionID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) {
|
||||
if err := validator.ValidateCapacityAdmissionRequest(request); err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
request, err = svc.authorizeCapacityRequest(user, request)
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
return svc.checkCapacityAdmission(user.ID, request)
|
||||
}
|
||||
|
||||
func (svc *CoreService) checkCapacityAdmission(actorID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) {
|
||||
endpoint, err := svc.store.RunEndpoints().Get(request.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
running, queued, err := svc.capacityJobCounts(endpoint.ID)
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
projection := svc.capacityProjection(endpoint, running, queued)
|
||||
decision := domain.CapacityAdmissionDecision{
|
||||
Accepted: true, State: domain.CapacityAdmissionAccepted, Reason: "capacity available",
|
||||
ServerInstanceID: request.ServerInstanceID, RunEndpointID: endpoint.ID, Capability: request.Capability,
|
||||
TargetKey: request.TargetKey, MaxJobs: projection.MaxJobs, RunningJobs: projection.RunningJobs,
|
||||
QueuedJobs: projection.QueuedJobs, CheckedAt: svc.now(),
|
||||
}
|
||||
pressure := append([]domain.CapacityPressureCode(nil), projection.PressureCodes...)
|
||||
if len(validator.MissingCapabilities(endpoint.Capabilities, []string{request.Capability})) > 0 {
|
||||
pressure = appendCapacityPressure(pressure, domain.CapacityPressureCapabilityGap)
|
||||
}
|
||||
decision.PressureCodes = pressure
|
||||
|
||||
hardDenied := containsCapacityPressure(pressure, domain.CapacityPressureEndpointOffline) || containsCapacityPressure(pressure, domain.CapacityPressureCapabilityGap)
|
||||
if hardDenied {
|
||||
decision.Accepted = false
|
||||
decision.State = domain.CapacityAdmissionDenied
|
||||
decision.Reason = "endpoint is unavailable or missing the required capability"
|
||||
} else if len(pressure) > 0 {
|
||||
decision.Accepted = false
|
||||
decision.State = domain.CapacityAdmissionDeferred
|
||||
decision.Reason = "endpoint capacity is temporarily under pressure"
|
||||
decision.RetryAfterSeconds = capacityRetryAfterSeconds
|
||||
}
|
||||
|
||||
auditResult := domain.AuditResultSuccess
|
||||
auditAction := "capacity.admission.accepted"
|
||||
if !decision.Accepted {
|
||||
auditResult = domain.AuditResultDenied
|
||||
auditAction = "capacity.admission.denied"
|
||||
}
|
||||
auditID, err := svc.recordAuditEventWithID(actorID, auditAction, "run-endpoint", endpoint.ID, auditResult, decision.Reason)
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
decision.AuditEventID = auditID
|
||||
if !decision.Accepted {
|
||||
severity := domain.AlertSeverityWarning
|
||||
if hardDenied {
|
||||
severity = domain.AlertSeverityCritical
|
||||
}
|
||||
alert, err := svc.upsertAlert(domain.AlertRecord{
|
||||
SourceKind: "run-endpoint", SourceID: endpoint.ID, RuleKey: "capacity.pressure", Severity: severity,
|
||||
Title: "Run endpoint capacity admission blocked", Message: decision.Reason, Retryable: true,
|
||||
RetryAfterSeconds: decision.RetryAfterSeconds, LastAuditEventID: auditID,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
decision.AlertID = alert.ID
|
||||
} else if err := svc.resolveAlertForSource("run-endpoint", endpoint.ID, "capacity.pressure", actorID, "capacity returned to an admissible state", auditID); err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
if err := validator.ValidateCapacityAdmissionDecision(decision); err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
return domain.CopyCapacityAdmissionDecision(decision), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListAlertsForSession(sessionID string, filter domain.AlertFilter) ([]domain.AlertRecord, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alerts, err := svc.store.Alerts().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := make([]domain.AlertRecord, 0, len(alerts))
|
||||
for _, alert := range alerts {
|
||||
if svc.canAccessAlert(user, alert) {
|
||||
visible = append(visible, alert)
|
||||
}
|
||||
}
|
||||
sort.Slice(visible, func(i, j int) bool { return visible[i].UpdatedAt.After(visible[j].UpdatedAt) })
|
||||
return domain.CopyAlertRecords(visible), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) AcknowledgeAlertForSession(sessionID string, request domain.AlertAcknowledgeRequest) (domain.AlertRecord, error) {
|
||||
if err := validator.ValidateAlertAcknowledgeRequest(request); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
alert, err := svc.store.Alerts().Get(request.AlertID)
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
if !svc.canAccessAlert(user, alert) {
|
||||
return domain.AlertRecord{}, ErrForbidden
|
||||
}
|
||||
if alert.State == domain.AlertStateResolved {
|
||||
return domain.AlertRecord{}, validationError("resolved alerts cannot be acknowledged")
|
||||
}
|
||||
stamp := svc.now()
|
||||
auditID, err := svc.recordAuditEventWithID(user.ID, "alert.acknowledge", "alert", alert.ID, domain.AuditResultSuccess, defaultAlertNote(request.Note, "alert acknowledged"))
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
alert.State = domain.AlertStateAcknowledged
|
||||
alert.AcknowledgedBy = user.ID
|
||||
alert.AcknowledgedAt = stamp
|
||||
alert.LastAuditEventID = auditID
|
||||
alert.UpdatedAt = stamp
|
||||
if err := validator.ValidateAlertRecord(alert); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
if err := svc.store.Alerts().Update(alert); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
return domain.CopyAlertRecord(alert), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ResolveAlertForSession(sessionID string, request domain.AlertResolveRequest) (domain.AlertRecord, error) {
|
||||
if err := validator.ValidateAlertResolveRequest(request); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
alert, err := svc.store.Alerts().Get(request.AlertID)
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
if !svc.canAccessAlert(user, alert) {
|
||||
return domain.AlertRecord{}, ErrForbidden
|
||||
}
|
||||
if alert.State == domain.AlertStateResolved {
|
||||
return domain.CopyAlertRecord(alert), nil
|
||||
}
|
||||
stamp := svc.now()
|
||||
note := defaultAlertNote(request.Note, "alert resolved after operator review")
|
||||
auditID, err := svc.recordAuditEventWithID(user.ID, "alert.resolve", "alert", alert.ID, domain.AuditResultSuccess, note)
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
alert.State = domain.AlertStateResolved
|
||||
alert.ResolvedBy = user.ID
|
||||
alert.ResolvedAt = stamp
|
||||
alert.ResolutionNote = note
|
||||
alert.LastAuditEventID = auditID
|
||||
alert.UpdatedAt = stamp
|
||||
if err := validator.ValidateAlertRecord(alert); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
if err := svc.store.Alerts().Update(alert); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
return domain.CopyAlertRecord(alert), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) RetryAlertForSession(sessionID string, request domain.AlertRetryRequest) (domain.AlertRetryResult, error) {
|
||||
if err := validator.ValidateAlertRetryRequest(request); err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
alert, err := svc.store.Alerts().Get(request.AlertID)
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
if !svc.canAccessAlert(user, alert) {
|
||||
return domain.AlertRetryResult{}, ErrForbidden
|
||||
}
|
||||
if !alert.Retryable {
|
||||
return domain.AlertRetryResult{}, validationError("alert source is not retryable")
|
||||
}
|
||||
switch alert.SourceKind {
|
||||
case "run-endpoint":
|
||||
endpoint, err := svc.store.RunEndpoints().Get(alert.SourceID)
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
capability := firstCapacityCapability(endpoint.Capabilities)
|
||||
decision, err := svc.CheckCapacityAdmissionForSession(sessionID, domain.CapacityAdmissionRequest{RunEndpointID: endpoint.ID, Capability: capability, IdempotencyKey: request.IdempotencyKey})
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
updated, err := svc.store.Alerts().Get(alert.ID)
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: updated, Decision: decision, Status: string(decision.State)}), nil
|
||||
case "plugin-lifecycle":
|
||||
installation, err := svc.store.PluginLifecycles().Get(alert.SourceID)
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: installation.PluginID, ServerInstanceID: installation.ServerInstanceID, Operation: installation.LastOperation, TargetVersion: installation.TargetVersion, IdempotencyKey: request.IdempotencyKey, Confirmed: true})
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: alert, Decision: result.Decision, Status: result.Status}), nil
|
||||
default:
|
||||
return domain.AlertRetryResult{}, validationError("alert source does not support scoped retry")
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListPluginLifecyclesForSession(sessionID string, filter domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
installations, err := svc.store.PluginLifecycles().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := make([]domain.PluginLifecycleInstallation, 0, len(installations))
|
||||
for _, installation := range installations {
|
||||
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
||||
if err == nil && canAccessServer(user, instance) {
|
||||
visible = append(visible, installation)
|
||||
}
|
||||
}
|
||||
sort.Slice(visible, func(i, j int) bool { return visible[i].UpdatedAt.After(visible[j].UpdatedAt) })
|
||||
return domain.CopyPluginLifecycleInstallations(visible), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error) {
|
||||
if err := validator.ValidatePluginLifecycleRequest(request); err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
user, instance, err := svc.requireServerOwner(sessionID, request.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
if instance.PluginID != request.PluginID {
|
||||
return domain.PluginLifecycleResult{}, validationError("pluginId must match the server plugin")
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
if !containsString(plugin.ProductionLifecycle.Operations, string(request.Operation)) {
|
||||
return domain.PluginLifecycleResult{}, validationError("plugin lifecycle operation is not declared by the manifest")
|
||||
}
|
||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
capability, targetKey, err := pluginLifecycleDispatchMetadata(plugin, request.Operation)
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
if request.TargetVersion == "" {
|
||||
request.TargetVersion = plugin.Version
|
||||
}
|
||||
if !containsString(plugin.SupportedOS, endpoint.Platform) {
|
||||
return svc.pluginLifecycleDenied(user.ID, instance, plugin, request, "plugin is not compatible with the assigned endpoint platform")
|
||||
}
|
||||
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
installationID := pluginLifecycleInstallationID(request.PluginID, request.ServerInstanceID)
|
||||
installation, getErr := svc.store.PluginLifecycles().Get(installationID)
|
||||
if errors.Is(getErr, repo.ErrNotFound) {
|
||||
stamp := svc.now()
|
||||
installation = domain.PluginLifecycleInstallation{ID: installationID, PluginID: plugin.ID, ServerInstanceID: instance.ID, TargetVersion: request.TargetVersion, DesiredState: domain.PluginLifecycleStatePending, CurrentState: domain.PluginLifecycleStatePending, Compatibility: "compatible", DependencyState: domain.DependencyStateUnknown, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
} else if getErr != nil {
|
||||
return domain.PluginLifecycleResult{}, getErr
|
||||
}
|
||||
if err := validatePluginLifecycleTransition(installation, request); err != nil {
|
||||
return svc.pluginLifecycleDeniedLocked(user.ID, installation, request, err.Error())
|
||||
}
|
||||
|
||||
existingJob, jobErr := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey)
|
||||
if jobErr == nil {
|
||||
if existingJob.ServerInstanceID != instance.ID || existingJob.Capability != capability || existingJob.TargetKey != targetKey || existingJob.ExecutionInput.PluginID != plugin.ID || existingJob.ExecutionInput.LifecycleOperation != string(request.Operation) || existingJob.ExecutionInput.TargetVersion != request.TargetVersion {
|
||||
return domain.PluginLifecycleResult{}, validationError("idempotencyKey is already used for different plugin lifecycle inputs")
|
||||
}
|
||||
installation.JobID = existingJob.ID
|
||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: existingJob, Status: "queued"}), nil
|
||||
}
|
||||
if !errors.Is(jobErr, repo.ErrNotFound) {
|
||||
return domain.PluginLifecycleResult{}, jobErr
|
||||
}
|
||||
decision, err := svc.checkCapacityAdmission(user.ID, domain.CapacityAdmissionRequest{ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: capability, TargetKey: targetKey, IdempotencyKey: request.IdempotencyKey})
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
if !decision.Accepted {
|
||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Decision: decision, Status: string(decision.State)}), nil
|
||||
}
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: jobIDFromParts("job-plugin-lifecycle", installation.ID, request.IdempotencyKey), ServerInstanceID: instance.ID,
|
||||
RunEndpointID: endpoint.ID, Capability: capability, TargetKey: targetKey, IdempotencyKey: request.IdempotencyKey,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), PluginID: plugin.ID, LifecycleOperation: string(request.Operation), TargetVersion: request.TargetVersion},
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "plugin lifecycle operation queued"},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
installation.PreviousVersion = installation.CurrentVersion
|
||||
installation.TargetVersion = request.TargetVersion
|
||||
installation.DesiredState = desiredPluginLifecycleState(request.Operation, installation)
|
||||
installation.CurrentState = dispatchedPluginLifecycleState(request.Operation, installation.CurrentState)
|
||||
installation.LastOperation = request.Operation
|
||||
installation.JobID = job.ID
|
||||
installation.IdempotencyKey = request.IdempotencyKey
|
||||
installation.FailureReason = ""
|
||||
installation.UpdatedAt = stamp
|
||||
auditID, err := svc.recordAuditEventWithID(user.ID, "plugin.lifecycle."+string(request.Operation), "plugin-lifecycle", installation.ID, domain.AuditResultQueued, "plugin lifecycle operation admitted and queued")
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
installation.AuditEventID = auditID
|
||||
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
if errors.Is(getErr, repo.ErrNotFound) {
|
||||
err = svc.store.PluginLifecycles().Create(installation)
|
||||
} else {
|
||||
err = svc.store.PluginLifecycles().Update(installation)
|
||||
}
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: job, Decision: decision, Status: "queued"}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListAIConfigDiffsForSession(sessionID string, filter domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previews, err := svc.store.AIConfigDiffs().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := make([]domain.AIConfigDiffPreview, 0, len(previews))
|
||||
for _, preview := range previews {
|
||||
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
|
||||
if err == nil && canAccessServer(user, instance) {
|
||||
visible = append(visible, preview)
|
||||
}
|
||||
}
|
||||
sort.Slice(visible, func(i, j int) bool { return visible[i].CreatedAt.After(visible[j].CreatedAt) })
|
||||
return domain.CopyAIConfigDiffPreviews(visible), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error) {
|
||||
if err := validator.ValidateAIConfigDiffApprovalRequest(request); err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
preview, err := svc.store.AIConfigDiffs().Get(request.DiffID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
if !canAccessServer(user, instance) || (!isPlatformAdmin(user) && preview.CreatedBy != user.ID) {
|
||||
return domain.AIConfigDiffApprovalResult{}, ErrForbidden
|
||||
}
|
||||
if preview.State == domain.AIConfigDiffStateApproved {
|
||||
if preview.ApprovalIdempotencyKey != request.IdempotencyKey {
|
||||
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is already approved with another idempotency key")
|
||||
}
|
||||
job, err := svc.store.Jobs().Get(preview.JobID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
dispatch := domain.ServerConfigWriteDispatch{Job: job, Status: "queued"}
|
||||
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
|
||||
}
|
||||
if preview.State != domain.AIConfigDiffStatePending {
|
||||
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is not pending approval")
|
||||
}
|
||||
if !preview.ExpiresAt.After(svc.now()) {
|
||||
preview.State = domain.AIConfigDiffStateExpired
|
||||
preview.UpdatedAt = svc.now()
|
||||
_ = svc.store.AIConfigDiffs().Update(preview)
|
||||
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff has expired")
|
||||
}
|
||||
dispatch, err := svc.ApproveServerConfigWriteForSession(sessionID, domain.ServerConfigWriteApproval{ServerInstanceID: preview.ServerInstanceID, ExpectedConfigVersion: preview.ConfigVersion, ExpectedChecksum: preview.CurrentConfigChecksum, Key: preview.Key, ProposedContent: preview.ProposedConfig, IdempotencyKey: request.IdempotencyKey})
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
preview.State = domain.AIConfigDiffStateApproved
|
||||
preview.ApprovedBy = user.ID
|
||||
preview.ApprovedAt = stamp
|
||||
preview.ApprovalIdempotencyKey = request.IdempotencyKey
|
||||
preview.JobID = dispatch.Job.ID
|
||||
preview.UpdatedAt = stamp
|
||||
if err := svc.store.AIConfigDiffs().Update(preview); err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
if _, err := svc.recordAuditEventWithID(user.ID, "ai.config-diff.approve", "ai-config-diff", preview.ID, domain.AuditResultQueued, "approved reviewed AI config diff and queued one config write job"); err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectProductionOpsJobResult(job domain.Job, stamp time.Time) error {
|
||||
if job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" {
|
||||
return nil
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
installation, err := svc.store.PluginLifecycles().Get(pluginLifecycleInstallationID(job.ExecutionInput.PluginID, job.ServerInstanceID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if installation.JobID != job.ID {
|
||||
return nil
|
||||
}
|
||||
if job.State == domain.JobStateSucceeded {
|
||||
applyPluginLifecycleSuccess(&installation)
|
||||
installation.FailureReason = ""
|
||||
if err := svc.resolveAlertForSource("plugin-lifecycle", installation.ID, "plugin.lifecycle.failed", "run:"+job.RunEndpointID, "plugin lifecycle job completed", installation.AuditEventID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
installation.CurrentState = domain.PluginLifecycleStateFailed
|
||||
installation.FailureReason = "plugin lifecycle job did not complete successfully"
|
||||
alert, err := svc.upsertAlert(domain.AlertRecord{SourceKind: "plugin-lifecycle", SourceID: installation.ID, RuleKey: "plugin.lifecycle.failed", Severity: domain.AlertSeverityWarning, Title: "Plugin lifecycle operation failed", Message: installation.FailureReason, Retryable: true, LastJobID: job.ID, LastAuditEventID: installation.AuditEventID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
installation.AlertID = alert.ID
|
||||
}
|
||||
installation.UpdatedAt = stamp
|
||||
return svc.store.PluginLifecycles().Update(installation)
|
||||
}
|
||||
|
||||
func (svc *CoreService) persistAIConfigDiff(actorID string, provider domain.AIProvider, request domain.AIInvocationRequest, result domain.AIProviderInvocationResult) (domain.AIConfigDiffPreview, error) {
|
||||
config, err := svc.getServerConfigForUser(actorID, request.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffPreview{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
preview := domain.AIConfigDiffPreview{ID: aiConfigDiffID(request.RequestID, request.ServerInstanceID), RequestID: request.RequestID, CreatedBy: actorID, ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, ProviderID: provider.ID, Model: result.Usage.Model, Key: config.Key, ConfigVersion: config.ConfigVersion, CurrentConfigChecksum: config.Checksum, ProposedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch", State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(aiConfigDiffTTL), CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if err := validator.ValidateAIConfigDiffPreview(preview); err != nil {
|
||||
return domain.AIConfigDiffPreview{}, err
|
||||
}
|
||||
if err := svc.store.AIConfigDiffs().Create(preview); err != nil {
|
||||
if !errors.Is(err, repo.ErrDuplicate) {
|
||||
return domain.AIConfigDiffPreview{}, err
|
||||
}
|
||||
existing, getErr := svc.store.AIConfigDiffs().Get(preview.ID)
|
||||
if getErr != nil {
|
||||
return domain.AIConfigDiffPreview{}, getErr
|
||||
}
|
||||
if existing.CreatedBy != actorID || existing.ServerInstanceID != request.ServerInstanceID || existing.PluginID != request.PluginID || existing.ProposedConfig != result.SuggestedConfig {
|
||||
return domain.AIConfigDiffPreview{}, validationError("requestId is already used for a different AI config recommendation")
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) getServerConfigForUser(userID, serverInstanceID string) (domain.ServerConfig, error) {
|
||||
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.ServerConfig{}, err
|
||||
}
|
||||
user, err := svc.store.Users().Get(userID)
|
||||
if err != nil {
|
||||
return domain.ServerConfig{}, err
|
||||
}
|
||||
if !canAccessServer(user, instance) {
|
||||
return domain.ServerConfig{}, ErrForbidden
|
||||
}
|
||||
config := domain.ServerConfig{ServerInstanceID: instance.ID, ConfigVersion: instance.ConfigVersion, Format: "properties", Key: instance.ConfigKey, Content: instance.ConfigContent, Checksum: instance.ConfigChecksum, Source: "platform-derived", UpdatedAt: instance.ConfigUpdatedAt}
|
||||
if config.ConfigVersion <= 0 {
|
||||
config.ConfigVersion = 1
|
||||
}
|
||||
if config.Key == "" {
|
||||
config.Key = "server.properties"
|
||||
}
|
||||
if config.Content == "" {
|
||||
config.Content = buildLogicalServerConfig(instance)
|
||||
}
|
||||
if config.Checksum == "" {
|
||||
config.Checksum = validator.BytesChecksum([]byte(config.Content))
|
||||
}
|
||||
if config.UpdatedAt.IsZero() {
|
||||
config.UpdatedAt = svc.now()
|
||||
}
|
||||
return config, validator.ValidateServerConfig(config)
|
||||
}
|
||||
|
||||
func (svc *CoreService) authorizeCapacityRequest(user domain.User, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionRequest, error) {
|
||||
if request.ServerInstanceID != "" {
|
||||
instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID)
|
||||
if err != nil {
|
||||
return request, err
|
||||
}
|
||||
if !canAccessServer(user, instance) {
|
||||
return request, ErrForbidden
|
||||
}
|
||||
if request.RunEndpointID != "" && request.RunEndpointID != instance.RunEndpointID {
|
||||
return request, validationError("runEndpointId must match server instance")
|
||||
}
|
||||
request.RunEndpointID = instance.RunEndpointID
|
||||
return request, nil
|
||||
}
|
||||
if request.RunEndpointID == "" {
|
||||
return request, validationError("serverInstanceId or runEndpointId is required")
|
||||
}
|
||||
if isPlatformAdmin(user) {
|
||||
return request, nil
|
||||
}
|
||||
visible, err := svc.visibleEndpointIDs(user)
|
||||
if err != nil {
|
||||
return request, err
|
||||
}
|
||||
if _, ok := visible[request.RunEndpointID]; !ok {
|
||||
return request, ErrForbidden
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) visibleEndpointIDs(user domain.User) (map[string]struct{}, error) {
|
||||
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := map[string]struct{}{}
|
||||
for _, instance := range instances {
|
||||
if isPlatformAdmin(user) || canAccessServer(user, instance) {
|
||||
ids[instance.RunEndpointID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) capacityJobCounts(endpointID string) (int, int, error) {
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpointID})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
running, queued := 0, 0
|
||||
for _, job := range jobs {
|
||||
switch job.State {
|
||||
case domain.JobStateAccepted, domain.JobStateRunning:
|
||||
running++
|
||||
case domain.JobStateQueued, domain.JobStateRetrying:
|
||||
queued++
|
||||
}
|
||||
}
|
||||
return running, queued, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) capacityProjection(endpoint domain.RunEndpoint, durableRunning, durableQueued int) domain.EndpointCapacityProjection {
|
||||
running := maxInt(endpoint.Capacity.RunningJobs, durableRunning)
|
||||
queued := maxInt(endpoint.Capacity.QueuedJobs, durableQueued)
|
||||
projection := domain.EndpointCapacityProjection{RunEndpointID: endpoint.ID, DisplayName: endpoint.DisplayName, Status: endpoint.Status, Capabilities: endpoint.Capabilities, MaxJobs: endpoint.Capacity.MaxJobs, RunningJobs: running, QueuedJobs: queued, LogBacklogBatches: endpoint.Capacity.LogBacklogBatches, ArtifactBacklogChunks: endpoint.Capacity.ArtifactBacklogChunks, Summary: safeBridgeReason(endpoint.Capacity.Summary), LastHeartbeatAt: endpoint.LastHeartbeatAt}
|
||||
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
|
||||
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureEndpointOffline)
|
||||
}
|
||||
if endpoint.LastHeartbeatAt.IsZero() || svc.now().Sub(endpoint.LastHeartbeatAt) > capacityHeartbeatStaleAfter {
|
||||
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureEndpointStale)
|
||||
}
|
||||
if projection.MaxJobs <= 0 || running >= projection.MaxJobs {
|
||||
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureJobLimit)
|
||||
}
|
||||
queueLimit := maxInt(4, projection.MaxJobs*2)
|
||||
if queued >= queueLimit {
|
||||
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureQueueLimit)
|
||||
}
|
||||
if projection.LogBacklogBatches >= capacityLogBacklogLimit || projection.ArtifactBacklogChunks >= capacityArtifactBacklogLimit || len(endpoint.Capacity.PressureCodes) > 0 {
|
||||
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureBacklog)
|
||||
}
|
||||
return projection
|
||||
}
|
||||
|
||||
func (svc *CoreService) upsertAlert(candidate domain.AlertRecord) (domain.AlertRecord, error) {
|
||||
stamp := svc.now()
|
||||
candidate.ID = alertIDForSource(candidate.SourceKind, candidate.SourceID, candidate.RuleKey)
|
||||
existing, err := svc.store.Alerts().Get(candidate.ID)
|
||||
if err == nil {
|
||||
existing.Severity = candidate.Severity
|
||||
existing.State = domain.AlertStateActive
|
||||
existing.Title = candidate.Title
|
||||
existing.Message = safeBridgeReason(candidate.Message)
|
||||
existing.OccurrenceCount++
|
||||
existing.Retryable = candidate.Retryable
|
||||
existing.RetryAfterSeconds = candidate.RetryAfterSeconds
|
||||
existing.LastJobID = candidate.LastJobID
|
||||
existing.LastAuditEventID = candidate.LastAuditEventID
|
||||
existing.LastSeenAt = stamp
|
||||
existing.ResolvedBy = ""
|
||||
existing.ResolvedAt = time.Time{}
|
||||
existing.ResolutionNote = ""
|
||||
existing.UpdatedAt = stamp
|
||||
if err := validator.ValidateAlertRecord(existing); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
if err := svc.store.Alerts().Update(existing); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, repo.ErrNotFound) {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
candidate.State = domain.AlertStateActive
|
||||
candidate.Message = safeBridgeReason(candidate.Message)
|
||||
candidate.OccurrenceCount = 1
|
||||
candidate.LastSeenAt = stamp
|
||||
candidate.CreatedAt = stamp
|
||||
candidate.UpdatedAt = stamp
|
||||
if err := validator.ValidateAlertRecord(candidate); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
if err := svc.store.Alerts().Create(candidate); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) resolveAlertForSource(sourceKind, sourceID, ruleKey, actorID, note, auditID string) error {
|
||||
alert, err := svc.store.Alerts().Get(alertIDForSource(sourceKind, sourceID, ruleKey))
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alert.State == domain.AlertStateResolved {
|
||||
return nil
|
||||
}
|
||||
stamp := svc.now()
|
||||
alert.State = domain.AlertStateResolved
|
||||
alert.ResolvedBy = actorID
|
||||
alert.ResolvedAt = stamp
|
||||
alert.ResolutionNote = note
|
||||
alert.LastAuditEventID = auditID
|
||||
alert.UpdatedAt = stamp
|
||||
return svc.store.Alerts().Update(alert)
|
||||
}
|
||||
|
||||
func (svc *CoreService) canAccessAlert(user domain.User, alert domain.AlertRecord) bool {
|
||||
if isPlatformAdmin(user) {
|
||||
return true
|
||||
}
|
||||
switch alert.SourceKind {
|
||||
case "server-instance":
|
||||
instance, err := svc.store.ServerInstances().Get(alert.SourceID)
|
||||
return err == nil && canAccessServer(user, instance)
|
||||
case "run-endpoint":
|
||||
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: alert.SourceID})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, instance := range instances {
|
||||
if canAccessServer(user, instance) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case "plugin-lifecycle":
|
||||
installation, err := svc.store.PluginLifecycles().Get(alert.SourceID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
||||
return err == nil && canAccessServer(user, instance)
|
||||
case "ai-config-diff":
|
||||
preview, err := svc.store.AIConfigDiffs().Get(alert.SourceID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
|
||||
return err == nil && canAccessServer(user, instance)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
stamp := svc.now()
|
||||
installation := domain.PluginLifecycleInstallation{ID: pluginLifecycleInstallationID(plugin.ID, instance.ID), PluginID: plugin.ID, ServerInstanceID: instance.ID, TargetVersion: request.TargetVersion, DesiredState: domain.PluginLifecycleStatePending, CurrentState: domain.PluginLifecycleStateFailed, LastOperation: request.Operation, Compatibility: "incompatible", DependencyState: domain.DependencyStateUnknown, FailureReason: safeBridgeReason(reason), IdempotencyKey: request.IdempotencyKey, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if existing, err := svc.store.PluginLifecycles().Get(installation.ID); err == nil {
|
||||
installation.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
return svc.pluginLifecycleDeniedLocked(actorID, installation, request, reason)
|
||||
}
|
||||
|
||||
func (svc *CoreService) pluginLifecycleDeniedLocked(actorID string, installation domain.PluginLifecycleInstallation, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
|
||||
stamp := svc.now()
|
||||
auditID, err := svc.recordAuditEventWithID(actorID, "plugin.lifecycle.denied", "plugin-lifecycle", installation.ID, domain.AuditResultDenied, reason)
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
installation.CurrentState = domain.PluginLifecycleStateFailed
|
||||
installation.LastOperation = request.Operation
|
||||
installation.TargetVersion = request.TargetVersion
|
||||
installation.FailureReason = safeBridgeReason(reason)
|
||||
installation.AuditEventID = auditID
|
||||
installation.UpdatedAt = stamp
|
||||
alert, err := svc.upsertAlert(domain.AlertRecord{SourceKind: "plugin-lifecycle", SourceID: installation.ID, RuleKey: "plugin.lifecycle.compatibility", Severity: domain.AlertSeverityWarning, Title: "Plugin lifecycle compatibility check failed", Message: installation.FailureReason, Retryable: true, LastAuditEventID: auditID})
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
installation.AlertID = alert.ID
|
||||
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
if _, err := svc.store.PluginLifecycles().Get(installation.ID); errors.Is(err, repo.ErrNotFound) {
|
||||
err = svc.store.PluginLifecycles().Create(installation)
|
||||
} else if err == nil {
|
||||
err = svc.store.PluginLifecycles().Update(installation)
|
||||
}
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Alert: &alert, Status: "denied"}), nil
|
||||
}
|
||||
|
||||
func pluginLifecycleDispatchMetadata(plugin domain.GamePlugin, operation domain.PluginLifecycleOperation) (string, string, error) {
|
||||
switch operation {
|
||||
case domain.PluginLifecycleOperationInstall, domain.PluginLifecycleOperationUpgrade, domain.PluginLifecycleOperationRollback:
|
||||
if plugin.LifecycleActions.Install == "" {
|
||||
return "", "", validationError("plugin install action is not declared")
|
||||
}
|
||||
return domain.LifecycleCapabilityInstall, plugin.LifecycleActions.Install, nil
|
||||
case domain.PluginLifecycleOperationEnable:
|
||||
if plugin.LifecycleActions.Start == "" {
|
||||
return "", "", validationError("plugin start action is not declared")
|
||||
}
|
||||
return domain.LifecycleCapabilityStart, plugin.LifecycleActions.Start, nil
|
||||
case domain.PluginLifecycleOperationDisable, domain.PluginLifecycleOperationRetire:
|
||||
if plugin.LifecycleActions.Stop == "" {
|
||||
return "", "", validationError("plugin stop action is not declared")
|
||||
}
|
||||
return domain.LifecycleCapabilityStop, plugin.LifecycleActions.Stop, nil
|
||||
case domain.PluginLifecycleOperationDependencyCheck:
|
||||
return domain.JobCapabilityDependenciesCheck, "dependencies/plugin", nil
|
||||
default:
|
||||
return "", "", validationError("plugin lifecycle operation is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func validatePluginLifecycleTransition(installation domain.PluginLifecycleInstallation, request domain.PluginLifecycleRequest) error {
|
||||
switch request.Operation {
|
||||
case domain.PluginLifecycleOperationInstall:
|
||||
if installation.CurrentState == domain.PluginLifecycleStateInstalled || installation.CurrentState == domain.PluginLifecycleStateEnabled || installation.CurrentState == domain.PluginLifecycleStateDisabled {
|
||||
return validationError("plugin is already installed")
|
||||
}
|
||||
case domain.PluginLifecycleOperationEnable:
|
||||
if installation.CurrentState != domain.PluginLifecycleStateInstalled && installation.CurrentState != domain.PluginLifecycleStateDisabled {
|
||||
return validationError("plugin must be installed or disabled before enable")
|
||||
}
|
||||
case domain.PluginLifecycleOperationDisable:
|
||||
if installation.CurrentState != domain.PluginLifecycleStateEnabled {
|
||||
return validationError("plugin must be enabled before disable")
|
||||
}
|
||||
case domain.PluginLifecycleOperationUpgrade:
|
||||
if installation.CurrentVersion == "" || request.TargetVersion == installation.CurrentVersion {
|
||||
return validationError("upgrade requires a different target version")
|
||||
}
|
||||
case domain.PluginLifecycleOperationRollback:
|
||||
if installation.PreviousVersion == "" {
|
||||
return validationError("rollback requires a retained previous version")
|
||||
}
|
||||
case domain.PluginLifecycleOperationRetire:
|
||||
if installation.CurrentState == domain.PluginLifecycleStateRetired {
|
||||
return validationError("plugin is already retired")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func desiredPluginLifecycleState(operation domain.PluginLifecycleOperation, installation domain.PluginLifecycleInstallation) domain.PluginLifecycleState {
|
||||
switch operation {
|
||||
case domain.PluginLifecycleOperationInstall:
|
||||
return domain.PluginLifecycleStateInstalled
|
||||
case domain.PluginLifecycleOperationEnable:
|
||||
return domain.PluginLifecycleStateEnabled
|
||||
case domain.PluginLifecycleOperationDisable:
|
||||
return domain.PluginLifecycleStateDisabled
|
||||
case domain.PluginLifecycleOperationRetire:
|
||||
return domain.PluginLifecycleStateRetired
|
||||
default:
|
||||
return installation.DesiredState
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchedPluginLifecycleState(operation domain.PluginLifecycleOperation, current domain.PluginLifecycleState) domain.PluginLifecycleState {
|
||||
switch operation {
|
||||
case domain.PluginLifecycleOperationUpgrade:
|
||||
return domain.PluginLifecycleStateUpgrading
|
||||
case domain.PluginLifecycleOperationRollback:
|
||||
return domain.PluginLifecycleStateRollingBack
|
||||
case domain.PluginLifecycleOperationInstall:
|
||||
return domain.PluginLifecycleStatePending
|
||||
default:
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
func applyPluginLifecycleSuccess(installation *domain.PluginLifecycleInstallation) {
|
||||
switch installation.LastOperation {
|
||||
case domain.PluginLifecycleOperationInstall:
|
||||
installation.CurrentVersion = installation.TargetVersion
|
||||
installation.CurrentState = domain.PluginLifecycleStateInstalled
|
||||
case domain.PluginLifecycleOperationEnable:
|
||||
installation.CurrentState = domain.PluginLifecycleStateEnabled
|
||||
case domain.PluginLifecycleOperationDisable:
|
||||
installation.CurrentState = domain.PluginLifecycleStateDisabled
|
||||
case domain.PluginLifecycleOperationUpgrade:
|
||||
installation.CurrentVersion = installation.TargetVersion
|
||||
installation.CurrentState = installation.DesiredState
|
||||
if installation.CurrentState != domain.PluginLifecycleStateEnabled && installation.CurrentState != domain.PluginLifecycleStateDisabled {
|
||||
installation.CurrentState = domain.PluginLifecycleStateInstalled
|
||||
}
|
||||
case domain.PluginLifecycleOperationRollback:
|
||||
current := installation.CurrentVersion
|
||||
installation.CurrentVersion = installation.PreviousVersion
|
||||
installation.PreviousVersion = current
|
||||
installation.TargetVersion = installation.CurrentVersion
|
||||
installation.CurrentState = domain.PluginLifecycleStateInstalled
|
||||
case domain.PluginLifecycleOperationRetire:
|
||||
installation.CurrentState = domain.PluginLifecycleStateRetired
|
||||
case domain.PluginLifecycleOperationDependencyCheck:
|
||||
installation.DependencyState = domain.DependencyStatePresent
|
||||
}
|
||||
}
|
||||
|
||||
func alertIDForSource(sourceKind, sourceID, ruleKey string) string {
|
||||
sum := sha256.Sum256([]byte(sourceKind + "\x00" + sourceID + "\x00" + ruleKey))
|
||||
return "alert-" + hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
func pluginLifecycleInstallationID(pluginID, serverInstanceID string) string {
|
||||
sum := sha256.Sum256([]byte(pluginID + "\x00" + serverInstanceID))
|
||||
return "plugin-lifecycle-" + hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
func aiConfigDiffID(requestID, serverInstanceID string) string {
|
||||
sum := sha256.Sum256([]byte(requestID + "\x00" + serverInstanceID))
|
||||
return "ai-config-diff-" + hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
func appendCapacityPressure(codes []domain.CapacityPressureCode, code domain.CapacityPressureCode) []domain.CapacityPressureCode {
|
||||
if !containsCapacityPressure(codes, code) {
|
||||
return append(codes, code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func containsCapacityPressure(codes []domain.CapacityPressureCode, target domain.CapacityPressureCode) bool {
|
||||
for _, code := range codes {
|
||||
if code == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstCapacityCapability(capabilities []string) string {
|
||||
for _, capability := range capabilities {
|
||||
if strings.TrimSpace(capability) != "" {
|
||||
return capability
|
||||
}
|
||||
}
|
||||
return "control.heartbeat"
|
||||
}
|
||||
|
||||
func defaultAlertNote(note, fallback string) string {
|
||||
if strings.TrimSpace(note) == "" {
|
||||
return fallback
|
||||
}
|
||||
return safeBridgeReason(note)
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func TestProductionCapacityCreatesDurableAlertAndSupportsClosure(t *testing.T) {
|
||||
svc, session, instance := newProductionOpsFixture(t)
|
||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
t.Fatalf("get endpoint: %v", err)
|
||||
}
|
||||
endpoint.Capacity.RunningJobs = endpoint.Capacity.MaxJobs
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("update endpoint pressure: %v", err)
|
||||
}
|
||||
|
||||
decision, err := svc.CheckCapacityAdmissionForSession(session, domain.CapacityAdmissionRequest{ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityInstall, IdempotencyKey: "capacity-pressure"})
|
||||
if err != nil {
|
||||
t.Fatalf("check capacity: %v", err)
|
||||
}
|
||||
if decision.Accepted || decision.State != domain.CapacityAdmissionDeferred || decision.AlertID == "" || decision.AuditEventID == "" {
|
||||
t.Fatalf("expected durable deferred decision, got %+v", decision)
|
||||
}
|
||||
alerts, err := svc.ListAlertsForSession(session, domain.AlertFilter{State: domain.AlertStateActive})
|
||||
if err != nil || len(alerts) != 1 || alerts[0].OccurrenceCount != 1 {
|
||||
t.Fatalf("expected one active alert, got %+v err=%v", alerts, err)
|
||||
}
|
||||
acknowledged, err := svc.AcknowledgeAlertForSession(session, domain.AlertAcknowledgeRequest{AlertID: decision.AlertID, Note: "operator reviewing queue pressure"})
|
||||
if err != nil || acknowledged.State != domain.AlertStateAcknowledged || acknowledged.AcknowledgedBy == "" {
|
||||
t.Fatalf("acknowledge alert: %+v err=%v", acknowledged, err)
|
||||
}
|
||||
resolved, err := svc.ResolveAlertForSession(session, domain.AlertResolveRequest{AlertID: decision.AlertID, Note: "capacity policy reviewed"})
|
||||
if err != nil || resolved.State != domain.AlertStateResolved || resolved.ResolvedBy == "" {
|
||||
t.Fatalf("resolve alert: %+v err=%v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) {
|
||||
svc, session, instance := newProductionOpsFixture(t)
|
||||
request := domain.PluginLifecycleRequest{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Operation: domain.PluginLifecycleOperationInstall, TargetVersion: "1.0.0", IdempotencyKey: "plugin-install-v1"}
|
||||
first, err := svc.RunPluginLifecycleForSession(session, request)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch plugin install: %v", err)
|
||||
}
|
||||
second, err := svc.RunPluginLifecycleForSession(session, request)
|
||||
if err != nil {
|
||||
t.Fatalf("repeat plugin install: %v", err)
|
||||
}
|
||||
if first.Job.ID == "" || second.Job.ID != first.Job.ID {
|
||||
t.Fatalf("expected one idempotent job, got first=%+v second=%+v", first.Job, second.Job)
|
||||
}
|
||||
drift := request
|
||||
drift.TargetVersion = "1.1.0"
|
||||
if _, err := svc.RunPluginLifecycleForSession(session, drift); err == nil || !strings.Contains(err.Error(), "idempotencyKey") {
|
||||
t.Fatalf("expected immutable input conflict, got %v", err)
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(jobs) != 1 {
|
||||
t.Fatalf("expected exactly one lifecycle job, got %+v err=%v", jobs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginLifecycleBridgeDispatchesOnlyPlatformGovernedJob(t *testing.T) {
|
||||
svc, session, instance := newProductionOpsFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
}
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.lifecycle")
|
||||
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionPluginLifecycle))
|
||||
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{
|
||||
Key: "operations", Title: "Operations", Path: "/operations",
|
||||
Permissions: []string{"server.lifecycle"}, BridgeActions: []string{string(domain.PluginBridgeActionPluginLifecycle)},
|
||||
})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin bridge declaration: %v", err)
|
||||
}
|
||||
|
||||
response, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||
RequestID: "bridge-plugin-install", PluginID: plugin.ID, RouteKey: "operations",
|
||||
ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionPluginLifecycle,
|
||||
Payload: map[string]string{"operation": "install", "targetVersion": plugin.Version, "idempotencyKey": "bridge-plugin-install-v1", "confirmed": "false"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute lifecycle bridge: %v", err)
|
||||
}
|
||||
if response.Status != "queued" || response.Result["jobId"] == "" || response.Result["installationId"] == "" || response.Result["admissionState"] != string(domain.CapacityAdmissionAccepted) {
|
||||
t.Fatalf("expected Platform-governed lifecycle job, got %+v", response)
|
||||
}
|
||||
serialized := strings.ToLower(strings.Join([]string{
|
||||
response.Result["jobId"], response.Result["installationId"], response.Result["currentState"],
|
||||
response.Result["desiredState"], response.Result["alertId"], response.Result["auditEventId"],
|
||||
response.Result["admissionState"], response.Result["admissionReason"],
|
||||
}, " "))
|
||||
for _, forbidden := range []string{"password", "apikey", "token", "secret://", "baseurl", "hostpath", "socket", "pid", "dsn", "rcon", "runendpoint"} {
|
||||
if strings.Contains(serialized, forbidden) {
|
||||
t.Fatalf("bridge lifecycle result exposed forbidden fragment %q: %s", forbidden, serialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testing.T) {
|
||||
svc, session, instance := newProductionOpsFixture(t)
|
||||
provider, err := svc.CreateAIProvider(domain.AIProvider{ID: "ai-local", Name: "Local AI", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:11434/v1", Models: []string{"test-model"}, DefaultModel: "test-model", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, Status: domain.AIProviderStatusActive, RedactionPolicy: "strict"})
|
||||
if err != nil {
|
||||
t.Fatalf("create provider: %v", err)
|
||||
}
|
||||
response, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-1", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke AI: %v", err)
|
||||
}
|
||||
if response.ConfigRecommendation == nil || response.ConfigRecommendation.DiffID == "" {
|
||||
t.Fatalf("expected persisted config recommendation, got %+v", response)
|
||||
}
|
||||
jobs, _ := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if len(jobs) != 0 {
|
||||
t.Fatalf("AI recommendation must not dispatch before approval: %+v", jobs)
|
||||
}
|
||||
approved, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("approve AI diff: %v", err)
|
||||
}
|
||||
if approved.Preview.State != domain.AIConfigDiffStateApproved || approved.Dispatch.Job.ID == "" {
|
||||
t.Fatalf("expected approved diff and queued job, got %+v", approved)
|
||||
}
|
||||
repeated, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
|
||||
if err != nil || repeated.Dispatch.Job.ID != approved.Dispatch.Job.ID {
|
||||
t.Fatalf("repeat approval must return original job: %+v err=%v", repeated, err)
|
||||
}
|
||||
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if len(jobs) != 1 {
|
||||
t.Fatalf("approval must dispatch exactly one job, got %+v", jobs)
|
||||
}
|
||||
|
||||
staleResponse, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-stale", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke stale AI candidate: %v", err)
|
||||
}
|
||||
stored, err := svc.store.ServerInstances().Get(instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get server: %v", err)
|
||||
}
|
||||
stored.ConfigVersion++
|
||||
if err := svc.store.ServerInstances().Update(stored); err != nil {
|
||||
t.Fatalf("advance config revision: %v", err)
|
||||
}
|
||||
if _, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: staleResponse.ConfigRecommendation.DiffID, IdempotencyKey: "approve-stale"}); err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") {
|
||||
t.Fatalf("expected stale revision rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newProductionOpsFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
plugin.SupportedOS = []string{"linux"}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin platform: %v", err)
|
||||
}
|
||||
endpoint.Platform = "linux"
|
||||
endpoint.Architecture = "amd64"
|
||||
endpoint.LastHeartbeatAt = fixedTime
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("update endpoint metadata: %v", err)
|
||||
}
|
||||
session := createServiceUserAndLogin(t, svc, domain.User{ID: "production-owner", DisplayName: "Production Owner", Email: "production-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "production-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Production Server", State: domain.ServerInstanceStateReady})
|
||||
if err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
binding := domain.RuntimeBinding{ID: "runtime-binding-" + instance.ID, ServerInstanceID: instance.ID, PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: fixedTime, UpdatedAt: fixedTime}
|
||||
if err := svc.store.RuntimeBindings().Create(binding); err != nil && !errors.Is(err, repo.ErrDuplicate) {
|
||||
t.Fatalf("create runtime binding: %v", err)
|
||||
}
|
||||
return svc, session, instance
|
||||
}
|
||||
@@ -82,17 +82,21 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request
|
||||
if timeout > selected.TimeoutSeconds || attempts > selected.MaxAttempts {
|
||||
return domain.RemoteAdapterResult{}, validationError("remote adapter timeout or retry exceeds declaration")
|
||||
}
|
||||
inputRef := request.InputRef
|
||||
if inputRef == "" {
|
||||
inputRef = fmt.Sprintf("input://remote-adapters/%s/%s", instance.ID, request.DeclarationKey)
|
||||
}
|
||||
job := domain.Job{
|
||||
ID: jobIDFromParts("job-remote-adapter", instance.ID, request.IdempotencyKey),
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
Capability: request.Capability,
|
||||
TargetKey: request.TargetKey,
|
||||
InputRef: fmt.Sprintf("input://remote-adapters/%s/%s", instance.ID, request.DeclarationKey),
|
||||
InputRef: inputRef,
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "scoped remote adapter queued"},
|
||||
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: attempts, InitialBackoffSeconds: 2, MaxBackoffSeconds: 30},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout, Inputs: domain.CopyStringMap(request.Inputs)},
|
||||
}
|
||||
created, err := svc.CreateJob(job)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||
plugin.Permissions.RemoteAccess = true
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
|
||||
plugin.RemoteAccess = domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{capability}, DatabaseEngines: []string{"sqlite"}}
|
||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "player-lookup", Kind: "sqlite", TargetKey: "scum-db.player-lookup", Capabilities: []string{capability}})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, capability)
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := 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(session, domain.ServerInstance{ID: "server-remote-input", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Remote Input"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inputs := map[string]string{"playerId": "steam-123", "limit": "25"}
|
||||
result, err := svc.RequestRemoteAdapterForSession(session, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: "player-lookup", TargetKey: "scum-db.player-lookup", Capability: capability, IdempotencyKey: "lookup-1", InputRef: "input://scum-db/player-lookup/lookup-1", Inputs: inputs})
|
||||
if err != nil {
|
||||
t.Fatalf("request remote adapter: %v", err)
|
||||
}
|
||||
inputs["playerId"] = "mutated"
|
||||
job, err := svc.store.Jobs().Get(result.RequestID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.InputRef != "input://scum-db/player-lookup/lookup-1" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["limit"] != "25" {
|
||||
t.Fatalf("typed inputs were not propagated: %#v", job)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, capability)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-remote-inputs"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run hello: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{capability}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil {
|
||||
t.Fatalf("claim remote adapter job: %v", err)
|
||||
}
|
||||
if !claim.HasJob || claim.Job == nil || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.Inputs["playerId"] != "steam-123" || claim.Job.ExecutionInput.Inputs["limit"] != "25" {
|
||||
t.Fatalf("typed inputs were not propagated to real Run claim: %#v", claim.Job)
|
||||
}
|
||||
|
||||
response := dto.RunJobAssignmentFromDomain(*claim.Job)
|
||||
payload, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal Run assignment response: %v", err)
|
||||
}
|
||||
var wire struct {
|
||||
ExecutionInput struct {
|
||||
Inputs map[string]string `json:"inputs"`
|
||||
} `json:"executionInput"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &wire); err != nil {
|
||||
t.Fatalf("unmarshal Run assignment response: %v", err)
|
||||
}
|
||||
if wire.ExecutionInput.Inputs["playerId"] != "steam-123" || wire.ExecutionInput.Inputs["limit"] != "25" {
|
||||
t.Fatalf("typed inputs were not preserved in Run assignment JSON: %s", payload)
|
||||
}
|
||||
wire.ExecutionInput.Inputs["playerId"] = "wire-mutated"
|
||||
if claim.Job.ExecutionInput.Inputs["playerId"] != "steam-123" {
|
||||
t.Fatal("Run assignment DTO aliases domain remote inputs")
|
||||
}
|
||||
claim.Job.ExecutionInput.Inputs["playerId"] = "assignment-mutated"
|
||||
stored, err := svc.store.Jobs().Get(result.RequestID)
|
||||
if err != nil {
|
||||
t.Fatalf("get claimed remote adapter job: %v", err)
|
||||
}
|
||||
if stored.ExecutionInput.Inputs["playerId"] != "steam-123" {
|
||||
t.Fatal("real Run claim aliases persisted remote inputs")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,28 @@ var (
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
|
||||
type ForbiddenError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (err ForbiddenError) Error() string {
|
||||
if strings.TrimSpace(err.Reason) == "" {
|
||||
return ErrForbidden.Error()
|
||||
}
|
||||
return ErrForbidden.Error() + ": " + err.Reason
|
||||
}
|
||||
|
||||
func (err ForbiddenError) Is(target error) bool {
|
||||
return target == ErrForbidden
|
||||
}
|
||||
|
||||
func forbiddenError(reason string) error {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
return ErrForbidden
|
||||
}
|
||||
return ForbiddenError{Reason: reason}
|
||||
}
|
||||
|
||||
type Core interface {
|
||||
CreateUser(domain.User) (domain.User, error)
|
||||
UpdateUser(string, domain.User) (domain.User, error)
|
||||
@@ -79,6 +101,16 @@ type Core interface {
|
||||
ArchiveServerInstanceForSession(string, string) (domain.ServerInstance, error)
|
||||
GetPlatformResourceUsage() (domain.PlatformResourceUsage, error)
|
||||
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
||||
GetProductionCapacityForSession(string) (domain.ProductionCapacitySummary, error)
|
||||
CheckCapacityAdmissionForSession(string, domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error)
|
||||
ListAlertsForSession(string, domain.AlertFilter) ([]domain.AlertRecord, error)
|
||||
AcknowledgeAlertForSession(string, domain.AlertAcknowledgeRequest) (domain.AlertRecord, error)
|
||||
ResolveAlertForSession(string, domain.AlertResolveRequest) (domain.AlertRecord, error)
|
||||
RetryAlertForSession(string, domain.AlertRetryRequest) (domain.AlertRetryResult, error)
|
||||
ListPluginLifecyclesForSession(string, domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
|
||||
RunPluginLifecycleForSession(string, domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error)
|
||||
ListAIConfigDiffsForSession(string, domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
|
||||
ApproveAIConfigDiffForSession(string, domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error)
|
||||
IngestMetricBatch(domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error)
|
||||
ListMetricSamplesForSession(string, domain.MetricSampleFilter) ([]domain.MetricSample, error)
|
||||
CreateBackupForSession(string, domain.BackupRecord) (domain.BackupRecord, error)
|
||||
@@ -137,6 +169,17 @@ type Core interface {
|
||||
RegisterClientManager(domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error)
|
||||
AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error)
|
||||
ReconcileClientManagerLifecycle() error
|
||||
QueueGameClientBridgeCommandForSession(string, domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error)
|
||||
ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error)
|
||||
AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error)
|
||||
CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error)
|
||||
CancelGameClientBridgeCommandForSession(string, domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error)
|
||||
UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error)
|
||||
ReconcileGameClientBridgeCommands() error
|
||||
GetGameClientBridgeStatusForSession(string, string) (domain.GameClientBridgeStatus, error)
|
||||
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
|
||||
GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error)
|
||||
QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error)
|
||||
PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
|
||||
ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error)
|
||||
GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error)
|
||||
@@ -169,6 +212,8 @@ type CoreService struct {
|
||||
runSessions map[string]domain.RunControlSession
|
||||
runSessionSeq uint64
|
||||
jobMu sync.Mutex
|
||||
bridgeMu sync.Mutex
|
||||
bridgeSeq uint64
|
||||
logStore LogBodyStore
|
||||
artifactStore ArtifactBodyStore
|
||||
artifactMu sync.Mutex
|
||||
@@ -177,6 +222,7 @@ type CoreService struct {
|
||||
artifactTransferSeq uint64
|
||||
auditMu sync.Mutex
|
||||
auditSeq uint64
|
||||
productionMu sync.Mutex
|
||||
aiProviderClient AIProviderClient
|
||||
secretEnvelope SecretEnvelope
|
||||
}
|
||||
@@ -237,6 +283,9 @@ func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, ar
|
||||
if err := service.ReconcileClientManagerLifecycle(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := service.ReconcileGameClientBridgeCommands(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
@@ -538,13 +587,13 @@ func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult,
|
||||
|
||||
result := domain.AIProviderTestResult{
|
||||
ProviderID: provider.ID,
|
||||
Mode: "metadata",
|
||||
Mode: "provider",
|
||||
Success: true,
|
||||
Message: "metadata validation passed",
|
||||
Message: "provider invocation passed",
|
||||
}
|
||||
if err := validator.ValidateAIProvider(provider); err != nil {
|
||||
result.Success = false
|
||||
result.Message = "metadata validation failed"
|
||||
result.Message = "provider validation failed"
|
||||
var validationErr validator.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
result.Violations = append(result.Violations, validationErr.Violations...)
|
||||
@@ -554,9 +603,35 @@ func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult,
|
||||
}
|
||||
if provider.Status != domain.AIProviderStatusActive {
|
||||
result.Success = false
|
||||
result.Message = "metadata validation failed"
|
||||
result.Message = "provider validation failed"
|
||||
result.Violations = append(result.Violations, "provider must be active")
|
||||
}
|
||||
if !result.Success {
|
||||
return domain.CopyAIProviderTestResult(result), nil
|
||||
}
|
||||
_, invokeErr := svc.aiProviderClient.Invoke(provider, domain.AIInvocationRequest{RequestID: "provider-test-" + provider.ID, Purpose: "provider.health", Prompt: "Return a short health acknowledgement.", Model: provider.DefaultModel})
|
||||
if invokeErr != nil {
|
||||
result.Success = false
|
||||
result.Message = "provider invocation failed safely"
|
||||
result.Violations = []string{"provider invocation failed safely"}
|
||||
auditID, auditErr := svc.recordAuditEventWithID("platform", "ai.provider.test.failed", "ai-provider", provider.ID, domain.AuditResultFailed, result.Message)
|
||||
if auditErr != nil {
|
||||
return domain.AIProviderTestResult{}, auditErr
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
_, alertErr := svc.upsertAlert(domain.AlertRecord{SourceKind: "ai-provider", SourceID: provider.ID, RuleKey: "ai.provider.failed", Severity: domain.AlertSeverityWarning, Title: "AI provider health check failed", Message: result.Message, Retryable: false, LastAuditEventID: auditID})
|
||||
svc.productionMu.Unlock()
|
||||
if alertErr != nil {
|
||||
return domain.AIProviderTestResult{}, alertErr
|
||||
}
|
||||
} else {
|
||||
svc.productionMu.Lock()
|
||||
resolveErr := svc.resolveAlertForSource("ai-provider", provider.ID, "ai.provider.failed", "platform", "AI provider health check passed", "")
|
||||
svc.productionMu.Unlock()
|
||||
if resolveErr != nil {
|
||||
return domain.AIProviderTestResult{}, resolveErr
|
||||
}
|
||||
}
|
||||
return domain.CopyAIProviderTestResult(result), nil
|
||||
}
|
||||
|
||||
@@ -584,6 +659,7 @@ func (svc *CoreService) CreateGamePlugin(plugin domain.GamePlugin) (domain.GameP
|
||||
if plugin.Status == "" {
|
||||
plugin.Status = domain.GamePluginStatusInstalled
|
||||
}
|
||||
plugin.ProductionLifecycle = normalizedProductionLifecycle(plugin.ProductionLifecycle)
|
||||
if err := validator.ValidateGamePlugin(plugin); err != nil {
|
||||
return domain.GamePlugin{}, err
|
||||
}
|
||||
@@ -621,8 +697,10 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
|
||||
Pages: manifest.Pages,
|
||||
Tags: manifest.Tags,
|
||||
AIPurposes: manifest.AI.Purposes,
|
||||
ProductionLifecycle: manifest.ProductionLifecycle,
|
||||
RemoteAccess: manifest.RemoteAccess,
|
||||
RuntimeProfiles: manifest.RuntimeProfiles,
|
||||
GameClientBridge: manifest.GameClientBridge,
|
||||
Status: domain.GamePluginStatusInstalled,
|
||||
}
|
||||
}
|
||||
@@ -714,6 +792,8 @@ func (svc *CoreService) ExecutePluginBridgeAction(sessionID string, request doma
|
||||
base = svc.executeBridgeLogsBackfillRequest(base, plugin, instance, request.Payload)
|
||||
case domain.PluginBridgeActionClientManager:
|
||||
base = svc.executeBridgeClientManager(sessionID, base, request)
|
||||
case domain.PluginBridgeActionPluginLifecycle:
|
||||
base = svc.executeBridgePluginLifecycle(sessionID, base, request)
|
||||
case domain.PluginBridgeActionArtifactsOpen:
|
||||
base = svc.executeBridgeArtifactOpen(sessionID, base, request)
|
||||
case domain.PluginBridgeActionAIInvoke:
|
||||
@@ -775,7 +855,6 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
|
||||
RouteKey: request.RouteKey,
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
Purpose: request.AIPurpose,
|
||||
ProviderID: request.Payload["providerId"],
|
||||
Model: request.Payload["model"],
|
||||
Prompt: defaultBridgeValue(request.Payload["prompt"], "Review the current server context and provide a safe recommendation."),
|
||||
CurrentConfig: request.Payload["currentConfig"],
|
||||
@@ -795,6 +874,9 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
|
||||
if response.ConfigRecommendation != nil {
|
||||
base.Result["suggestedConfig"] = response.ConfigRecommendation.SuggestedConfig
|
||||
base.Result["diffSummary"] = response.ConfigRecommendation.DiffSummary
|
||||
base.Result["diffId"] = response.ConfigRecommendation.DiffID
|
||||
base.Result["key"] = response.ConfigRecommendation.Key
|
||||
base.Result["expiresAt"] = response.ConfigRecommendation.ExpiresAt
|
||||
}
|
||||
if response.Error != nil {
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
|
||||
@@ -802,6 +884,22 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
|
||||
return base
|
||||
}
|
||||
|
||||
func (svc *CoreService) executeBridgePluginLifecycle(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
|
||||
confirmed, err := strconv.ParseBool(defaultBridgeValue(request.Payload["confirmed"], "false"))
|
||||
if err != nil {
|
||||
base.Status = "error"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "confirmed must be true or false"}
|
||||
return base
|
||||
}
|
||||
result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: request.PluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Payload["operation"]), TargetVersion: request.Payload["targetVersion"], IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID), Confirmed: confirmed})
|
||||
if err != nil {
|
||||
return bridgeExecutionError(base, err)
|
||||
}
|
||||
base.Status = result.Status
|
||||
base.Result = map[string]string{"installationId": result.Installation.ID, "currentState": string(result.Installation.CurrentState), "desiredState": string(result.Installation.DesiredState), "jobId": result.Job.ID, "alertId": result.Installation.AlertID, "auditEventId": result.Installation.AuditEventID, "admissionState": string(result.Decision.State), "admissionReason": result.Decision.Reason}
|
||||
return base
|
||||
}
|
||||
|
||||
func (svc *CoreService) executeBridgeJobDispatch(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
|
||||
capability := strings.TrimSpace(payload["capability"])
|
||||
if capability == "" {
|
||||
@@ -951,7 +1049,53 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base
|
||||
}
|
||||
timeoutSeconds, _ := strconv.Atoi(payload["timeoutSeconds"])
|
||||
maxAttempts, _ := strconv.Atoi(payload["maxAttempts"])
|
||||
result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: declarationKey, TargetKey: payload["targetKey"], Capability: capability, TimeoutSeconds: timeoutSeconds, MaxAttempts: maxAttempts, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID)})
|
||||
inputs := map[string]string{}
|
||||
for key, value := range payload {
|
||||
if strings.HasPrefix(key, "input.") {
|
||||
inputs[strings.TrimPrefix(key, "input.")] = value
|
||||
}
|
||||
}
|
||||
if capability == domain.JobCapabilityRemoteRunDBSQLiteQuery {
|
||||
templateKey := strings.TrimSpace(inputs["templateKey"])
|
||||
if templateKey == "" {
|
||||
base.Status = "error"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "input.templateKey is required for sqlite query requests"}
|
||||
return base
|
||||
}
|
||||
template, reason := findBridgeQueryTemplate(plugin, base.RouteKey, templateKey)
|
||||
if reason != "" {
|
||||
base.Status = "denied"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "query_template_denied", Message: reason}
|
||||
return base
|
||||
}
|
||||
if template.Engine != "sqlite" || template.TransportKey != declarationKey || template.TargetKey != payload["targetKey"] {
|
||||
base.Status = "denied"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "query_template_denied", Message: "query template transport or target is not approved"}
|
||||
return base
|
||||
}
|
||||
if timeoutSeconds == 0 {
|
||||
timeoutSeconds = template.TimeoutSeconds
|
||||
} else if timeoutSeconds > template.TimeoutSeconds {
|
||||
base.Status = "error"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "query template timeout limit exceeded"}
|
||||
return base
|
||||
}
|
||||
maxRows := template.MaxRows
|
||||
if requestedRows, ok := inputs["maxRows"]; ok && strings.TrimSpace(requestedRows) != "" {
|
||||
parsedRows, parseErr := strconv.Atoi(requestedRows)
|
||||
if parseErr != nil || parsedRows <= 0 {
|
||||
base.Status = "error"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "input.maxRows must be a positive integer"}
|
||||
return base
|
||||
}
|
||||
if parsedRows < maxRows {
|
||||
maxRows = parsedRows
|
||||
}
|
||||
}
|
||||
inputs["templateKey"] = template.Key
|
||||
inputs["maxRows"] = strconv.Itoa(maxRows)
|
||||
}
|
||||
result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: declarationKey, TargetKey: payload["targetKey"], Capability: capability, TimeoutSeconds: timeoutSeconds, MaxAttempts: maxAttempts, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), InputRef: payload["inputRef"], Inputs: inputs})
|
||||
if err != nil {
|
||||
return bridgeExecutionError(base, err)
|
||||
}
|
||||
@@ -967,6 +1111,46 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base
|
||||
return base
|
||||
}
|
||||
|
||||
func findBridgeQueryTemplate(plugin domain.GamePlugin, routeKey string, templateKey string) (domain.GameClientBridgeQueryTemplateDeclaration, string) {
|
||||
pageFound := false
|
||||
pageAllowsTemplate := false
|
||||
for _, page := range plugin.GameClientBridge.Pages {
|
||||
if page.PageKey != routeKey {
|
||||
continue
|
||||
}
|
||||
pageFound = true
|
||||
if containsString(page.QueryTemplateKeys, templateKey) {
|
||||
pageAllowsTemplate = true
|
||||
}
|
||||
}
|
||||
if !pageFound || !pageAllowsTemplate {
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template is not declared by the bridge page"
|
||||
}
|
||||
var selected domain.GameClientBridgeQueryTemplateDeclaration
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.Key == templateKey {
|
||||
selected = template
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected.Key == "" {
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template is not declared by the plugin"
|
||||
}
|
||||
for _, page := range plugin.Pages {
|
||||
if page.Key != routeKey {
|
||||
continue
|
||||
}
|
||||
if !containsString(page.Permissions, selected.Permission) {
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template permission is not declared by the plugin page"
|
||||
}
|
||||
if !containsString(page.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) {
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template page does not declare remote access"
|
||||
}
|
||||
return selected, ""
|
||||
}
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template plugin page is not declared"
|
||||
}
|
||||
|
||||
func (svc *CoreService) executeBridgeRunDistribution(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
|
||||
distribution, err := svc.GenerateRunDistributionForSession(sessionID, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
@@ -1278,14 +1462,29 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark
|
||||
Pages: plugin.Pages,
|
||||
Tags: plugin.Tags,
|
||||
AIPurposes: plugin.AIPurposes,
|
||||
ProductionLifecycle: plugin.ProductionLifecycle,
|
||||
RemoteAccess: plugin.RemoteAccess,
|
||||
RuntimeProfiles: plugin.RuntimeProfiles,
|
||||
GameClientBridge: plugin.GameClientBridge,
|
||||
ValidationViolations: plugin.ValidationViolations,
|
||||
Status: plugin.Status,
|
||||
Source: "platform-registry",
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedProductionLifecycle(lifecycle domain.GamePluginProductionLifecycle) domain.GamePluginProductionLifecycle {
|
||||
if len(lifecycle.Operations) == 0 {
|
||||
lifecycle.Operations = []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}
|
||||
}
|
||||
if lifecycle.DependencyPolicy == "" {
|
||||
lifecycle.DependencyPolicy = "optional"
|
||||
}
|
||||
if len(lifecycle.ApprovalRequired) == 0 {
|
||||
lifecycle.ApprovalRequired = []string{"disable", "rollback", "retire"}
|
||||
}
|
||||
return lifecycle
|
||||
}
|
||||
|
||||
func marketplacePluginMatchesKeyword(plugin domain.PluginMarketplacePlugin, keyword string) bool {
|
||||
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||||
if keyword == "" {
|
||||
|
||||
@@ -757,7 +757,7 @@ func TestCoreServiceManagesAIProviderMetadata(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("test enabled provider: %v", err)
|
||||
}
|
||||
if !testResult.Success || testResult.Mode != "metadata" {
|
||||
if !testResult.Success || testResult.Mode != "provider" {
|
||||
t.Fatalf("expected metadata test success, got %+v", testResult)
|
||||
}
|
||||
|
||||
@@ -1022,6 +1022,147 @@ func TestCoreServiceRemoteAccessRequiresPluginDeclaration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceDispatchesDeclaredSQLiteQueryTemplate(t *testing.T) {
|
||||
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
|
||||
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||
RequestID: "query-template-dispatch-1",
|
||||
PluginID: plugin.ID,
|
||||
RouteKey: "remote",
|
||||
ServerInstanceID: instance.ID,
|
||||
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||
Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
"declarationKey": "scum-db-read",
|
||||
"targetKey": "scum-db.player-lookup",
|
||||
"idempotencyKey": "query-template-dispatch-1",
|
||||
"input.templateKey": "players.by-id",
|
||||
"input.playerId": "steam-123",
|
||||
"input.maxRows": "100",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute declared sqlite query template: %v", err)
|
||||
}
|
||||
if queued.Status != "queued" || queued.Result["jobId"] == "" {
|
||||
t.Fatalf("expected queued query template job, got %+v", queued)
|
||||
}
|
||||
job, err := svc.store.Jobs().Get(queued.Result["jobId"])
|
||||
if err != nil {
|
||||
t.Fatalf("get query template job: %v", err)
|
||||
}
|
||||
if job.ExecutionInput.TimeoutSeconds != 20 {
|
||||
t.Fatalf("expected template timeout 20, got %+v", job.ExecutionInput)
|
||||
}
|
||||
if job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["maxRows"] != "25" {
|
||||
t.Fatalf("expected typed bounded query template inputs, got %#v", job.ExecutionInput.Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceDeniesUndeclaredOrMismatchedSQLiteQueryTemplateBeforeJob(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
templateKey string
|
||||
declarationKey string
|
||||
targetKey string
|
||||
}{
|
||||
{name: "undeclared template", templateKey: "players.unknown", declarationKey: "scum-db-read", targetKey: "scum-db.player-lookup"},
|
||||
{name: "mismatched transport", templateKey: "players.by-id", declarationKey: "other-transport", targetKey: "scum-db.player-lookup"},
|
||||
{name: "mismatched target", templateKey: "players.by-id", declarationKey: "scum-db-read", targetKey: "scum-db.other"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||
RequestID: "query-template-denied-1",
|
||||
PluginID: plugin.ID,
|
||||
RouteKey: "remote",
|
||||
ServerInstanceID: instance.ID,
|
||||
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||
Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
"declarationKey": test.declarationKey,
|
||||
"targetKey": test.targetKey,
|
||||
"idempotencyKey": "query-template-denied-1",
|
||||
"input.templateKey": test.templateKey,
|
||||
"input.playerId": "steam-123",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute denied sqlite query template: %v", err)
|
||||
}
|
||||
if result.Status != "denied" || result.Error == nil || result.Error.Code != "query_template_denied" {
|
||||
t.Fatalf("expected query template denial, got %+v", result)
|
||||
}
|
||||
jobs, err := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("list jobs after denial: %v", err)
|
||||
}
|
||||
if len(jobs) != 0 {
|
||||
t.Fatalf("query template denial created jobs: %+v", jobs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBridgeQueryTemplateRequiresPagePermissionAndRemoteAction(t *testing.T) {
|
||||
_, plugin, _, _, _ := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].Permission = "server.game-client.read"
|
||||
|
||||
for index := range plugin.Pages {
|
||||
if plugin.Pages[index].Key == "remote" {
|
||||
plugin.Pages[index].Permissions = []string{"server.remote.access"}
|
||||
}
|
||||
}
|
||||
if _, reason := findBridgeQueryTemplate(plugin, "remote", "players.by-id"); !strings.Contains(reason, "permission") {
|
||||
t.Fatalf("expected query template permission denial, got %q", reason)
|
||||
}
|
||||
|
||||
for index := range plugin.Pages {
|
||||
if plugin.Pages[index].Key == "remote" {
|
||||
plugin.Pages[index].Permissions = []string{"server.remote.access"}
|
||||
plugin.Pages[index].BridgeActions = nil
|
||||
}
|
||||
}
|
||||
plugin.GameClientBridge.QueryTemplates[0].Permission = "server.remote.access"
|
||||
if _, reason := findBridgeQueryTemplate(plugin, "remote", "players.by-id"); !strings.Contains(reason, "remote access") {
|
||||
t.Fatalf("expected query template remote access denial, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsArbitrarySQLBridgeInputBeforeJob(t *testing.T) {
|
||||
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
|
||||
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||
RequestID: "query-template-sql-rejected-1",
|
||||
PluginID: plugin.ID,
|
||||
RouteKey: "remote",
|
||||
ServerInstanceID: instance.ID,
|
||||
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||
Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
"declarationKey": "scum-db-read",
|
||||
"targetKey": "scum-db.player-lookup",
|
||||
"idempotencyKey": "query-template-sql-rejected-1",
|
||||
"input.templateKey": "players.by-id",
|
||||
"input.sqlText": "SELECT * FROM users",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute arbitrary SQL bridge input: %v", err)
|
||||
}
|
||||
if result.Status != "error" || result.Error == nil || !strings.Contains(strings.ToLower(result.Error.Message), "unsafe") {
|
||||
t.Fatalf("expected arbitrary SQL input rejection, got %+v", result)
|
||||
}
|
||||
jobs, listErr := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if listErr != nil {
|
||||
t.Fatalf("list jobs after arbitrary SQL rejection: %v", listErr)
|
||||
}
|
||||
if len(jobs) != 0 {
|
||||
t.Fatalf("arbitrary SQL rejection created jobs: %+v", jobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsDuplicateGamePluginManifest(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
registration := validPluginManifestRegistration()
|
||||
@@ -1212,6 +1353,80 @@ func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlug
|
||||
return plugin, endpoint
|
||||
}
|
||||
|
||||
func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlugin, domain.RunEndpoint, string, domain.ServerInstance) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
|
||||
plugin.Permissions.RemoteAccess = true
|
||||
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
||||
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{
|
||||
Key: "remote",
|
||||
Title: "Remote",
|
||||
Path: "/remote",
|
||||
Permissions: []string{"server.remote.access"},
|
||||
BridgeActions: []string{string(domain.PluginBridgeActionRemoteAccessRequest)},
|
||||
})
|
||||
plugin.RemoteAccess = domain.GamePluginRemoteAccess{
|
||||
Methods: []string{"run"},
|
||||
RunCapabilities: []string{capability},
|
||||
DatabaseEngines: []string{"sqlite"},
|
||||
}
|
||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{
|
||||
Key: "scum-db-read",
|
||||
Kind: "sqlite",
|
||||
TargetKey: "scum-db.player-lookup",
|
||||
Capabilities: []string{capability},
|
||||
})
|
||||
plugin.GameClientBridge = domain.GameClientBridgeManifest{
|
||||
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{
|
||||
{
|
||||
Key: "players.by-id",
|
||||
Title: "Player lookup",
|
||||
Permission: "server.remote.access",
|
||||
Engine: "sqlite",
|
||||
TransportKey: "scum-db-read",
|
||||
TargetKey: "scum-db.player-lookup",
|
||||
ParameterSchemaRef: "schemas/queries/players.by-id.parameters.schema.json",
|
||||
ResultSchemaRef: "schemas/queries/players.by-id.result.schema.json",
|
||||
MaxRows: 25,
|
||||
TimeoutSeconds: 20,
|
||||
},
|
||||
},
|
||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100},
|
||||
Pages: []domain.GameClientBridgePageContract{
|
||||
{PageKey: "remote", QueryTemplateKeys: []string{"players.by-id"}},
|
||||
},
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update sqlite query plugin fixture: %v", err)
|
||||
}
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, capability)
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("update sqlite query endpoint fixture: %v", err)
|
||||
}
|
||||
session := createServiceUserAndLogin(t, svc, domain.User{
|
||||
ID: "user-query-owner",
|
||||
DisplayName: "Query Owner",
|
||||
Email: "query-owner@example.test",
|
||||
Roles: []string{"server-owner"},
|
||||
PasswordHash: "secret-password",
|
||||
})
|
||||
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{
|
||||
ID: "server-query-template",
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Name: "Query Template Server",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlite query server fixture: %v", err)
|
||||
}
|
||||
return svc, plugin, endpoint, session, instance
|
||||
}
|
||||
|
||||
func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domain.ServerInstance, profileKey string) domain.RuntimeBinding {
|
||||
t.Helper()
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
@@ -1269,8 +1484,9 @@ func validPluginManifestRegistration() domain.GamePluginManifestRegistration {
|
||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
||||
},
|
||||
},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}},
|
||||
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}},
|
||||
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user