Files
browser/platform/service/plugin_operations.go
T

443 lines
21 KiB
Go

package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const (
aiConfigDiffTTL = 30 * time.Minute
)
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.pluginOperationsMu.Lock()
defer svc.pluginOperationsMu.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
}
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
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, 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.pluginOperationsMu.Lock()
defer svc.pluginOperationsMu.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
}
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
}
func (svc *CoreService) projectPluginOperationsJobResult(job domain.Job, stamp time.Time) error {
if !strings.HasPrefix(job.ID, "job-plugin-lifecycle-") || job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" {
return nil
}
svc.pluginOperationsMu.Lock()
defer svc.pluginOperationsMu.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 = ""
} else {
installation.CurrentState = domain.PluginLifecycleStateFailed
installation.FailureReason = "plugin lifecycle job did not complete successfully"
}
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) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
svc.pluginOperationsMu.Lock()
defer svc.pluginOperationsMu.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()
installation.CurrentState = domain.PluginLifecycleStateFailed
installation.LastOperation = request.Operation
installation.TargetVersion = request.TargetVersion
installation.FailureReason = safeBridgeReason(reason)
installation.UpdatedAt = stamp
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
return domain.PluginLifecycleResult{}, err
}
_, getErr := svc.store.PluginLifecycles().Get(installation.ID)
switch {
case errors.Is(getErr, repo.ErrNotFound):
getErr = svc.store.PluginLifecycles().Create(installation)
case getErr == nil:
getErr = svc.store.PluginLifecycles().Update(installation)
}
if getErr != nil {
return domain.PluginLifecycleResult{}, getErr
}
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, 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 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])
}