304 lines
14 KiB
Go
304 lines
14 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"
|
|
)
|
|
|
|
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.GetRunEndpoint(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) 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) 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 aiConfigWriteIdempotencyKey(requestID, serverInstanceID string) string {
|
|
sum := sha256.Sum256([]byte("ai-config-write\x00" + requestID + "\x00" + serverInstanceID))
|
|
return "ai-config-write-" + hex.EncodeToString(sum[:16])
|
|
}
|